# Tests for collectors/common/BackupDashboard.Collector.psm1, the code all collectors share. BeforeAll { Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'common/BackupDashboard.Collector.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force function New-TempConfig([hashtable]$Values, [hashtable]$State) { $dir = New-TestDir 'module' $path = Join-Path $dir 'test-collector.json' $Values | ConvertTo-Json | Set-Content -Path $path if ($State) { $State | ConvertTo-Json | Set-Content -Path (Join-Path $dir 'test-collector.state.json') } return $path } $config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' } } Describe 'Get-FirstValue' { It 'returns the first property that has a value' { Get-FirstValue ([pscustomobject]@{ A = $null; B = ''; C = 'yes' }) @('A', 'B', 'C') | Should -Be 'yes' } It 'treats an empty collection as no value' { Get-FirstValue ([pscustomobject]@{ A = @(); B = 'yes' }) @('A', 'B') | Should -Be 'yes' } It 'returns a one-item list of objects (which [string] would turn into an empty string)' { $item = [pscustomobject]@{ Name = 'E:\' } $result = @(Get-FirstValue ([pscustomobject]@{ List = @($item) }) @('List')) $result.Count | Should -Be 1 $result[0].Name | Should -Be 'E:\' } It 'returns $null for a missing property or object' { Get-FirstValue ([pscustomobject]@{ A = 1 }) @('Nope') | Should -BeNullOrEmpty Get-FirstValue $null @('A') | Should -BeNullOrEmpty } It 'keeps $false (a real value, not "missing")' { Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled') | Should -BeFalse $null -eq (Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled')) | Should -BeFalse } } Describe 'ConvertTo-IsoTime' { It 'gives local times their offset' { $local = [datetime]::new(2026, 10, 8, 21, 0, 0, [DateTimeKind]::Local) ConvertTo-IsoTime $local | Should -Be ([DateTimeOffset]$local).ToString("yyyy-MM-dd'T'HH:mm:sszzz") } It 'gives UTC times +00:00' { ConvertTo-IsoTime ([datetime]::new(2026, 10, 8, 3, 0, 5, [DateTimeKind]::Utc)) | Should -Be '2026-10-08T03:00:05+00:00' } It 'treats "not set" values as no time' { ConvertTo-IsoTime ([datetime]::MinValue) | Should -BeNullOrEmpty ConvertTo-IsoTime ([datetime]::new(1601, 1, 1)) | Should -BeNullOrEmpty # MARS ConvertTo-IsoTime ([datetime]::new(1900, 1, 1)) | Should -BeNullOrEmpty # Veeam ConvertTo-IsoTime $null | Should -BeNullOrEmpty } } Describe 'Initialize-Collector' { It 'requires ApiUrl, ApiKey and Tenant by default' { $path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' } { Initialize-Collector -Name 'test' -ConfigPath $path } | Should -Throw "*missing 'Tenant'*" } It 'accepts a custom set of required keys' { $path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' } { Initialize-Collector -Name 'test' -ConfigPath $path -RequiredKeys 'ApiUrl', 'ApiKey' } | Should -Not -Throw } It 'resolves LookbackHours: parameter, then config, then 48' { (Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig $config)).LookbackHours | Should -Be 48 (Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 }))).LookbackHours | Should -Be 720 (Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 })) -LookbackHours 12).LookbackHours | Should -Be 12 } It 'loads runs already sent from the state file' { $path = New-TempConfig $config @{ 'run-1' = [DateTimeOffset]::Now.ToUnixTimeSeconds() } $null = Initialize-Collector -Name 'test' -ConfigPath $path Test-RunSent 'run-1' | Should -BeTrue Test-RunSent 'run-2' | Should -BeFalse } } Describe 'Send-JobReport and Complete-Collector' { BeforeEach { $path = New-TempConfig $config @{ 'recent' = [DateTimeOffset]::Now.AddHours(-10).ToUnixTimeSeconds() 'stale' = [DateTimeOffset]::Now.AddHours(-100).ToUnixTimeSeconds() # > 48h + 1 day ago } $statePath = Join-Path (Split-Path $path) 'test-collector.state.json' $job = [ordered]@{ external_id = 'j1'; name = 'Job'; next_run_at = $null } } It 'records a finished run as sent, but not a running one' { $null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun $null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'done' -StartedAt (Get-Date) 6>&1 $null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'running' }) -SentKey 'busy' -StartedAt (Get-Date) 6>&1 Test-RunSent 'done' | Should -BeTrue Test-RunSent 'busy' | Should -BeFalse } It 'prints the payload in a dry run, with the job and run' { $null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun $printed = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'x' -StartedAt (Get-Date) 6>&1 $payload = "$printed" | ConvertFrom-Json $payload.tenant | Should -Be 'Acme' $payload.source_system | Should -Be 'Test' $payload.job.external_id | Should -Be 'j1' $payload.run.status | Should -Be 'success' } It 'saves the state, dropping runs older than the lookback window plus a day' { $null = Initialize-Collector -Name 'test' -ConfigPath $path $null = Complete-Collector -Failures 0 6>&1 $saved = Get-Content $statePath -Raw | ConvertFrom-Json $saved.PSObject.Properties.Name | Should -Be @('recent') } It "doesn't touch the state in a dry run" { $before = Get-Content $statePath -Raw $null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun $null = Complete-Collector -Failures 0 6>&1 Get-Content $statePath -Raw | Should -Be $before } It 'returns exit code 1 when anything failed, 0 otherwise' { $null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun Complete-Collector -Failures 0 6>$null | Should -Be 0 Complete-Collector -Failures 2 6>$null | Should -Be 1 } It 'logs a summary with or without a job count' { $null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun "$(Complete-Collector -JobCount 3 -Failures 0 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 3 jobs, 0 runs, 0 failures$' "$(Complete-Collector -Failures 1 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 0 runs, 1 failures$' } } Describe 'Test-AuthRejected' { It 'is true only for HTTP 401' { $error401 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 401 } } } $error500 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 500 } } } $noResponse = [pscustomobject]@{ Exception = [pscustomobject]@{ Message = 'timeout' } } Test-AuthRejected $error401 | Should -BeTrue Test-AuthRejected $error500 | Should -BeFalse Test-AuthRejected $noResponse | Should -BeFalse } }