modules/Azure/Infrastructure/Tests/Unit/InvokeAzureApiBatch.Tests.ps1

BeforeAll {
    Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue
    Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' '..' 'Devolutions.CIEM.psd1')

    function Initialize-InfrastructureTestDatabase {
        New-CIEMDatabase -Path "$TestDrive/ciem.db"

        InModuleScope Devolutions.CIEM {
            $script:DatabasePath = "$TestDrive/ciem.db"
        }

        $schemaPath = Join-Path $PSScriptRoot '..' '..' 'Data' 'azure_schema.sql'
        foreach ($statement in ((Get-Content $schemaPath -Raw) -split ';\s*\n' | Where-Object { $_.Trim() })) {
            Invoke-CIEMQuery -Query $statement.Trim() -AsNonQuery | Out-Null
        }
    }
}

Describe 'Invoke-AzureApi Graph batch support' {
    BeforeEach {
        Remove-Item "$TestDrive/ciem.db" -Force -ErrorAction SilentlyContinue
        Initialize-InfrastructureTestDatabase
        Mock -ModuleName Devolutions.CIEM Write-CIEMLog {}
        Mock -ModuleName Devolutions.CIEM InvokeCIEMAzureSleep {}

        InModuleScope Devolutions.CIEM {
            $script:AzureAuthContext = [CIEMAzureAuthContext]::new()
            $script:AzureAuthContext.IsConnected = $true
            $script:AzureAuthContext.TenantId = 'tenant-1'
            $script:AzureAuthContext.SubscriptionIds = @('sub-1')
            $script:AzureAuthContext.ARMToken = 'arm-token'
            $script:AzureAuthContext.GraphToken = 'graph-token'
            $script:AzureAuthContext.KeyVaultToken = 'kv-token'
        }
    }

    It 'Sends 20 requests in a single batch POST' {
        $script:batchSizes = [System.Collections.Generic.List[int]]::new()
        $requests = foreach ($index in 1..20) {
            @{ Id = "req-$index"; Method = 'GET'; Path = "/users/$index?`$select=id" }
        }

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            $payload = $Body | ConvertFrom-Json -AsHashtable
            $script:batchSizes.Add(@($payload.requests).Count)
            [pscustomobject]@{
                responses = @(
                    foreach ($request in @($payload.requests)) {
                        [pscustomobject]@{
                            id = $request.id
                            status = 200
                            body = [pscustomobject]@{
                                value = @([pscustomobject]@{ id = $request.id })
                            }
                        }
                    }
                )
            }
        }

        $result = Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop

        $script:batchSizes | Should -Be @(20)
        @($result.Keys) | Should -HaveCount 20
        @($result['req-1'].Items) | Should -HaveCount 1
    }

    It 'Splits 21 requests into two batch POSTs' {
        $script:batchSizes = [System.Collections.Generic.List[int]]::new()
        $requests = foreach ($index in 1..21) {
            @{ Id = "req-$index"; Method = 'GET'; Path = "/users/$index?`$select=id" }
        }

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            $payload = $Body | ConvertFrom-Json -AsHashtable
            $script:batchSizes.Add(@($payload.requests).Count)
            [pscustomobject]@{
                responses = @(
                    foreach ($request in @($payload.requests)) {
                        [pscustomobject]@{
                            id = $request.id
                            status = 200
                            body = [pscustomobject]@{
                                value = @([pscustomobject]@{ id = $request.id })
                            }
                        }
                    }
                )
            }
        }

        $result = Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop

        $script:batchSizes | Should -Be @(20, 1)
        @($result.Keys) | Should -HaveCount 21
    }

    It 'Throws when Requests is empty' {
        {
            Invoke-AzureApi -Api Graph -Requests @() -ResourceName 'GraphBatch' -ErrorAction Stop
        } | Should -Throw '*at least one batch request*'
    }

    It 'Retries only throttled sub-requests and preserves response IDs when the batch response is reordered' {
        $script:callCount = 0
        $script:batchSizes = [System.Collections.Generic.List[int]]::new()
        $requests = @(
            @{ Id = 'a'; Method = 'GET'; Path = '/groups/group-a/members?$select=id' }
            @{ Id = 'b'; Method = 'GET'; Path = '/groups/group-b/members?$select=id' }
        )

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            $payload = $Body | ConvertFrom-Json -AsHashtable
            $script:batchSizes.Add(@($payload.requests).Count)
            $script:callCount++

            if ($script:callCount -eq 1) {
                return [pscustomobject]@{
                    responses = @(
                        [pscustomobject]@{
                            id = 'b'
                            status = 200
                            body = [pscustomobject]@{
                                value = @([pscustomobject]@{ id = 'member-b' })
                            }
                        }
                        [pscustomobject]@{
                            id = 'a'
                            status = 429
                            headers = @{ 'Retry-After' = '3' }
                            body = [pscustomobject]@{
                                error = [pscustomobject]@{ message = 'slow down' }
                            }
                        }
                    )
                }
            }

            [pscustomobject]@{
                responses = @(
                    [pscustomobject]@{
                        id = 'a'
                        status = 200
                        body = [pscustomobject]@{
                            value = @([pscustomobject]@{ id = 'member-a' })
                        }
                    }
                )
            }
        }

        $result = Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop

        $script:batchSizes | Should -Be @(2, 1)
        Assert-MockCalled InvokeCIEMAzureSleep -ModuleName Devolutions.CIEM -Times 1 -Exactly -ParameterFilter { $Seconds -eq 3 }
        @($result['a'].Items) | Should -HaveCount 1
        $result['a'].Items[0].id | Should -Be 'member-a'
        $result['b'].Items[0].id | Should -Be 'member-b'
    }

    It 'Returns non-throttle batch failures without failing sibling requests' {
        $requests = @(
            @{ Id = 'success'; Method = 'GET'; Path = '/groups/group-a/members?$select=id' }
            @{ Id = 'forbidden'; Method = 'GET'; Path = '/groups/group-b/members?$select=id' }
        )

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            [pscustomobject]@{
                responses = @(
                    [pscustomobject]@{
                        id = 'success'
                        status = 200
                        body = [pscustomobject]@{
                            value = @([pscustomobject]@{ id = 'member-a' })
                        }
                    }
                    [pscustomobject]@{
                        id = 'forbidden'
                        status = 403
                        body = [pscustomobject]@{
                            error = [pscustomobject]@{ message = 'denied' }
                        }
                    }
                )
            }
        }

        $result = Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop

        $result['success'].Success | Should -BeTrue
        $result['forbidden'].Success | Should -BeFalse
        $result['forbidden'].StatusCode | Should -Be 403
    }

    It 'Aborts a batch when the wall-clock retry budget is exceeded' {
        # Set the cap very low so real wall-clock passes it before the 5-retry
        # per-request counter is exhausted. InvokeCIEMAzureSleep must REALLY sleep
        # so DateTimeOffset.UtcNow advances between iterations.
        InModuleScope Devolutions.CIEM { $script:CIEMGraphBatchWallClockSeconds = 1 }

        $requests = @(
            @{ Id = 'forever'; Method = 'GET'; Path = '/groups/group-a/members?$select=id' }
        )

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            [pscustomobject]@{
                responses = @(
                    [pscustomobject]@{
                        id = 'forever'
                        status = 429
                        headers = @{ 'Retry-After' = '2' }
                        body = [pscustomobject]@{
                            error = [pscustomobject]@{ message = 'slow down' }
                        }
                    }
                )
            }
        }

        # Let the sleep actually advance real time (capped to the wall-clock budget)
        Mock -ModuleName Devolutions.CIEM InvokeCIEMAzureSleep {
            param($Seconds)
            Start-Sleep -Milliseconds 1100
        }

        {
            Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop
        } | Should -Throw '*wall-clock*'

        InModuleScope Devolutions.CIEM { $script:CIEMGraphBatchWallClockSeconds = 300 }
    }

    It 'Source file declares the wall-clock cap script-scope tunable' {
        $psm1Path = Join-Path $PSScriptRoot '..' '..' '..' '..' '..' 'Devolutions.CIEM.psm1'
        $psm1Source = Get-Content -Path $psm1Path -Raw
        $psm1Source | Should -Match '\$script:CIEMGraphBatchWallClockSeconds\s*='
    }

    It 'Follows @odata.nextLink returned inside a batch sub-response' {
        $script:callCount = 0
        $requests = @(
            @{ Id = 'paged'; Method = 'GET'; Path = '/groups/group-a/members?$select=id' }
        )

        Mock -ModuleName Devolutions.CIEM Invoke-RestMethod {
            if ($Uri -like '*$batch') {
                return [pscustomobject]@{
                    responses = @(
                        [pscustomobject]@{
                            id = 'paged'
                            status = 200
                            body = [pscustomobject]@{
                                value = @([pscustomobject]@{ id = 'member-1' })
                                '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/groups/group-a/members?page=2'
                            }
                        }
                    )
                }
            }

            [pscustomobject]@{
                value = @([pscustomobject]@{ id = 'member-2' })
            }
        }

        $result = Invoke-AzureApi -Api Graph -Requests $requests -ResourceName 'GraphBatch' -ErrorAction Stop

        @($result['paged'].Items) | Should -HaveCount 2
        $result['paged'].Items[1].id | Should -Be 'member-2'
    }
}