tests/CostInventoryResilience.Tests.ps1

<#
    AB#5636 — cost data is optional enrichment and must never cost the caller their inventory.

    The original catch block ran `throw $_.Exception.Message` with an unreachable
    `$Costs = @()` on the line after it, so the intended fallback never executed and any
    Cost Management failure destroyed the entire run. On a machine without
    Az.CostManagement installed, -IncludeCosts lost the whole report.
#>


BeforeAll {
    $script:RepoRoot = Split-Path -Parent $PSScriptRoot
    . (Join-Path $script:RepoRoot 'Modules/Private/Main/Write-AZTIRunLog.ps1')
    . (Join-Path $script:RepoRoot 'Modules/Private/Extraction/Get-AZTICostInventory.ps1')

    # Pester cannot mock a command that does not exist, and the whole point of AB#5636 is
    # that Az.CostManagement may be absent. Stub it so the mocks below have something to
    # replace; the real module, when present, takes precedence outside these tests.
    if (-not (Get-Command Invoke-AzCostManagementQuery -ErrorAction SilentlyContinue)) {
        function global:Invoke-AzCostManagementQuery {
            param($Type, $Scope, $Timeframe, $DatasetGranularity, $DatasetGrouping,
                  $DatasetAggregation, $TimePeriodFrom, $TimePeriodTo, $Debug)
        }
    }

    $script:Subs = @(
        [pscustomobject]@{ id = 'sub-1'; name = 'First subscription' }
        [pscustomobject]@{ id = 'sub-2'; name = 'Second subscription' }
    )
}

Describe 'Get-AZSCCostInventory resilience' {

    It 'completes instead of throwing when Az.CostManagement is not installed' {
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith {
            throw "The term 'Invoke-AzCostManagementQuery' is not recognized as a name of a cmdlet, function, script file, or executable program."
        }
        { Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly' -WarningAction SilentlyContinue } |
            Should -Not -Throw
    }

    It 'names the install command so the operator can act on it' {
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith {
            throw "The term 'Invoke-AzCostManagementQuery' is not recognized as a name of a cmdlet, function, script file, or executable program."
        }
        # Captured by stream redirection rather than -WarningVariable: Get-AZSCCostInventory
        # is a simple function, so the common warning parameters are not reliable here.
        $Warnings = @(Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly' 3>&1 |
            Where-Object { $_ -is [System.Management.Automation.WarningRecord] })
        ($Warnings -join ' ') | Should -Match 'Install-Module Az.CostManagement'
    }

    It 'still returns one row per subscription, with empty cost data' {
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith { throw 'Cost Management is unavailable' }
        $Result = @(Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly' -WarningAction SilentlyContinue)
        $Result.Count | Should -Be 2
        @($Result[0].CostData).Count | Should -Be 0
        $Result[1].SubscriptionName | Should -Be 'Second subscription'
    }

    It 'keeps collecting the remaining subscriptions after one fails' {
        # One bad subscription must not take the others down with it.
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith {
            if ($Scope -like '*sub-1*') { throw 'Subscription not enrolled for Cost Management' }
            return @([pscustomobject]@{ PreTaxCost = 42 })
        }
        $Result = @(Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly' -WarningAction SilentlyContinue)
        @($Result[0].CostData).Count | Should -Be 0
        @($Result[1].CostData).Count | Should -Be 1
    }

    It 'warns with the underlying message for a non-dependency failure' {
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith { throw 'Subscription not enrolled for Cost Management' }
        $Warnings = @(Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly' 3>&1 |
            Where-Object { $_ -is [System.Management.Automation.WarningRecord] })
        ($Warnings -join ' ') | Should -Match 'not enrolled for Cost Management'
    }

    It 'returns the cost data unchanged on the success path' {
        Mock -CommandName Invoke-AzCostManagementQuery -MockWith { return @([pscustomobject]@{ PreTaxCost = 7 }) }
        $Result = @(Get-AZSCCostInventory -Subscriptions $script:Subs -Days 60 -Granularity 'Monthly')
        $Result.Count | Should -Be 2
        $Result[0].CostData[0].PreTaxCost | Should -Be 7
    }
}

Describe 'Cost dependency stays opt-in' {

    It 'does NOT auto-install Az.CostManagement on module import' {
        # Auto-installing it drags in a newer Az.Accounts as a dependency. On a machine that
        # already has Az.Accounts that leaves two versions side by side, and every subsequent
        # Import-Module dies with a stack overflow inside Az.Accounts' assembly-load-context
        # resolver. Observed on a working machine, caused by exactly this line. (AB#5636)
        $Psm1 = Get-Content -Path (Join-Path $script:RepoRoot 'AzureScout.psm1')
        $Bootstrap = $Psm1 | Where-Object { $_ -notmatch '^\s*#' }
        ($Bootstrap -join "`n") | Should -Not -Match "'Az\.CostManagement'"
    }

    It 'documents Az.CostManagement as an optional prerequisite instead' {
        $Prereq = Get-Content -Raw -Path (Join-Path $script:RepoRoot 'docs/prerequisites.md')
        $Prereq | Should -Match 'Az\.CostManagement'
        $Prereq | Should -Match 'IncludeCosts'
    }
}

Describe 'The dead-code fallback is gone' {

    It 'no longer rethrows out of the cost collector' {
        $Raw = Get-Content -Path (Join-Path $script:RepoRoot 'Modules/Private/Extraction/Get-AZTICostInventory.ps1')
        $Active = ($Raw | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"
        $Active | Should -Not -Match 'throw \$_\.Exception\.Message'
    }
}