Tests/VSAModule.Transport.Tests.ps1

$ModuleRoot = Split-Path -Path (Split-Path -Parent $PSScriptRoot)
$ModulePath = Join-Path -Path $ModuleRoot -ChildPath 'VSAModule.psd1'

# Installs a fake HttpMessageHandler in place of the network. See Tests/VSAFakeHttp.ps1.
. (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 "Transport - value escaping and query building" {

    It "ConvertTo-ODataString doubles single quotes only (T-4.1)" {
        InModuleScope VSAModule {
            ConvertTo-ODataString "O'Brien" | Should -Be "O''Brien"
            ConvertTo-ODataString 'C:\path' | Should -Be 'C:\path'  # backslash untouched
        }
    }

    It "Format-VSAPathSegment encodes a single segment (T-4.5)" {
        InModuleScope VSAModule {
            Format-VSAPathSegment 'PC #7' | Should -Match '%23'
        }
    }

    It "Format-VSAPathSegment keeps '/' but encodes spaces (T-4.5)" {
        InModuleScope VSAModule {
            $r = Format-VSAPathSegment 'folder/sub dir'
            $r | Should -Be 'folder/sub%20dir'
        }
    }
}

Describe "Transport - Invoke-VSARestMethod query and filter" {

    BeforeEach {
        InModuleScope VSAModule {
            $script:CapturedUris = New-Object System.Collections.ArrayList
            Mock Update-VSAConnection {}
            Mock Get-RequestData {
                $null = $script:CapturedUris.Add($URI)
                [pscustomobject]@{ Result = @(); ResponseCode = 0; Status = 'OK' }
            }
        }
    }

    It "passes an OData filter through with only URL-encoding (T-4.1/T-4.5)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' -Filter "Name eq 'O''Brien'" | Out-Null
            $uri = $script:CapturedUris[0]
            $uri | Should -Match '\$filter='
            # The decoded filter must equal the original, untouched, string.
            $encoded = ($uri -split '\$filter=')[1] -split '&' | Select-Object -First 1
            [uri]::UnescapeDataString($encoded) | Should -Be "Name eq 'O''Brien'"
        }
    }

    It "URL-encodes spaces and ampersands in the filter (T-4.5)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' -Filter "Name eq 'A & B'" | Out-Null
            $uri = $script:CapturedUris[0]
            $uri | Should -Match '%20'
            $uri | Should -Match '%26'
        }
    }

    It "accepts a valid Sort and emits `$orderby (T-4.2)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' -Sort 'Name asc' | Out-Null
            $uri = $script:CapturedUris[0]
            ($uri -split '\$orderby=')[1] -split '&' | Select-Object -First 1 | ForEach-Object { [uri]::UnescapeDataString($_) } | Should -Be 'Name asc'
        }
    }

    It "throws on an injection-style Sort (T-4.2)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            { Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' -Sort 'Name; DROP' } | Should -Throw
        }
    }
}

Describe "Transport - empty-body 2xx (HTTP 204) is success, not an error (F-21)" {
    # A successful DELETE (and some PUTs) returns 204 No Content: Invoke-RestMethod yields $null on
    # PS7 and '' on some editions. Both must be treated as success. Verified live against a real VSA:
    # DELETE /system/orgs/{id} returns 204 and previously threw "Unexpected API response".

    AfterEach {
        InModuleScope VSAModule { $script:VSAHttpClients.Clear() }
    }

    It "Get-RequestData returns `$null (no throw) for a real 204 with no body" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(204, '', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $r = Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -Method DELETE
            $r | Should -Be $null
            $h.CallCount | Should -Be 1
        }
    }

    It "Get-RequestData normalizes a whitespace-only 200 body to `$null (no throw)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(200, ' ', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $r = Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -Method DELETE
            $r | Should -Be $null
        }
    }

    It "Invoke-VSARestMethod returns without error for a DELETE that yields 204" {
        InModuleScope VSAModule {
            Mock Update-VSAConnection {}
            Mock Get-RequestData { $null }
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            { Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/system/orgs/123' -Method DELETE } | Should -Not -Throw
        }
    }
}

Describe "Transport - token renewal ordering (T-4.4)" {

    It "renews the token BEFORE the request, so the request uses the fresh token" {
        InModuleScope VSAModule {
            $script:usedAuth = $null
            # Simulate renewal swapping the token on the connection object.
            Mock Update-VSAConnection { $VSAConnection.UpdateToken('NEWTOKEN') }
            Mock Get-RequestData {
                $script:usedAuth = $AuthString
                [pscustomobject]@{ Result = @(); ResponseCode = 0; Status = 'OK' }
            }
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'OLDTOKEN', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' | Out-Null
            $script:usedAuth | Should -Be 'Bearer NEWTOKEN'
        }
    }
}

Describe "Transport - pagination" {

    BeforeEach {
        InModuleScope VSAModule {
            $script:PageCalls = New-Object System.Collections.ArrayList
            Mock Update-VSAConnection {}
            Mock Get-RequestData {
                $null = $script:PageCalls.Add($URI)
                $skip = 0
                if ($URI -match '\$skip=(\d+)') { $skip = [int]$Matches[1] }
                $remaining = 250 - $skip
                $count = [Math]::Max(0, [Math]::Min(100, $remaining))
                $items = if ($count -gt 0) { 1..$count | ForEach-Object { [pscustomobject]@{ n = $skip + $_ } } } else { @() }
                [pscustomobject]@{ Result = $items; TotalRecords = 250; ResponseCode = 0; Status = 'OK' }
            }
        }
    }

    It "fetches exactly 3 pages (skip 0/100/200), top=100, 250 records, no trailing empty call (T-4.3)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            $result = Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x'
            $script:PageCalls.Count | Should -Be 3
            $result.Count | Should -Be 250
            # every call carries $top=100
            foreach ($u in $script:PageCalls) { $u | Should -Match '\$top=100' }
            # first call has no explicit skip (=0); the next two are 100 and 200
            $script:PageCalls[0] | Should -Not -Match '\$skip='
            $script:PageCalls[1] | Should -Match '\$skip=100'
            $script:PageCalls[2] | Should -Match '\$skip=200'
        }
    }

    It "-ExtendedOutput returns the envelope with the merged Result (T-4.8)" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            $env = Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x' -ExtendedOutput
            $env.TotalRecords | Should -Be 250
            $env.Result.Count | Should -Be 250
        }
    }
}

Describe "Get-VSAStorageContent - routes through the transport (T-6.1)" {

    It "passes the correct URISuffix and an OutFile under the user profile" {
        InModuleScope VSAModule {
            $script:capSuffix = $null
            $script:capOut = $null
            Mock Invoke-VSARestMethod { $script:capSuffix = $URISuffix; $script:capOut = $OutFile }
            Get-VSAStorageContent -FileId 12345 | Out-Null
            $script:capSuffix | Should -Be 'api/v1.0/storage/file/12345/contents'
            $script:capOut | Should -Match '12345\.webm$'
            $script:capOut | Should -Match ([regex]::Escape([Environment]::GetFolderPath('UserProfile')))
        }
    }
}

Describe "Transport - Get-RequestData error handling (dual runtime)" {

    # These exercise the REAL transport (Invoke-VSAHttp + HttpClient) against a fake
    # HttpMessageHandler, so the retry loop, status handling and Retry-After parsing are genuinely
    # executed rather than simulated (F-67).

    AfterEach {
        InModuleScope VSAModule { $script:VSAHttpClients.Clear() }
    }

    It "retries a transient 503 then succeeds (T-4.6)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(503, '', 0)
            $h.EnqueueResponse(200, '{"Result":[],"ResponseCode":0,"Status":"OK"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $r = Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -MaxRetries 3 -WarningAction SilentlyContinue
            $h.CallCount | Should -Be 2
            $r.Status | Should -Be 'OK'
        }
    }

    It "honours Retry-After on 429 (T-4.6)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(429, '', 2)   # server asks for 2s; exponential backoff alone would be 1s
            $h.EnqueueResponse(200, '{"Result":[],"ResponseCode":0,"Status":"OK"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -MaxRetries 3 -WarningAction SilentlyContinue | Out-Null
            $sw.Stop()
            $h.CallCount | Should -Be 2
            # Proves the server's hint won over the client's own (shorter) exponential backoff.
            $sw.Elapsed.TotalSeconds | Should -BeGreaterThan 1.5
        }
    }

    It "surfaces the VSA error body from a terminal 400, thrown once (T-4.6)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(400, '{"Error":"bad filter"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            { Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -MaxRetries 3 } |
                Should -Throw -ExpectedMessage '*bad filter*'
            # A terminal 400 is not transient: exactly one attempt, no retry storm.
            $h.CallCount | Should -Be 1
        }
    }

    It "does NOT retry a terminal 400 but DOES retry each transient status (T-4.6)" {
        InModuleScope VSAModule {
            foreach ($status in @(429, 502, 503, 504)) {
                $h = [FakeHttpMessageHandler]::new()
                $h.EnqueueResponse($status, '', 0)
                $h.EnqueueResponse(200, '{"Result":[],"ResponseCode":0,"Status":"OK"}', 0)
                $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
                $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

                Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -MaxRetries 3 -WarningAction SilentlyContinue | Out-Null
                $h.CallCount | Should -Be 2 -Because "HTTP $status is transient and must be retried"
                $script:VSAHttpClients.Clear()
            }
        }
    }

    It "sends the Authorization header, method and body the caller asked for" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(200, '{"ResponseCode":0,"Status":"OK"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer TOK' -Method POST -Body '{"a":1}' | Out-Null
            $h.AuthHeaders[0]   | Should -Be 'Bearer TOK'
            $h.Methods[0]       | Should -Be 'POST'
            $h.RequestBodies[0] | Should -Be '{"a":1}'
            $h.ContentTypes[0]  | Should -Be 'application/json'
        }
    }

    It "sends a byte[] body with its Content-Type verbatim, preserving a multipart boundary (F-37)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(200, '{"ResponseCode":0,"Status":"OK"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            [byte[]] $bytes = [System.Text.Encoding]::UTF8.GetBytes('--B123--')
            $ct = 'multipart/form-data; boundary="B123"'
            Get-RequestData -URI 'https://vsa.example.com/api/v1.0/upload' -AuthString 'Bearer t' -Method POST -Body $bytes -ContentType $ct | Out-Null
            # The boundary parameter must survive byte-for-byte -- StringContent would have appended
            # its own charset and corrupted it.
            $h.ContentTypes[0]  | Should -Be $ct
            $h.RequestBodies[0] | Should -Be '--B123--'
        }
    }

    It "writes the response body to -OutFile and returns nothing" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(200, 'RAWFILEBYTES', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $out = Join-Path ([System.IO.Path]::GetTempPath()) "vsa-outfile-$([guid]::NewGuid()).bin"
            try {
                $r = Get-RequestData -URI 'https://vsa.example.com/api/v1.0/file' -AuthString 'Bearer t' -OutFile $out
                $r | Should -BeNullOrEmpty
                Test-Path $out | Should -BeTrue
                [System.IO.File]::ReadAllText($out) | Should -Be 'RAWFILEBYTES'
            } finally {
                Remove-Item $out -ErrorAction SilentlyContinue
            }
        }
    }
}

Describe "Transport - shared retry/envelope policy is a single source of truth (F-67)" {

    # The policy functions are extracted precisely so both dispatch modes share them; testing them
    # directly is cheaper and stricter than inferring the rules through a transport.

    It "Get-VSABackoffSeconds prefers a server Retry-After over exponential backoff" {
        InModuleScope VSAModule {
            Get-VSABackoffSeconds -Attempt 1 -RetryAfterSeconds 7 | Should -Be 7
            Get-VSABackoffSeconds -Attempt 1 -RetryAfterSeconds $null | Should -Be 1
            Get-VSABackoffSeconds -Attempt 3 -RetryAfterSeconds $null | Should -Be 4
        }
    }

    It "Get-VSABackoffSeconds caps the wait at 30s from either source" {
        InModuleScope VSAModule {
            Get-VSABackoffSeconds -Attempt 10 -RetryAfterSeconds $null | Should -Be 30
            Get-VSABackoffSeconds -Attempt 1  -RetryAfterSeconds 9999  | Should -Be 30
        }
    }

    It "Resolve-VSAResponse returns a raw non-enveloped payload as-is (F-63)" {
        InModuleScope VSAModule {
            $raw = [pscustomobject]@{ '757824222824211' = 'KCB_BackupStatus_Ok' }
            (Resolve-VSAResponse -Response $raw -Method GET -Uri 'x').'757824222824211' | Should -Be 'KCB_BackupStatus_Ok'
        }
    }

    It "Resolve-VSAResponse throws typed on an app error inside a 200 envelope" {
        InModuleScope VSAModule {
            $env = [pscustomobject]@{ ResponseCode = '400'; Error = 'bad request'; Result = $null }
            { Resolve-VSAResponse -Response $env -Method POST -Uri 'x' } | Should -Throw '*bad request*'
        }
    }

    It "Resolve-VSAResponse throws on an unrecognised envelope rather than returning it silently" {
        InModuleScope VSAModule {
            $env = [pscustomobject]@{ ResponseCode = '999'; Status = 'WAT' }
            { Resolve-VSAResponse -Response $env -Method GET -Uri 'x' } | Should -Throw '*Unexpected API response*'
        }
    }

    It "Test-VSARetryable retries transient statuses for ANY method (the server said it did not process)" {
        InModuleScope VSAModule {
            foreach ($m in @('GET','POST','PUT','DELETE')) {
                foreach ($s in @(429, 502, 503, 504)) {
                    Test-VSARetryable -StatusCode $s -Method $m | Should -BeTrue -Because "HTTP $s on $m is transient"
                }
                Test-VSARetryable -StatusCode 400 -Method $m | Should -BeFalse
                Test-VSARetryable -StatusCode 404 -Method $m | Should -BeFalse
            }
        }
    }

    It "Test-VSARetryable retries a no-response ONLY for idempotent methods" {
        InModuleScope VSAModule {
            # A reset leaves us unable to know whether the server applied the request. Repeating a
            # GET is safe; repeating a POST/PUT/DELETE could apply it twice. It is also how a blocked
            # (post-2021) write endpoint answers, so retrying just burns the backoff.
            Test-VSARetryable -StatusCode 0 -Method 'GET' -NoResponse $true | Should -BeTrue
            foreach ($m in @('POST','PUT','DELETE')) {
                Test-VSARetryable -StatusCode 0 -Method $m -NoResponse $true | Should -BeFalse -Because "$m is not idempotent; a retry could duplicate the write"
            }
        }
    }

    It "a blocked write endpoint fails fast: no retry storm, ConnectionReset raised once" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            1..4 | ForEach-Object { $h.EnqueueFault() }   # plenty available IF it wrongly retried
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
            try {
                $err = $null
                try { Get-RequestData -URI 'https://vsa.example.com/api/v1.0/system/users/9' -AuthString 'Bearer t' -Method DELETE -MaxRetries 3 } catch { $err = $_ }
                $err.Exception.ConnectionReset | Should -BeTrue
                $h.CallCount | Should -Be 1 -Because 'a DELETE that got no response must not be retried'
            } finally { $script:VSAHttpClients.Clear() }
        }
    }

    It "a GET that gets no response IS retried (read fan-outs survive a socket blip)" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueFault()
            $h.EnqueueResponse(200, '{"Result":[],"ResponseCode":0,"Status":"OK"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
            try {
                $r = Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -Method GET -MaxRetries 3 -WarningAction SilentlyContinue
                $h.CallCount | Should -Be 2
                $r.Status | Should -Be 'OK'
            } finally { $script:VSAHttpClients.Clear() }
        }
    }

    It "both dispatch modes share ONE retry-status set" {
        InModuleScope VSAModule {
            $script:VSARetryStatuses | Should -Be @(429, 502, 503, 504)
            # Guard against a second, drifting copy being reintroduced in either path.
            # private/ is a SIBLING of Tests/, not a child: the old path (Join-Path $PSScriptRoot
            # 'private/...') resolved to Tests/private/... which does not exist, so Get-Content
            # returned $null and `$null | Should -Not -Match` passed VACUOUSLY -- the drift guard
            # never actually read the file (F-74, caught by the 5.1 pass).
            $src = Get-Content (Join-Path (Split-Path -Parent $PSScriptRoot) 'private/Invoke-VSAParallelRequest.ps1') -Raw
            $src | Should -Not -BeNullOrEmpty -Because 'the guard must actually read the file, not pass on a null'
            $src | Should -Not -Match '(?m)^\s*\$retryStatuses\s*='
        }
    }
}

Describe "Transport - URISuffix that already has a query, and status-only responses (F-22 / F-23)" {

    It "joins OData params with '&' (not a 2nd '?') when the URISuffix already has a query (F-22)" {
        InModuleScope VSAModule {
            $script:capturedUri = $null
            Mock Update-VSAConnection {}
            Mock Get-RequestData { $script:capturedUri = $URI; [pscustomobject]@{ Result = @(); ResponseCode = 0; Status = 'OK' } }
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x/true?flag=true' -Method PUT | Out-Null
            # exactly one '?' in the whole URL, and $top is appended with '&'
            (($script:capturedUri.ToCharArray() | Where-Object { $_ -eq '?' }).Count) | Should -Be 1
            $script:capturedUri | Should -Match 'flag=true&\$top='
        }
    }

    It "does not throw when a (write) response envelope has no 'Result' property (F-23)" {
        InModuleScope VSAModule {
            Mock Update-VSAConnection {}
            Mock Get-RequestData { [pscustomobject]@{ ResponseCode = 0; Status = 'OK' } }   # status-only, no Result
            $conn = [VSAConnection]::new('https://vsa.example.com', 'u', 'tok', 'pat', [datetime]::Now.AddHours(1), $false, $false)
            { Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/x/true?flag=true' -Method PUT } | Should -Not -Throw
        }
    }
}

Describe "Sequential path recovers from a server-side session invalidation (F-77)" {

    # A VSA session can die long before its client-tracked expiry (SaaS early cut-off, or
    # Close-VSAUserSession logging the current session out). Update-VSAConnection only compares the
    # client-side SessionExpiration to the clock, so it cannot detect this; before F-77 every
    # subsequent call failed 401 forever despite the module holding the PAT. Found live: after
    # Close-VSAUserSession, a connection expiring two days later 401'd permanently. The parallel
    # pump already recovered (single forced renewal per 401); the sequential path now mirrors it.

    It "renews ONCE on a 401 and retries with the fresh token" {
        InModuleScope VSAModule {
            $script:calls = 0
            $script:seenAuth = @()
            Mock Get-RequestData {
                $script:calls++
                $script:seenAuth += $AuthString
                if ($script:calls -eq 1) { throw (New-VSAApiError -Message 'dead session' -StatusCode 401 -Method GET -Uri 'https://h/x') }
                [pscustomobject]@{ Result = @(1,2); ResponseCode = 0; Status = 'OK' }
            }
            Mock Update-VSAConnection { if ($Force) { $VSAConnection.UpdateToken('RENEWED') } }

            $conn = [VSAConnection]::new('https://vsa.example/', 'u', 'OLDTOK', 'pat', [datetime]::Now.AddDays(2), $false, $false)
            $out = @(Invoke-VSARestMethod -URISuffix 'api/v1.0/x' -VSAConnection $conn)

            $out.Count | Should -Be 2
            Should -Invoke Get-RequestData -Times 2 -Exactly
            Should -Invoke Update-VSAConnection -ParameterFilter { [bool]$Force } -Times 1 -Exactly
            # The retry must actually carry the renewed token, not the dead one.
            $script:seenAuth[1] | Should -Be 'Bearer RENEWED'
        }
    }

    It "does NOT loop: a second 401 (genuinely bad credentials) surfaces typed after exactly one retry" {
        InModuleScope VSAModule {
            $script:calls = 0
            Mock Get-RequestData {
                $script:calls++
                throw (New-VSAApiError -Message 'still unauthorized' -StatusCode 401 -Method GET -Uri 'https://h/x')
            }
            Mock Update-VSAConnection { }

            $conn = [VSAConnection]::new('https://vsa.example/', 'u', 'tok', 'pat', [datetime]::Now.AddDays(2), $false, $false)
            $err = $null
            try { Invoke-VSARestMethod -URISuffix 'api/v1.0/x' -VSAConnection $conn } catch { $err = $_ }

            $err.Exception | Should -BeOfType ([VSAApiException])
            $err.Exception.StatusCode | Should -Be 401
            $script:calls | Should -Be 2 -Because 'one initial attempt + exactly one post-renewal retry, never a loop'
        }
    }

    It "a non-401 error triggers NO forced renewal (behaviour for 403/404/500 unchanged)" {
        InModuleScope VSAModule {
            Mock Get-RequestData { throw (New-VSAApiError -Message 'forbidden' -StatusCode 403 -Method GET -Uri 'https://h/x') }
            Mock Update-VSAConnection { }

            $conn = [VSAConnection]::new('https://vsa.example/', 'u', 'tok', 'pat', [datetime]::Now.AddDays(2), $false, $false)
            { Invoke-VSARestMethod -URISuffix 'api/v1.0/x' -VSAConnection $conn } | Should -Throw
            Should -Invoke Get-RequestData -Times 1 -Exactly
            Should -Invoke Update-VSAConnection -ParameterFilter { [bool]$Force } -Times 0 -Exactly
        }
    }
}