tests/Get-MsecAzureCost.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureCost.
#
# Three things here are easy to get wrong, and all three were found against the live API:
#
# * Cost Management THROTTLES hard, and the natural use - a loop over every resource group -
# is exactly the shape that trips it. Losing a scope silently makes the total too low,
# which is the worst way for a cost report to be wrong: it still looks like a total.
# * the response carries a CURRENCY, and dropping it is how a report adds SEK to EUR.
# * column ORDER is not contractual, so reading rows[0][0] as the cost works right up until
# the API reorders and it starts returning a currency code as a number.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop

    $script:Ok = {
        param($Cost, $Currency = 'SEK', $Columns = @('PreTaxCost', 'Currency'))
        $row = if ($Columns[0] -eq 'PreTaxCost') { @($Cost, $Currency) } else { @($Currency, $Cost) }
        [pscustomobject]@{
            StatusCode = 200
            Content = (@{ properties = @{ columns = @($Columns | ForEach-Object { @{ name = $_ } }); rows = @(, $row) } } | ConvertTo-Json -Depth 10)
        }
    }
}

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecAzureCost' {

    BeforeEach {
        InModuleScope Msec {
            Mock Get-AzContext -MockWith {
                [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-1' } }
            }
        }
    }

    It 'returns the cost with its currency, unrounded' {
        $row = InModuleScope Msec -Parameters @{ Ok = $script:Ok } {
            param($Ok)
            Mock Invoke-AzRestMethod -MockWith { & $Ok 3796.3584217 'SEK' }
            Get-MsecAzureCost
        }

        # Not rounded: the collector must not lose the difference between 0.4 and 0, because
        # the second reads as free.
        $row.Cost     | Should -Be 3796.3584217
        $row.Currency | Should -Be 'SEK'
        $row.Scope    | Should -Be 'sub-1'
    }

    It 'reads the columns by name, not by position' {
        $row = InModuleScope Msec -Parameters @{ Ok = $script:Ok } {
            param($Ok)
            # The same answer with the columns the other way round. Reading rows[0][0] would
            # return 'SEK' as the cost.
            Mock Invoke-AzRestMethod -MockWith { & $Ok 1234.5 'EUR' @('Currency', 'PreTaxCost') }
            Get-MsecAzureCost
        }

        $row.Cost     | Should -Be 1234.5
        $row.Currency | Should -Be 'EUR'
    }

    It 'retries a throttled scope, then reports it as missing rather than as zero' {
        $rows = InModuleScope Msec -Parameters @{ Ok = $script:Ok } {
            param($Ok)
            $script:Calls = 0
            Mock Start-Sleep -MockWith { }
            Mock Invoke-AzRestMethod -MockWith {
                $script:Calls++
                # Throttled twice, then answers - the ordinary case under a resource-group loop.
                if ($script:Calls -le 2) { [pscustomobject]@{ StatusCode = 429; Content = '{}'; Headers = @() } }
                else { & $Ok 99.5 'SEK' }
            }
            $result = Get-MsecAzureCost
            $result
        }

        @($rows).Count | Should -Be 1
        $rows.Cost     | Should -Be 99.5
    }

    It 'warns loudly when a scope is lost to throttling, because the total goes short' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            Mock Invoke-AzRestMethod -MockWith { [pscustomobject]@{ StatusCode = 429; Content = '{}'; Headers = @() } }
            Get-MsecAzureCost -ResourceGroupName 'rg-busy'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # No row at all - a fabricated 0 would silently understate the estate's spend.
        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'contributes NO row'
        ($warnings -join ' ') | Should -Match 'short by its cost'
    }

    It 'warns when a run spans two billing currencies' {
        $warnings = @()
        InModuleScope Msec -Parameters @{ Ok = $script:Ok } {
            param($Ok)
            $script:N = 0
            Mock Invoke-AzRestMethod -MockWith {
                $script:N++
                if ($script:N -eq 1) { & $Ok 100 'SEK' } else { & $Ok 200 'EUR' }
            }
            Get-MsecAzureCost -ResourceGroupName 'rg-se', 'rg-de'
        } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null

        # Adding SEK to EUR produces a number that means nothing, and nothing downstream can
        # tell it happened unless this says so.
        ($warnings -join ' ') | Should -Match 'billing currencies'
        ($warnings -join ' ') | Should -Match 'Do NOT sum'
    }

    It 'names Cost Management Reader on a denial' {
        $warnings = @()
        InModuleScope Msec {
            Mock Invoke-AzRestMethod -MockWith { [pscustomobject]@{ StatusCode = 403; Content = 'denied' } }
            Get-MsecAzureCost
        } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null

        # Reader on the subscription is NOT enough for this API, and the message must say so
        # or the reader checks access that is already correct.
        ($warnings -join ' ') | Should -Match 'Cost Management Reader'
        ($warnings -join ' ') | Should -Match 'not sufficient'
    }

    It 'treats a scope with no charges as zero, not as a failure' {
        $row = InModuleScope Msec {
            # An empty resource group genuinely costs nothing.
            Mock Invoke-AzRestMethod -MockWith {
                [pscustomobject]@{ StatusCode = 200
                    Content = (@{ properties = @{ columns = @(@{ name = 'PreTaxCost' }, @{ name = 'Currency' }); rows = @() } } | ConvertTo-Json -Depth 10) }
            }
            Get-MsecAzureCost -ResourceGroupName 'rg-empty'
        }

        $row.Cost              | Should -Be 0
        $row.ResourceGroupName | Should -Be 'rg-empty'
    }

    It 'throws a clear error without an Az context' {
        InModuleScope Msec {
            Mock Get-AzContext -MockWith { $null }
            { Get-MsecAzureCost } | Should -Throw '*Connect-AzAccount*'
        }
    }
}