tests/Analyze.CostAnomaly.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# Pester tests for src/analyze/Get-ScoutCostAnomaly.ps1 — cost anomaly detection (ADO Story AB#324). Uses synthetic cost datasets (both the raw Get-AZSCCostInventory shape and a pre-normalized shape) — no Azure connection required, no network calls. #> BeforeAll { $script:Root = Split-Path $PSScriptRoot -Parent . "$script:Root/src/analyze/Get-ScoutCostAnomaly.ps1" # Raw Get-AZSCCostInventory shape: Row order is # [cost, usageDate, resourceType, resourceGroup, resourceLocation, serviceName, currency] function New-RawCostBlock { param([string] $SubscriptionId, [string] $SubscriptionName, [array] $Rows) [pscustomobject]@{ SubscriptionId = $SubscriptionId SubscriptionName = $SubscriptionName CostData = [pscustomobject]@{ Row = $Rows } } } } Describe 'Get-ScoutCostAnomaly -- graceful degradation with no data' { It 'returns HasData = $false and a clear message for $null input' { $result = Get-ScoutCostAnomaly -CostData $null $result.HasData | Should -BeFalse $result.Message | Should -Not -BeNullOrEmpty $result.Anomalies | Should -BeNullOrEmpty $result.TopMovers | Should -BeNullOrEmpty } It 'returns HasData = $false for an empty array' { $result = Get-ScoutCostAnomaly -CostData @() $result.HasData | Should -BeFalse $result.RecordCount | Should -Be 0 } It 'does not throw' { { Get-ScoutCostAnomaly -CostData $null } | Should -Not -Throw } } Describe 'Get-ScoutCostAnomaly -- flat data produces no anomalies' { BeforeAll { $flat = 1..6 | ForEach-Object { [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = "2026-0$_"; Amount = 500.00 } } $script:Result = Get-ScoutCostAnomaly -CostData $flat -MinDataPoints 4 } It 'flags zero anomalies against a perfectly flat series' { $script:Result.HasData | Should -BeTrue $script:Result.Anomalies.Count | Should -Be 0 } It 'still returns top movers with zero delta' { $script:Result.TopMovers.Count | Should -BeGreaterThan 0 $script:Result.TopMovers[0].DeltaAmount | Should -Be 0 } } Describe 'Get-ScoutCostAnomaly -- injected spike is flagged (pre-normalized dataset)' { BeforeAll { $data = @( [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = '2026-01'; Amount = 500.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = '2026-02'; Amount = 510.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = '2026-03'; Amount = 495.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = '2026-04'; Amount = 505.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'virtualMachines'; Period = '2026-05'; Amount = 5000.00 } # clear spike # unrelated flat group -- must not be flagged [pscustomobject]@{ Scope = 'sub-dev'; ResourceType = 'storageAccounts'; Period = '2026-01'; Amount = 50.00 } [pscustomobject]@{ Scope = 'sub-dev'; ResourceType = 'storageAccounts'; Period = '2026-02'; Amount = 50.00 } [pscustomobject]@{ Scope = 'sub-dev'; ResourceType = 'storageAccounts'; Period = '2026-03'; Amount = 50.00 } [pscustomobject]@{ Scope = 'sub-dev'; ResourceType = 'storageAccounts'; Period = '2026-04'; Amount = 50.00 } ) $script:Result = Get-ScoutCostAnomaly -CostData $data -MinDataPoints 4 } It 'flags the spiking group' { $script:Result.HasData | Should -BeTrue $script:Result.Anomalies.Count | Should -BeGreaterThan 0 ($script:Result.Anomalies | Where-Object { $_.Scope -eq 'sub-prod' -and $_.ResourceType -eq 'virtualMachines' }).Count | Should -BeGreaterThan 0 } It 'flags it with Critical or High severity' { $flagged = $script:Result.Anomalies | Where-Object { $_.Scope -eq 'sub-prod' } $flagged.Severity | Should -Contain 'Critical' } It 'does not flag the unrelated flat group' { @($script:Result.Anomalies | Where-Object { $_.Scope -eq 'sub-dev' }).Count | Should -Be 0 } It 'reports the spike as the top mover' { $script:Result.TopMovers[0].Scope | Should -Be 'sub-prod' $script:Result.TopMovers[0].DeltaAmount | Should -BeGreaterThan 1000 } } Describe 'Get-ScoutCostAnomaly -- raw Get-AZSCCostInventory shape is understood' { BeforeAll { $rows = @( , @(100.00, '2026-01-01', 'virtualMachines', 'rg-workload1', 'eastus', 'Virtual Machines', 'USD') , @(105.00, '2026-02-01', 'virtualMachines', 'rg-workload1', 'eastus', 'Virtual Machines', 'USD') , @(98.00, '2026-03-01', 'virtualMachines', 'rg-workload1', 'eastus', 'Virtual Machines', 'USD') , @(4000.00, '2026-04-01', 'virtualMachines', 'rg-workload1', 'eastus', 'Virtual Machines', 'USD') ) $raw = @( New-RawCostBlock -SubscriptionId 'sub-0001' -SubscriptionName 'sub-prod' -Rows $rows ) $script:Result = Get-ScoutCostAnomaly -CostData $raw -MinDataPoints 4 } It 'normalizes and processes the raw extraction shape without throwing' { $script:Result.HasData | Should -BeTrue $script:Result.RecordCount | Should -Be 4 } It 'flags the injected spike from the raw shape' { $script:Result.Anomalies.Count | Should -BeGreaterThan 0 $script:Result.Anomalies[0].Scope | Should -Be 'sub-prod' } } Describe 'Get-ScoutCostAnomaly -- spike-only detection with short (2-period) series' { It 'flags a sudden spike even with only 2 periods (below MinDataPoints)' { $data = @( [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'sqlDatabases'; Period = '2026-01'; Amount = 200.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'sqlDatabases'; Period = '2026-02'; Amount = 900.00 } ) $result = Get-ScoutCostAnomaly -CostData $data -MinDataPoints 4 $result.Anomalies.Count | Should -Be 1 $result.Anomalies[0].Method | Should -Be 'Spike' } It 'does not flag a mild 2-period change under the spike threshold' { $data = @( [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'sqlDatabases'; Period = '2026-01'; Amount = 200.00 } [pscustomobject]@{ Scope = 'sub-prod'; ResourceType = 'sqlDatabases'; Period = '2026-02'; Amount = 220.00 } ) $result = Get-ScoutCostAnomaly -CostData $data $result.Anomalies.Count | Should -Be 0 } } |