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
+116
View File
@@ -0,0 +1,116 @@
# Veeam Backup for Microsoft 365 collector, against collectors/vb365/tests/MockVb365.ps1 (shapes from
# a VB365 8.4 server: log items carry their status as a Title prefix, retries are separate sessions).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$base = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' }
function Get-JobRuns($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
function Get-Tenants($Reports) {
$map = @{}
foreach ($report in $Reports) { $map[$report.job.name] = $report.tenant }
return $map
}
}
Describe 'VB365 collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector vb365 -Config ($base + @{ Tenant = 'Default Co' }) -DryRun
$acme = Get-JobRuns $r.Reports 'Acme 365 Backup'
$blue = Get-JobRuns $r.Reports 'Bluewater 365 Backup'
}
It 'runs cleanly, reporting every job' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 3 jobs, 5 runs, 0 failures'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam Backup for M365'
}
It "uses each job's NextRun, and none for a disabled job" {
$next = $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))
($r.Reports | Where-Object { $_.job.name -eq 'Acme 365 Backup' })[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
($r.Reports | Where-Object { $_.job.name -eq 'Bluewater Old Mailboxes' })[0].job.next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its size ("187.2 MB" in bytes) and objects' {
$run = $acme | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(-1).AddHours(17))
$run.size_bytes | Should -Be ([long](187.2 * 1MB))
$run.details.processed_objects | Should -Be 568
$run.details.organization | Should -Be 'acme.onmicrosoft.com'
$run.message | Should -Be 'Job completed successfully.'
}
It 'reports a session in progress as running, without a finish time' {
$run = $acme | Where-Object status -EQ 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'builds the message from [Warning]/[Failed] log items, without the prefix' {
$run = $blue | Where-Object status -EQ 'warning'
$run.message | Should -BeLike 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size*'
# Literal check: in -like patterns, [Warning] is a character set
$run.message.Contains('[Warning]') | Should -BeFalse
}
It 'ignores [Success] log items, even ones mentioning "error"' {
($blue | Where-Object status -EQ 'warning').message | Should -Not -BeLike '*error-reports*'
}
It 'flags a session VB365 will retry, and links the retry back to it' {
$warning = $blue | Where-Object status -EQ 'warning'
$retry = $blue | Where-Object { $_.details.retry_of }
$warning.message | Should -BeLike '*Veeam will retry this job.'
$warning.details.will_retry | Should -BeTrue
$retry.status | Should -Be 'success'
$retry.details.retry_of | Should -Be $warning.external_id
}
It 'reports a failed session with its error' {
$run = $blue | Where-Object status -EQ 'failed'
$run.message | Should -BeLike '*(401) Unauthorized*'
}
It 'skips sessions outside the lookback window' {
Get-JobRuns $r.Reports 'Bluewater Old Mailboxes' | Should -BeNullOrEmpty
}
}
Context 'which company a job appears under' {
It 'uses OrganizationTenants (not case-sensitive), then Tenant for unmapped organizations' {
$r = Invoke-Collector vb365 -DryRun -Config ($base + @{
Tenant = 'Default Co'; OrganizationTenants = @{ 'ACME.onmicrosoft.com' = 'Acme Corp' } })
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'Acme Corp'
$tenants['Bluewater 365 Backup'] | Should -Be 'Default Co'
}
It "falls back to the organization's name with no mapping and no Tenant" {
$r = Invoke-Collector vb365 -Config $base -DryRun
$r.ExitCode | Should -Be 0
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'acme.onmicrosoft.com'
$tenants['Bluewater 365 Backup'] | Should -Be 'bluewater.onmicrosoft.com'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $base + @{ Tenant = 'Default Co' }; $live.ApiUrl = $stub.Url
$first = Invoke-Collector vb365 -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector vb365 -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every session, remembering the finished ones' {
$first.ExitCode | Should -Be 0
@($firstRequests | Where-Object { $_.Payload.run }).Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 3
}
}
}