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]>
This commit is contained in:
2026-10-08 13:36:27 -06:00
co-authored by Claude Opus 5.5
parent 2fa9eea274
commit c5fe69a813
14 changed files with 1011 additions and 5 deletions
+108
View File
@@ -0,0 +1,108 @@
# Windows Server Backup collector, against collectors/wsb/tests/MockWsb.ps1 (shapes from a Server
# 2019 Hyper-V host: job times are culture-formatted local-time strings, HRESULTs are strings,
# VMs are nested under a VirtualMachineList item).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
$vms = @('AAOG-EDM-AD1', 'Host Component')
$hostComponentBytes = 447729
}
Describe 'Windows Server Backup collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector wsb -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
function At([double]$HoursAgo) { $byStart[(ConvertTo-ApiTime $now.AddHours(-$HoursAgo))] }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, with the next backup from Get-WBSummary' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'wsb:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Windows Server Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Windows Server Backup'
$next = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'reads job times as local time, and sends no run ID (WSB jobs have none; the API keys on start time)' {
(At 12).finished_at | Should -Be (ConvertTo-ApiTime $now.AddHours(-11.6))
$runs | ForEach-Object { $_.PSObject.Properties.Name | Should -Not -Contain 'external_id' }
}
It 'reports a Hyper-V backup with its VMs (nested in VirtualMachineList) and size' {
$run = At 12
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.details.items | Should -Be $vms
$run.size_bytes | Should -Be (124756706036 + $hostComponentBytes)
}
It "doesn't cite the failure log for a success (WSB fills it in regardless)" {
(At 12).message | Should -Not -BeLike '*Failure log*'
}
It 'reports a backup where one VM failed as a warning, naming the VM' {
$run = At 36
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*AAOG-EDM-AD1 - Access is denied.*'
$run.message | Should -BeLike '*Failure log: C:\Windows\Logs\WindowsServerBackup\Backup_Error.log*'
}
It 'reports a backup with an error HRESULT as failed, with the code in 0x8 form' {
$run = At 30
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*The backup storage location could not be found.*'
$run.message | Should -BeLike '*error 0x80042302*'
}
It 'reports a volume backup with its volumes and size' {
$run = At 20
$run.status | Should -Be 'success'
$run.details.items | Should -Be @('C:', 'D:')
$run.size_bytes | Should -Be (240GB)
}
It "ignores restores, old backups, and Get-WBJob's empty job when nothing is running" {
$runs.Count | Should -Be 4
}
It 'reads dd/MM dates correctly on servers with other regional settings' {
$gb = Invoke-Collector wsb -Config $config -DryRun -Culture 'en-GB'
($gb.Reports | ConvertTo-Json -Depth 6) | Should -Be ($r.Reports | ConvertTo-Json -Depth 6)
}
It 'reports a backup in progress as running, with no message' {
$running = Invoke-Collector wsb -Config $config -DryRun -MockArgs '-WithRunningJob'
$run = $running.Reports | Where-Object { $_.run.status -eq 'running' } | ForEach-Object run
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-10))
$run.message | Should -Be ''
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.details.items | Should -Be $vms
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector wsb -Config $live -MockArgs '-WithRunningJob'
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector wsb -Config $live -WorkDir $first.WorkDir -MockArgs '-RunningJobFinished'
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run, remembering the finished ones' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'sends the finished backup once it ends, with the same start time, so the API merges it into the running one' {
$running = $firstRequests.Payload.run | Where-Object status -EQ 'running'
$secondRequests.Count | Should -Be 1
$finished = $secondRequests[0].Payload.run
$finished.status | Should -Be 'success'
$finished.started_at | Should -Be $running.started_at
$second.State.Count | Should -Be 5
}
}
}