Files
Backup-Dashboard/collectors/tests/Module.Tests.ps1
kyleandClaude Opus 5.5 c5fe69a813 Add a Pester test suite for the collectors
84 tests in collectors/tests, run with Invoke-CollectorTests.ps1 (~15 s,
PowerShell 7.2+ and Pester 5.5+):

- Module.Tests: the shared module (property lookup, time format,
  config/lookback, sent-run state and pruning, dry runs, exit codes,
  401 detection)
- Veeam/Mars/Wsb/Vb365.Tests: each collector run in its own process
  against its mock and a stand-in ingest API, asserting the behaviours
  confirmed on real servers: VBR 13 live schedule, copy jobs, MARS UTC
  times and ErrorCode 0, WSB culture-formatted times and nested VMs,
  VB365 organization mapping, log prefixes and retries, state and
  resending, rejected keys
- Installers.Tests: config, files and scheduled task from each
  installer, with stand-ins for icacls / Task Scheduler / the key prompt

Collectors run under the test runner's PowerShell, or another shell via
-CollectorShell (e.g. powershell.exe, to test under Windows PowerShell
5.1 like the MARS, WSB and VB365 servers). The mocks take their "now"
from MOCK_NOW so expectations can't drift with the clock; the WSB mock
gains -RunningJobFinished for the running-to-finished case.

Each of 12 past bugs, put back one at a time, fails the test for its
behaviour (and the runner exits 1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:36:27 -06:00

137 lines
7.3 KiB
PowerShell

# 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
}
}