tests/Invoke-AZSCGraphRequest.Tests.ps1
|
#Requires -Modules Pester <# .SYNOPSIS Pester tests for Invoke-AZSCGraphRequest. .DESCRIPTION Validates the Graph API request handler: - URI normalization (relative → absolute) - Retry on 429 (throttle) with Retry-After header - Retry on 5xx with exponential backoff - Pagination via @odata.nextLink - SinglePage switch disables pagination - Non-retryable errors bubble correctly - Returns .value collection or raw response .NOTES Author: thisismydemo Version: 1.0.0 Created: 2026-02-23 #> $ModuleRoot = Split-Path -Parent $PSScriptRoot Import-Module (Join-Path $ModuleRoot 'AzureScout.psd1') -Force -ErrorAction Stop Describe 'Invoke-AZSCGraphRequest' { # ── URI Normalization ───────────────────────────────────────────── Context 'URI Normalization' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Invoke-RestMethod { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1'; displayName = 'Test' }) } } -ModuleName AzureScout } It 'Prepends https://graph.microsoft.com for relative URIs' { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/organization' } Should -Invoke Invoke-RestMethod -ModuleName AzureScout -ParameterFilter { $Uri -like 'https://graph.microsoft.com/v1.0/organization*' } } It 'Passes absolute URIs through unchanged' { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri 'https://graph.microsoft.com/v1.0/users' } Should -Invoke Invoke-RestMethod -ModuleName AzureScout -ParameterFilter { $Uri -eq 'https://graph.microsoft.com/v1.0/users' } } } # ── Successful Single-Page Request ──────────────────────────────── Context 'Single Successful Request' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Invoke-RestMethod { return [PSCustomObject]@{ value = @( [PSCustomObject]@{ id = '1'; displayName = 'User A' } [PSCustomObject]@{ id = '2'; displayName = 'User B' } ) } } -ModuleName AzureScout } It 'Returns objects from the .value collection' { $result = InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' } $result.Count | Should -Be 2 $result[0].displayName | Should -Be 'User A' } } # ── Raw Response (No .value) ────────────────────────────────────── Context 'Single-Object Endpoint (no .value)' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Invoke-RestMethod { return [PSCustomObject]@{ id = 'org-1'; displayName = 'Contoso' } } -ModuleName AzureScout } It 'Returns the raw response when no .value property exists' { $result = InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/organization' } $result.displayName | Should -Be 'Contoso' } } # ── Pagination ──────────────────────────────────────────────────── Context 'Pagination via @odata.nextLink' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout $script:callCount = 0 Mock Invoke-RestMethod { $script:callCount++ if ($script:callCount -eq 1) { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1' }) '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/users?$skip=1' } } else { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '2' }) } } } -ModuleName AzureScout } AfterAll { Remove-Variable -Name callCount -Scope Script -ErrorAction SilentlyContinue } It 'Follows @odata.nextLink and aggregates results' { $result = InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' } $result.Count | Should -Be 2 } It 'Makes multiple Invoke-RestMethod calls for pagination' { Should -Invoke Invoke-RestMethod -ModuleName AzureScout -Times 2 -Scope Context } } # ── SinglePage Switch ───────────────────────────────────────────── Context 'SinglePage Switch' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Invoke-RestMethod { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1' }) '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/users?$skip=1' } } -ModuleName AzureScout } It 'Does not follow nextLink when SinglePage is set' { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' -SinglePage } Should -Invoke Invoke-RestMethod -ModuleName AzureScout -Times 1 -Scope It } } # ── Retry on 429 (Throttle) ─────────────────────────────────────── Context 'Retry on 429 Throttle' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Start-Sleep { } -ModuleName AzureScout $script:retryCallCount = 0 Mock Invoke-RestMethod { $script:retryCallCount++ if ($script:retryCallCount -eq 1) { $mockResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]429) throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('Too Many Requests', $mockResponse) } return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1' }) } } -ModuleName AzureScout } AfterAll { Remove-Variable -Name retryCallCount -Scope Script -ErrorAction SilentlyContinue } It 'Retries after a 429 error' { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' -MaxRetries 3 } Should -Invoke Invoke-RestMethod -ModuleName AzureScout -Times 2 -Scope It } It 'Calls Start-Sleep during retry backoff' { Should -Invoke Start-Sleep -ModuleName AzureScout -Scope Context } } # ── Retry on 5xx ────────────────────────────────────────────────── Context 'Retry on 5xx Server Error' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Start-Sleep { } -ModuleName AzureScout $script:serverErrorCount = 0 Mock Invoke-RestMethod { $script:serverErrorCount++ if ($script:serverErrorCount -le 2) { $mockResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::InternalServerError) throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('Internal Server Error', $mockResponse) } return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1' }) } } -ModuleName AzureScout } AfterAll { Remove-Variable -Name serverErrorCount -Scope Script -ErrorAction SilentlyContinue } It 'Retries multiple times on server errors' { $result = InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' -MaxRetries 5 } $result | Should -Not -BeNullOrEmpty } } # ── Max Retries Exceeded ────────────────────────────────────────── Context 'Max Retries Exceeded' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Start-Sleep { } -ModuleName AzureScout Mock Invoke-RestMethod { $mockResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::ServiceUnavailable) throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('Service Unavailable', $mockResponse) } -ModuleName AzureScout } It 'Throws after exhausting all retries' { { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' -MaxRetries 2 } } | Should -Throw } } # ── Token Refresh ───────────────────────────────────────────────── Context 'Token Handling' { BeforeAll { Mock Get-AZSCGraphToken { return @{ 'Authorization' = 'Bearer mock-token-refreshed'; 'Content-Type' = 'application/json' } } -ModuleName AzureScout Mock Invoke-RestMethod { return [PSCustomObject]@{ value = @([PSCustomObject]@{ id = '1' }) } } -ModuleName AzureScout } It 'Fetches a token via Get-AZSCGraphToken' { InModuleScope 'AzureScout' { Invoke-AZSCGraphRequest -Uri '/v1.0/users' } Should -Invoke Get-AZSCGraphToken -ModuleName AzureScout } } } |