Tests/VSAModule.TypedError.Tests.ps1

# The module has a single HTTP stack (HttpClient) since v1.5.1, so terminal failures are simulated
# with a fake HttpMessageHandler rather than by mocking Invoke-RestMethod (F-67).
. (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 "New-VSAApiError classifies failures into typed, branchable errors" {

    It "maps HTTP status to StatusCode, ErrorCategory, and errorId" -ForEach @(
        @{ Status = 401; Category = 'AuthenticationError'; Id = 'VSAHttp401' }
        @{ Status = 403; Category = 'PermissionDenied';    Id = 'VSAHttp403' }
        @{ Status = 404; Category = 'ObjectNotFound';      Id = 'VSAHttp404' }
        @{ Status = 500; Category = 'InvalidOperation';    Id = 'VSAHttp500' }
    ) {
        InModuleScope VSAModule -Parameters @{ Status = $Status; Category = $Category; Id = $Id } {
            param($Status, $Category, $Id)
            $rec = New-VSAApiError -Message 'x' -StatusCode $Status -Method 'PUT' -Uri 'https://h/api'
            $rec | Should -BeOfType ([System.Management.Automation.ErrorRecord])
            $rec.Exception | Should -BeOfType ([VSAApiException])
            $rec.Exception.StatusCode | Should -Be $Status
            $rec.Exception.ConnectionReset | Should -Be $false
            $rec.CategoryInfo.Category | Should -Be $Category
            $rec.FullyQualifiedErrorId | Should -BeLike "$Id*"
        }
    }

    It "flags a connection reset (StatusCode 0) as ConnectionError" {
        InModuleScope VSAModule {
            $rec = New-VSAApiError -Message 'reset' -StatusCode 0 -Method 'DELETE' -Uri 'https://h/api' -ConnectionReset
            $rec.Exception.StatusCode | Should -Be 0
            $rec.Exception.ConnectionReset | Should -Be $true
            $rec.CategoryInfo.Category | Should -Be 'ConnectionError'
            $rec.FullyQualifiedErrorId | Should -BeLike 'VSAConnectionReset*'
        }
    }
}

Describe "Get-RequestData throws typed VSAApiException" {

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

    It "surfaces an HTTP 404 as StatusCode 404 / ObjectNotFound" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(404, '{"Error":"Org does not exist"}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $err = $null
            try { Get-RequestData -URI 'https://vsa.example.com/api/v1.0/system/orgs/9' -AuthString 'Bearer t' -Method DELETE }
            catch { $err = $_ }
            $err.Exception | Should -BeOfType ([VSAApiException])
            $err.Exception.StatusCode | Should -Be 404
            $err.Exception.ConnectionReset | Should -Be $false
            $err.CategoryInfo.Category | Should -Be 'ObjectNotFound'
            $err.Exception.VSAError | Should -Be 'Org does not exist'
        }
    }

    It "surfaces a connection reset (no HTTP response) as ConnectionReset / StatusCode 0" {
        InModuleScope VSAModule {
            # Deterministic: the handler faults with no response at all, which is what a hardened
            # (post-2021) VSA does to user-mutation endpoints. Retries are disabled so the terminal
            # classification is what is under test, not the retry loop.
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueFault()
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $err = $null
            try { Get-RequestData -URI 'https://vsa.example.com/api/v1.0/system/users/9' -AuthString 'Bearer t' -Method DELETE -MaxRetries 0 }
            catch { $err = $_ }
            $err.Exception | Should -BeOfType ([VSAApiException])
            $err.Exception.StatusCode | Should -Be 0
            $err.Exception.ConnectionReset | Should -Be $true
            $err.CategoryInfo.Category | Should -Be 'ConnectionError'
            $err.Exception.Message | Should -BeLike '*blocked or*restricted*'
        }
    }

    It "surfaces an application-level error inside an HTTP 200 envelope as typed" {
        InModuleScope VSAModule {
            $h = [FakeHttpMessageHandler]::new()
            $h.EnqueueResponse(200, '{"ResponseCode":"400","Error":"bad request","Result":null}', 0)
            $script:VSAHttpClients['strict'] = [System.Net.Http.HttpClient]::new($h)
            $script:VSAHttpClients['strict'].Timeout = [System.Threading.Timeout]::InfiniteTimeSpan

            $err = $null
            try { Get-RequestData -URI 'https://vsa.example.com/api/v1.0/x' -AuthString 'Bearer t' -Method POST -Body '{}' }
            catch { $err = $_ }
            $err.Exception | Should -BeOfType ([VSAApiException])
            $err.Exception.StatusCode | Should -Be 400
            $err.Exception.ConnectionReset | Should -Be $false
            $err.Exception.VSAError | Should -Be 'bad request'
        }
    }

    It "a -Parallel caller receives the SAME typed error a sequential caller would (F-67)" {
        InModuleScope VSAModule {
            # The regression this guards: the parallel path used to re-throw the raw transport
            # exception, so .StatusCode / .ConnectionReset branching silently stopped working under
            # -Parallel. Both paths now build the error through New-VSATransportError.
            $reset = New-VSATransportError -StatusCode 0 -Method 'GET' -Uri 'https://h/x' -InnerException ([Exception]::new('socket reset'))
            $reset.Exception | Should -BeOfType ([VSAApiException])
            $reset.Exception.ConnectionReset | Should -Be $true
            $reset.Exception.StatusCode | Should -Be 0

            $notFound = New-VSATransportError -StatusCode 404 -Method 'GET' -Uri 'https://h/x' -Body '{"Error":"nope"}'
            $notFound.Exception | Should -BeOfType ([VSAApiException])
            $notFound.Exception.ConnectionReset | Should -Be $false
            $notFound.Exception.VSAError | Should -Be 'nope'
            $notFound.CategoryInfo.Category | Should -Be 'ObjectNotFound'
        }
    }
}

Describe "Invoke-VSARestMethod preserves the typed error (does not flatten to string)" {

    It "re-throws the VSAApiException with StatusCode/Category intact" {
        InModuleScope VSAModule {
            Mock Update-VSAConnection {}
            Mock Get-RequestData { throw (New-VSAApiError -Message 'boom' -StatusCode 403 -Method 'PUT' -Uri 'https://h/api/v1.0/system/users/9') }
            $conn = [VSAConnection]::new(); $conn.URI = 'https://h'; $conn.Token = 't'
            $err = $null
            try { Invoke-VSARestMethod -VSAConnection $conn -URISuffix 'api/v1.0/system/users/9' -Method PUT -Body '{}' } catch { $err = $_ }
            $err.Exception | Should -BeOfType ([VSAApiException])
            $err.Exception.StatusCode | Should -Be 403
            $err.CategoryInfo.Category | Should -Be 'PermissionDenied'
        }
    }
}