Tests/VSAModule.Parallel.Tests.ps1
|
# Installs a fake HttpMessageHandler in place of the network (used by the F-78 pump tests below). . (Join-Path $PSScriptRoot 'VSAFakeHttp.ps1') BeforeAll { $script:ModuleRoot = Split-Path -Path (Split-Path -Parent $PSCommandPath) Import-Module (Join-Path $script:ModuleRoot 'VSAModule.psd1') -Force } Describe "Parallel pump restarts 401'd streams after a single renewal (F-78)" { # When the session dies mid fan-out, every in-flight stream 401s at once. The pump must renew the # token ONCE (not once per stream) and restart each failed stream with the fresh token -- and that # auth renewal must NOT consume the transient-retry budget reserved for 429/502/503/504. AfterEach { InModuleScope VSAModule { $script:VSAHttpClients.Clear() } } It "recovers every stream from a simultaneous mid-run 401 with exactly one renewal" { InModuleScope VSAModule { $script:renewals = 0 # $getToken calls Update-VSAConnection (no Force); $forceRenew calls it with -Force. Mock Update-VSAConnection { if ($Force) { $script:renewals++ } } $h = [FakeHttpMessageHandler]::new() # The initial dispatch of all 3 streams 401s; after the single renewal the retries succeed. 1..3 | ForEach-Object { $h.EnqueueResponse(401, '', 0) } 1..3 | ForEach-Object { $h.EnqueueResponse(200, '{"Result":[1],"ResponseCode":0,"Status":"OK"}', 0) } $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h) $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $conn = [VSAConnection]::new('https://h', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false) $reqs = 1..3 | ForEach-Object { @{ Id = "$_"; Uri = "https://h/x/$_" } } $res = Invoke-VSAParallelRequest -Request $reqs -VSAConnection $conn -ThrottleLimit 3 -MaxRetries 3 -WarningAction SilentlyContinue @($res | Where-Object { $null -eq $_.Error }).Count | Should -Be 3 -Because 'all three streams recover after renewal' $script:renewals | Should -Be 1 -Because 'single-flight: one renewal serves all three simultaneous 401s, not three' } } It "a 401 does not consume the transient-retry budget: renew, THEN still tolerate a 503" { InModuleScope VSAModule { Mock Update-VSAConnection { } $h = [FakeHttpMessageHandler]::new() # One stream: 401 (auth), then 503 (transient), then 200. With the budgets separated, the # 503 still has its full transient allowance after the 401 renewal, so it recovers. $h.EnqueueResponse(401, '', 0) $h.EnqueueResponse(503, '', 0) $h.EnqueueResponse(200, '{"Result":[1],"ResponseCode":0,"Status":"OK"}', 0) $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h) $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $conn = [VSAConnection]::new('https://h', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false) $res = Invoke-VSAParallelRequest -Request @(@{ Id = '1'; Uri = 'https://h/x/1' }) -VSAConnection $conn -ThrottleLimit 1 -MaxRetries 1 -WarningAction SilentlyContinue # MaxRetries=1: if the 401 had consumed it, the later 503 would fail. It recovers because # auth renewals use a separate budget. @($res | Where-Object { $null -eq $_.Error }).Count | Should -Be 1 } } It "a genuinely revoked PAT (renewed token still 401s) terminates instead of looping" { InModuleScope VSAModule { Mock Update-VSAConnection { } $h = [FakeHttpMessageHandler]::new() 1..20 | ForEach-Object { $h.EnqueueResponse(401, '', 0) } # every attempt 401s $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h) $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $conn = [VSAConnection]::new('https://h', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false) $res = Invoke-VSAParallelRequest -Request @(@{ Id = '1'; Uri = 'https://h/x/1' }) -VSAConnection $conn -ThrottleLimit 1 -MaxRetries 3 -WarningAction SilentlyContinue # Bounded by the auth-renewal cap: it fails typed (401) rather than renewing forever. $res.Count | Should -Be 1 $res[0].Error | Should -Not -BeNullOrEmpty $res[0].Error.Exception.StatusCode | Should -Be 401 } } } Describe "Parallel paging threshold (Invoke-VSARestMethod)" { # A page-1 envelope with a large TotalRecords, and no-op token renewal. BeforeEach { InModuleScope VSAModule { Mock Update-VSAConnection { } Mock Get-RequestData { [pscustomobject]@{ Result = @(1..100); TotalRecords = 5000; ResponseCode = '0'; Status = 'OK' } } Mock Invoke-VSAParallelRequest { # One canned 100-record page per requested skip. foreach ($r in $Request) { [pscustomobject]@{ Id = $r.Id; Response = [pscustomobject]@{ Result = @(1..100) }; Error = $null } } } } } It "engages the pump when TotalRecords >= auto threshold (2*throttle*pageSize)" { InModuleScope VSAModule { $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' $out = Invoke-VSARestMethod -URISuffix 'api/v1.0/things' -VSAConnection $conn -Parallel -ThrottleLimit 8 Should -Invoke Invoke-VSAParallelRequest -Times 1 -Exactly $out.Count | Should -Be 5000 # page1 (100) + 49 parallel pages * 100 } } It "stays sequential when below an explicit ParallelThreshold" { InModuleScope VSAModule { $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' Invoke-VSARestMethod -URISuffix 'api/v1.0/things' -VSAConnection $conn -Parallel -ParallelThreshold 999999 | Out-Null Should -Invoke Invoke-VSAParallelRequest -Times 0 -Exactly # Sequential path fetches the remaining 49 pages one by one via Get-RequestData. Should -Invoke Get-RequestData -Times 50 -Exactly } } It "never engages the pump without -Parallel (default behaviour unchanged)" { InModuleScope VSAModule { $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' Invoke-VSARestMethod -URISuffix 'api/v1.0/things' -VSAConnection $conn | Out-Null Should -Invoke Invoke-VSAParallelRequest -Times 0 -Exactly } } It "does not engage the pump for a single-page collection, even below an explicit threshold" { InModuleScope VSAModule { # Regression: -ParallelThreshold 1 on a collection that fits in one page used to pass the # threshold test, build an EMPTY pages-2..N list, and hand it to the pump -- which failed # its own ValidateNotNullOrEmpty on -Request. Page 1 is already in hand, so there is # nothing left to parallelise. Found live with `Get-VSAAgent -Parallel -ParallelThreshold 1` # against 14 agents. Mock Get-RequestData { [pscustomobject]@{ Result = @(1..14); TotalRecords = 14; ResponseCode = '0'; Status = 'OK' } } $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' # Called directly rather than inside a { } | Should -Not -Throw: that runs the scriptblock # in a child scope, so the assignment would not escape. A throw fails the test regardless. $out = Invoke-VSARestMethod -URISuffix 'api/v1.0/things' -VSAConnection $conn -Parallel -ParallelThreshold 1 Should -Invoke Invoke-VSAParallelRequest -Times 0 -Exactly @($out).Count | Should -Be 14 } } It "engages the pump when there IS a second page and the threshold is met" { InModuleScope VSAModule { # Boundary partner to the test above: 101 records = page 1 + one remaining page. Mock Get-RequestData { [pscustomobject]@{ Result = @(1..100); TotalRecords = 101; ResponseCode = '0'; Status = 'OK' } } Mock Invoke-VSAParallelRequest { foreach ($r in $Request) { [pscustomobject]@{ Id = $r.Id; Response = [pscustomobject]@{ Result = @(1) }; Error = $null } } } $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' $out = Invoke-VSARestMethod -URISuffix 'api/v1.0/things' -VSAConnection $conn -Parallel -ParallelThreshold 1 Should -Invoke Invoke-VSAParallelRequest -Times 1 -Exactly @($out).Count | Should -Be 101 } } } Describe "Get-VSAItemById id-array / -Parallel routing" { It "a single id (no -Parallel) calls the transport once and returns its result directly" { InModuleScope VSAModule { Mock Invoke-VSARestMethod { 'single-result' } $r = Get-VSAItemById -URISuffix 'api/v1.0/x/{0}/notes' -Id 42 $r | Should -Be 'single-result' Should -Invoke Invoke-VSARestMethod -Times 1 -Exactly Should -Invoke Invoke-VSARestMethod -ParameterFilter { $URISuffix -eq 'api/v1.0/x/42/notes' } -Times 1 } } It "a single id + -Parallel forwards page-parallel controls to the transport" { InModuleScope VSAModule { # Capture the mocked command's bound args as named variables (Pester does not populate # $PSBoundParameters for SPLATTED arguments). $script:seen = $null Mock Invoke-VSARestMethod { $script:seen = @{ Parallel = $Parallel; ThrottleLimit = $ThrottleLimit; ParallelThreshold = $ParallelThreshold } } Get-VSAItemById -URISuffix 'api/v1.0/x/{0}/notes' -Id 42 -Parallel -ThrottleLimit 12 -ParallelThreshold 500 | Out-Null [bool]$script:seen['Parallel'] | Should -Be $true $script:seen['ThrottleLimit'] | Should -Be 12 $script:seen['ParallelThreshold'] | Should -Be 500 } } It "multiple ids + -Parallel routes to the fan-out helper" { InModuleScope VSAModule { Mock Invoke-VSABatchGet { 'fanned' } $r = Get-VSAItemById -URISuffix 'api/v1.0/x/{0}/notes' -Id 1, 2, 3 -Parallel $r | Should -Be 'fanned' Should -Invoke Invoke-VSABatchGet -ParameterFilter { $Id.Count -eq 3 } -Times 1 } } It "multiple ids without -Parallel loops sequentially and concatenates" { InModuleScope VSAModule { Mock Invoke-VSARestMethod { , @("r-$($URISuffix.Split('/')[-2])") } $r = @(Get-VSAItemById -URISuffix 'api/v1.0/x/{0}/notes' -Id 7, 8) $r.Count | Should -Be 2 Should -Invoke Invoke-VSARestMethod -Times 2 -Exactly } } } Describe "Invoke-VSABatchGet merge + follow-up rounds" { It "flattens per-id results in id order and issues a follow-up round for >1-page ids" { InModuleScope VSAModule { $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' $script:round = 0 Mock Invoke-VSAParallelRequest { $script:round++ if ($script:round -eq 1) { # id 1 -> 250 records (needs skip 100 & 200); id 2 -> 1 record (single page) @( [pscustomobject]@{ Id = '1'; Response = [pscustomobject]@{ Result = @('a', 'b'); TotalRecords = 250 }; Error = $null } [pscustomobject]@{ Id = '2'; Response = [pscustomobject]@{ Result = @('z'); TotalRecords = 1 }; Error = $null } ) } else { # follow-up pages for id 1 @( [pscustomobject]@{ Id = '1|100'; Response = [pscustomobject]@{ Result = @('c', 'd') }; Error = $null } [pscustomobject]@{ Id = '1|200'; Response = [pscustomobject]@{ Result = @('e') }; Error = $null } ) } } $out = Invoke-VSABatchGet -URISuffixTemplate 'api/v1.0/x/{0}/notes' -Id '1', '2' -VSAConnection $conn Should -Invoke Invoke-VSAParallelRequest -Times 2 -Exactly # id 1's five records precede id 2's single record (id order preserved). @($out) | Should -Be @('a', 'b', 'c', 'd', 'e', 'z') } } It "throws the first error when any request fails" { InModuleScope VSAModule { $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't' Mock Invoke-VSAParallelRequest { @([pscustomobject]@{ Id = '1'; Response = $null; Error = (New-VSAApiError -Message 'boom' -StatusCode 500 -Method GET -Uri 'u' -VSAError 'boom') }) } { Invoke-VSABatchGet -URISuffixTemplate 'api/v1.0/x/{0}/notes' -Id '1' -VSAConnection $conn } | Should -Throw } } } |