Tests/WinBatchOrchestrator.Tests.ps1
|
#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { # Pester 5+ runs Describe bodies during discovery; anything with side effects # (imports, file setup) must live in a Before* block. $script:ModuleRoot = Split-Path -Parent $PSScriptRoot $script:AssetsDir = Join-Path $script:ModuleRoot "Assets" $script:ManifestPath = Join-Path $script:ModuleRoot "WinBatchOrchestrator.psd1" # Redirect the Ops root at a scratch directory so the asset scripts operate # on test data instead of C:\Ops. $script:OriginalOpsRoot = $env:OPS_ROOT $script:TestOpsRoot = Join-Path ([System.IO.Path]::GetTempPath()) "WBO-Tests-$([guid]::NewGuid())" $env:OPS_ROOT = $script:TestOpsRoot New-Item -ItemType Directory -Path (Join-Path $script:TestOpsRoot "Logs") -Force | Out-Null Import-Module $script:ManifestPath -Force } AfterAll { Remove-Module WinBatchOrchestrator -Force -ErrorAction SilentlyContinue if ($null -eq $script:OriginalOpsRoot) { Remove-Item Env:\OPS_ROOT -ErrorAction SilentlyContinue } else { $env:OPS_ROOT = $script:OriginalOpsRoot } if (Test-Path $script:TestOpsRoot) { Remove-Item -Path $script:TestOpsRoot -Recurse -Force -ErrorAction SilentlyContinue } } Describe "Module manifest" { It "is a valid manifest" { { Test-ModuleManifest -Path $script:ManifestPath -ErrorAction Stop } | Should -Not -Throw } It "exports exactly the documented commands" { $manifest = Test-ModuleManifest -Path $script:ManifestPath $manifest.ExportedFunctions.Keys | Sort-Object | Should -Be (@( 'Get-OpsJob', 'Get-OpsJobHistory', 'Get-OpsJobLog', 'Initialize-OrchestratorEnvironment', 'New-OpsCreds', 'New-OpsJob', 'Register-OpsJobs', 'Remove-OpsJob', 'Start-OpsJob', 'Stop-OpsJob' ) | Sort-Object) } It "lists only files that exist" { $manifest = Test-ModuleManifest -Path $script:ManifestPath $missing = $manifest.FileList | Where-Object { -not (Test-Path $_) } $missing | Should -BeNullOrEmpty } } Describe "Asset scripts" { It "<Name> parses without syntax errors" -ForEach @( Get-ChildItem -Path (Join-Path (Split-Path -Parent $PSScriptRoot) "Assets") -Include *.ps1, *.psm1 -Recurse | ForEach-Object { @{ Name = $_.Name; Path = $_.FullName } } ) { $errors = $null $tokens = $null [void][System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) $errors | Should -BeNullOrEmpty } } Describe "Invoke-OpsScript" { It "throws a helpful error when the asset script is missing" { InModuleScope WinBatchOrchestrator { Mock Test-Path { $false } { Invoke-OpsScript -ScriptName "Missing.ps1" } | Should -Throw "*Script not found*" } } It "strips common parameters that the asset scripts cannot bind" { InModuleScope WinBatchOrchestrator { Mock Test-Path { $true } $script:Captured = $null # Stand in for the '& $ScriptPath @Splat' invocation. Mock Join-Path { "TestDrive:\fake.ps1" } $splat = @{} $arguments = @{ JobName = 'X'; Verbose = $true; ErrorAction = 'Stop'; WhatIf = $true } foreach ($key in $arguments.Keys) { if ($script:CommonParameterNames -notcontains $key) { $splat[$key] = $arguments[$key] } } $splat.Keys | Should -Be @('JobName') } } } Describe "Proxy functions dispatch to the matching asset script" { It "<Command> invokes <ScriptFile>" -ForEach @( @{ Command = 'New-OpsJob'; ScriptFile = 'New-OpsJob.ps1'; Params = @{ JobName = 'TestJob'; ScriptPath = 'C:\Test.ps1'; ScheduleTime = '03:00' } } @{ Command = 'Get-OpsJob'; ScriptFile = 'Get-OpsJob.ps1'; Params = @{ JobName = 'TestJob' } } @{ Command = 'Start-OpsJob'; ScriptFile = 'Start-OpsJob.ps1'; Params = @{ JobName = 'TestJob' } } @{ Command = 'Stop-OpsJob'; ScriptFile = 'Stop-OpsJob.ps1'; Params = @{ JobName = 'TestJob' } } @{ Command = 'Get-OpsJobHistory'; ScriptFile = 'Get-OpsJobHistory.ps1'; Params = @{ JobName = 'TestJob' } } @{ Command = 'Get-OpsJobLog'; ScriptFile = 'Get-OpsJobLog.ps1'; Params = @{ JobName = 'TestJob' } } @{ Command = 'New-OpsCreds'; ScriptFile = 'New-OpsCreds.ps1'; Params = @{ Name = 'DbCreds' } } @{ Command = 'Register-OpsJobs'; ScriptFile = 'Register-OpsJobs.ps1'; Params = @{ ConfigPath = 'C:\jobs.psd1' } } ) { InModuleScope WinBatchOrchestrator -Parameters @{ Command = $Command; Expected = $ScriptFile; Splat = $Params } { Mock Invoke-OpsScript { } & $Command @Splat Should -Invoke Invoke-OpsScript -Times 1 -Exactly -ParameterFilter { $ScriptName -eq $Expected } } } It "Remove-OpsJob honours -WhatIf and does not dispatch" { InModuleScope WinBatchOrchestrator { Mock Invoke-OpsScript { } Remove-OpsJob -JobName "TestJob" -WhatIf Should -Invoke Invoke-OpsScript -Times 0 -Exactly } } It "Remove-OpsJob dispatches when confirmed" { InModuleScope WinBatchOrchestrator { Mock Invoke-OpsScript { } Remove-OpsJob -JobName "TestJob" -Confirm:$false Should -Invoke Invoke-OpsScript -Times 1 -Exactly -ParameterFilter { $ScriptName -eq 'Remove-OpsJob.ps1' } } } } Describe "New-OpsJob payload encoding" { BeforeAll { # Reproduce the encoding New-OpsJob.ps1 performs, and the decoding # JobRunner.ps1 performs, so the contract between them is covered without # needing the Task Scheduler. function Encode-Payload { param($JobName, $ScriptPath, $ScriptArguments) $normalised = @() if ($null -ne $ScriptArguments) { if ($ScriptArguments -is [System.Collections.IDictionary]) { $ordered = [ordered]@{} foreach ($k in $ScriptArguments.Keys) { $ordered[[string]$k] = $ScriptArguments[$k] } $normalised = $ordered } else { $normalised = @($ScriptArguments) } } $json = @{ JobName = $JobName; ScriptPath = $ScriptPath; ScriptArguments = $normalised EmailRecipients = @(); AlertWebhookUrl = ""; RequiredSecrets = @() } | ConvertTo-Json -Depth 10 -Compress [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) } function Decode-Payload { param([string]$Base64) $payload = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Base64)) | ConvertFrom-Json $args = @() if ($null -ne $payload.ScriptArguments) { if ($payload.ScriptArguments -is [System.Management.Automation.PSCustomObject]) { $table = @{} foreach ($p in $payload.ScriptArguments.PSObject.Properties) { $table[$p.Name] = $p.Value } $args = $table } else { $args = @($payload.ScriptArguments) } } [pscustomobject]@{ JobName = $payload.JobName; ScriptPath = $payload.ScriptPath; Arguments = $args } } } It "round-trips a hashtable as named parameters" { $decoded = Decode-Payload (Encode-Payload "Cleanup" "x.ps1" @{ DaysToKeep = 30; SourcePath = "C:\Logs\My Prod Logs"; Force = $true }) $decoded.Arguments | Should -BeOfType [hashtable] $decoded.Arguments['DaysToKeep'] | Should -Be 30 $decoded.Arguments['SourcePath'] | Should -Be "C:\Logs\My Prod Logs" $decoded.Arguments['Force'] | Should -BeTrue } It "preserves array arguments containing spaces and quotes" { $decoded = Decode-Payload (Encode-Payload "Sync" "x.js" @("incremental", "a b c", 'has"quote')) $decoded.Arguments | Should -Be @("incremental", "a b c", 'has"quote') } It "preserves a job name containing shell metacharacters" { $decoded = Decode-Payload (Encode-Payload 'Job & "weird" name' "x.ps1" $null) $decoded.JobName | Should -Be 'Job & "weird" name' } It "yields an empty argument list when none are supplied" { $decoded = Decode-Payload (Encode-Payload "Bare" "x.ps1" $null) @($decoded.Arguments).Count | Should -Be 0 } It "rejects a job name with characters invalid in a task name" { $script = Join-Path $script:AssetsDir "New-OpsJob.ps1" { & $script -JobName 'bad\name' -ScriptPath 'x.ps1' -ScheduleTime '03:00' } | Should -Throw "*invalid in a scheduled task name*" } } Describe "Get-OpsJob" { BeforeAll { $script:GetOpsJobScript = Join-Path $script:AssetsDir "Get-OpsJob.ps1" function script:New-TaskFixture { param([string]$TaskName, [string]$Arguments) [pscustomobject]@{ TaskName = $TaskName State = "Ready" Actions = @([pscustomobject]@{ Arguments = $Arguments }) } } function script:New-PayloadArguments { param($ScriptPath, $ScriptArguments) $json = @{ JobName = "Fixture"; ScriptPath = $ScriptPath; ScriptArguments = $ScriptArguments EmailRecipients = @(); AlertWebhookUrl = ""; RequiredSecrets = @() } | ConvertTo-Json -Depth 10 -Compress $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Ops\Bin\JobRunner.ps1" -PayloadBase64 ' + $b64 } # Task Scheduler cmdlets do not exist off Windows, and mocking them where # they do would still need a fixture, so both are replaced outright. # $global: rather than $script: because PowerShell resolves scopes # dynamically: read from the stub, the script scope is Get-OpsJob.ps1's. function global:Get-ScheduledTask { param($TaskName, $ErrorAction) $global:WBOTaskFixtures } function global:Get-ScheduledTaskInfo { param([Parameter(ValueFromPipeline = $true)]$Task) process { [pscustomobject]@{ LastRunTime = $null; LastTaskResult = 0; NextRunTime = $null } } } } AfterAll { Remove-Item Function:\Get-ScheduledTask, Function:\Get-ScheduledTaskInfo -ErrorAction SilentlyContinue Remove-Variable -Name WBOTaskFixtures -Scope Global -ErrorAction SilentlyContinue } It "reports the script path and named arguments from the task payload" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-UpdateBezahlt" (New-PayloadArguments "C:\Ops\Scripts\Update-Rechnungsstatus.ps1" ([ordered]@{ OpListFilter = $true; DryRun = $true }))) $job = & $script:GetOpsJobScript $job.ScriptPath | Should -Be "C:\Ops\Scripts\Update-Rechnungsstatus.ps1" $job.ScriptArguments | Should -BeOfType [System.Collections.IDictionary] $job.ScriptArguments['OpListFilter'] | Should -BeTrue $job.ScriptArguments['DryRun'] | Should -BeTrue } It "returns arguments that can be splatted back as named parameters" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-Splat" (New-PayloadArguments "x.ps1" ([ordered]@{ DaysToKeep = 30; Force = $true }))) function Test-Splat { param([int]$DaysToKeep, [switch]$Force) "$DaysToKeep|$($Force.IsPresent)" } $splat = (& $script:GetOpsJobScript).ScriptArguments Test-Splat @splat | Should -Be "30|True" } It "reports positional arguments as an array" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-Sync" (New-PayloadArguments "sync.js" @("--mode", "incremental"))) (& $script:GetOpsJobScript).ScriptArguments | Should -Be @("--mode", "incremental") } It "strips only a leading OpsJob- prefix from the job name" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-Sync-OpsJob-Nightly" (New-PayloadArguments "x.ps1" @())) (& $script:GetOpsJobScript).JobName | Should -Be "Sync-OpsJob-Nightly" } It "reports the path but no arguments for a pre-payload task" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-Legacy" '-NoProfile -File "C:\Ops\Bin\JobRunner.ps1" -ScriptPath "C:\Ops\Scripts\Old Script.ps1" -JobName Legacy') $job = & $script:GetOpsJobScript $job.ScriptPath | Should -Be "C:\Ops\Scripts\Old Script.ps1" $job.ScriptArguments | Should -BeNullOrEmpty } It "still lists a job whose action cannot be parsed" { $global:WBOTaskFixtures = @(New-TaskFixture "OpsJob-Opaque" "-NoProfile -File C:\Other\Thing.ps1") $job = & $script:GetOpsJobScript $job.JobName | Should -Be "Opaque" $job.ScriptPath | Should -BeNullOrEmpty } } Describe "Write-OpsLog" { BeforeAll { Import-Module (Join-Path $script:AssetsDir "OpsUtils.psm1") -Force $script:LogPath = Join-Path $script:TestOpsRoot "Logs\unit.history.json" } BeforeEach { Remove-Item $script:LogPath -ErrorAction SilentlyContinue $env:OPS_LOG_FILE = $script:LogPath } AfterAll { Remove-Item Env:\OPS_LOG_FILE -ErrorAction SilentlyContinue Remove-Item Env:\OPS_LOG_LEVEL -ErrorAction SilentlyContinue Remove-Module OpsUtils -Force -ErrorAction SilentlyContinue } It "writes one JSON object per line" { $env:OPS_LOG_LEVEL = "INFO" Write-OpsLog -Message "first" -Level "INFO" 6>$null Write-OpsLog -Message "second" -Level "ERROR" 6>$null $lines = @(Get-Content $script:LogPath) $lines.Count | Should -Be 2 ($lines[0] | ConvertFrom-Json).Message | Should -Be "first" ($lines[1] | ConvertFrom-Json).Level | Should -Be "ERROR" } It "suppresses messages below the configured level" { $env:OPS_LOG_LEVEL = "WARNING" Write-OpsLog -Message "chatter" -Level "DEBUG" 6>$null Write-OpsLog -Message "trouble" -Level "ERROR" 6>$null $lines = @(Get-Content $script:LogPath) $lines.Count | Should -Be 1 ($lines[0] | ConvertFrom-Json).Message | Should -Be "trouble" } It "falls back to INFO when OPS_LOG_LEVEL is not a known level" { # An unknown level used to index the level table to $null, which made the # comparison true for everything including DEBUG. $env:OPS_LOG_LEVEL = "NONSENSE" Write-OpsLog -Message "chatter" -Level "DEBUG" 6>$null Write-OpsLog -Message "notice" -Level "INFO" 6>$null $lines = @(Get-Content $script:LogPath) $lines.Count | Should -Be 1 ($lines[0] | ConvertFrom-Json).Message | Should -Be "notice" } It "preserves non-ASCII characters" { $env:OPS_LOG_LEVEL = "INFO" Write-OpsLog -Message "Größe: 5 µs — ok" -Level "INFO" 6>$null (@(Get-Content $script:LogPath)[0] | ConvertFrom-Json).Message | Should -Be "Größe: 5 µs — ok" } } Describe "Get-OpsJobHistory" { BeforeAll { $script:HistoryScript = Join-Path $script:AssetsDir "Get-OpsJobHistory.ps1" $logs = Join-Path $script:TestOpsRoot "Logs" Set-Content -Path (Join-Path $logs "HistJob.history.json") -Encoding UTF8 -Value @( '{"Timestamp":"2026-08-20 01:00:00","Level":"INFO","Message":"Starting Job: HistJob"}' '{"Timestamp":"2026-08-20 01:00:05","Level":"INFO","Message":"Job completed successfully."}' 'this line is not json' '{"Timestamp":"2026-08-21 01:00:07","Level":"ERROR","Message":"Job Failed: boom"}' ) } AfterAll { Remove-Item (Join-Path $script:TestOpsRoot "Logs\HistJob.history.json") -ErrorAction SilentlyContinue } It "parses newline-delimited JSON and skips malformed lines" { # Piping the whole file into ConvertFrom-Json throws on PowerShell 5.1 and # would discard every entry because of the malformed line. $result = @(& $script:HistoryScript -JobName "HistJob") $result.Count | Should -Be 2 } It "reports the newest entry first with the right status" { $result = @(& $script:HistoryScript -JobName "HistJob") $result[0].Status | Should -Be "Failed" $result[0].JobName | Should -Be "HistJob" $result[1].Status | Should -Be "Success" } It "honours -Count" { @(& $script:HistoryScript -JobName "HistJob" -Count 1).Count | Should -Be 1 } It "returns nothing for an unknown job" { @(& $script:HistoryScript -JobName "NoSuchJob").Count | Should -Be 0 } } Describe "Cleanup-OpsLogs" { BeforeEach { $script:CleanupScript = Join-Path $script:AssetsDir "Cleanup-OpsLogs.ps1" $script:Logs = Join-Path $script:TestOpsRoot "Logs" Get-ChildItem $script:Logs -File | Remove-Item -Force $old = (Get-Date).AddDays(-90) $recent = (Get-Date).AddDays(-1) $oldLog = Join-Path $script:Logs "Old-20240101-000000.log" "old transcript" | Set-Content $oldLog (Get-Item $oldLog).LastWriteTime = $old $newLog = Join-Path $script:Logs "New-20260825-000000.log" "new transcript" | Set-Content $newLog Set-Content -Path (Join-Path $script:Logs "Mixed.history.json") -Encoding UTF8 -Value @( ('{{"Timestamp":"{0}","Level":"INFO","Message":"ancient"}}' -f $old.ToString("yyyy-MM-dd HH:mm:ss")) ('{{"Timestamp":"{0}","Level":"INFO","Message":"recent"}}' -f $recent.ToString("yyyy-MM-dd HH:mm:ss")) ) } It "deletes transcripts older than the retention window" { & $script:CleanupScript -DaysToKeep 30 6>$null | Out-Null Test-Path (Join-Path $script:Logs "Old-20240101-000000.log") | Should -BeFalse Test-Path (Join-Path $script:Logs "New-20260825-000000.log") | Should -BeTrue } It "trims history files instead of deleting them" { # The previous version deleted every file in the directory, history included. & $script:CleanupScript -DaysToKeep 30 6>$null | Out-Null $historyPath = Join-Path $script:Logs "Mixed.history.json" Test-Path $historyPath | Should -BeTrue $lines = @(Get-Content $historyPath) $lines.Count | Should -Be 1 ($lines[0] | ConvertFrom-Json).Message | Should -Be "recent" } } Describe "Get-OpsJobLog" { It "returns the most recent transcripts newest first" { $logs = Join-Path $script:TestOpsRoot "Logs" Get-ChildItem $logs -File | Remove-Item -Force foreach ($i in 1..3) { $p = Join-Path $logs ("Tail-2026082{0}-000000.log" -f $i) "run $i" | Set-Content $p (Get-Item $p).LastWriteTime = (Get-Date).AddDays(-$i) } $result = @(& (Join-Path $script:AssetsDir "Get-OpsJobLog.ps1") -JobName "Tail" -Count 2) $result.Count | Should -Be 2 $result[0].Name | Should -Be "Tail-20260821-000000.log" } } Describe "Register-OpsJobs" { It "rejects a config file without a Jobs key" { $config = Join-Path $script:TestOpsRoot "bad.psd1" "@{ NotJobs = @() }" | Set-Content $config { & (Join-Path $script:AssetsDir "Register-OpsJobs.ps1") -ConfigPath $config } | Should -Throw "*has no 'Jobs' key*" } It "throws when the config file does not exist" { { & (Join-Path $script:AssetsDir "Register-OpsJobs.ps1") -ConfigPath (Join-Path $script:TestOpsRoot "nope.psd1") } | Should -Throw "*Config file not found*" } } Describe "JobRunner" { BeforeAll { $script:Runner = Join-Path $script:AssetsDir "JobRunner.ps1" $script:JobScripts = Join-Path $script:TestOpsRoot "Scripts" New-Item -ItemType Directory -Path $script:JobScripts -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $script:TestOpsRoot "Bin") -Force | Out-Null # JobRunner imports OpsUtils.psm1 from its own directory, so it runs from Assets. function New-Payload { param($JobName, $ScriptPath, $ScriptArguments = @(), $EmailRecipients = @()) $json = @{ JobName = $JobName ScriptPath = $ScriptPath ScriptArguments = $ScriptArguments EmailRecipients = @($EmailRecipients) AlertWebhookUrl = "" RequiredSecrets = @() } | ConvertTo-Json -Depth 10 -Compress [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) } function Invoke-Runner { param([string]$Base64) # Run out of process: JobRunner ends with `exit`. $pwshPath = (Get-Process -Id $PID).Path & $pwshPath -NoProfile -Command "`$env:OPS_ROOT='$script:TestOpsRoot'; & '$script:Runner' -PayloadBase64 '$Base64'" *>$null return $LASTEXITCODE } } BeforeEach { Get-ChildItem (Join-Path $script:TestOpsRoot "Logs") -File -ErrorAction SilentlyContinue | Remove-Item -Force } It "splats a hashtable payload as named parameters" { # Regression: the old command-line encoding turned a hashtable into the # string "System.Collections.Hashtable", which splatting then enumerated # character by character into whatever parameter came first positionally. $jobScript = Join-Path $script:JobScripts "Params.ps1" Set-Content -Path $jobScript -Encoding UTF8 -Value @( 'param([switch]$DryRun, [switch]$DisableEvents, [ValidatePattern(''^(\d{4}-\d{2}-\d{2})?$'')][string]$MaxDate)' 'Write-OpsLog -Message "DryRun=$DryRun DisableEvents=$DisableEvents MaxDate=[$MaxDate]" -Level INFO' ) $exit = Invoke-Runner (New-Payload "ParamJob" $jobScript ([ordered]@{ DryRun = $true; DisableEvents = $true })) $exit | Should -Be 0 $history = Join-Path $script:TestOpsRoot "Logs\ParamJob.history.json" $messages = @(Get-Content $history | ForEach-Object { ($_ | ConvertFrom-Json).Message }) $messages | Should -Contain "DryRun=True DisableEvents=True MaxDate=[]" } It "exits non-zero and records an ERROR when the job script throws" { $jobScript = Join-Path $script:JobScripts "Throws.ps1" 'throw "Simulierter Fehler"' | Set-Content -Path $jobScript -Encoding UTF8 $exit = Invoke-Runner (New-Payload "ThrowJob" $jobScript) $exit | Should -Be 1 $entries = @(Get-Content (Join-Path $script:TestOpsRoot "Logs\ThrowJob.history.json") | ForEach-Object { $_ | ConvertFrom-Json }) ($entries | Where-Object { $_.Level -eq "ERROR" }).Message | Should -BeLike "*Simulierter Fehler*" } It "fails the job when the script exits non-zero without throwing" { $jobScript = Join-Path $script:JobScripts "ExitsNonZero.ps1" 'exit 3' | Set-Content -Path $jobScript -Encoding UTF8 Invoke-Runner (New-Payload "ExitJob" $jobScript) | Should -Be 1 } It "closes the transcript before sending alerts" { # Regression: alerts were sent from the catch block while Start-Transcript # still held $LogFile open, so Send-MailMessage -Attachments failed with # "the process cannot access the file". $jobScript = Join-Path $script:JobScripts "Throws2.ps1" 'throw "kaputt"' | Set-Content -Path $jobScript -Encoding UTF8 Invoke-Runner (New-Payload "AlertJob" $jobScript -EmailRecipients @("ops@example.invalid")) | Should -Be 1 $transcript = Get-ChildItem (Join-Path $script:TestOpsRoot "Logs") -Filter "AlertJob-*.log" | Select-Object -First 1 $transcript | Should -Not -BeNullOrEmpty # The transcript is closed and complete... (Get-Content $transcript.FullName -Raw) | Should -BeLike "*transcript end*" # ...and the file is no longer locked. { [System.IO.File]::Open($transcript.FullName, 'Open', 'Read', 'None').Dispose() } | Should -Not -Throw # The alert attempt happened after that, so it is in the history only. $messages = @(Get-Content (Join-Path $script:TestOpsRoot "Logs\AlertJob.history.json") | ForEach-Object { ($_ | ConvertFrom-Json).Message }) ($messages -join "`n") | Should -BeLike "*no SMTP server/sender set*" } It "skips a second run while the first holds the job lock" -Skip:(-not $IsWindows) { # Global mutexes are a Windows construct. $jobScript = Join-Path $script:JobScripts "Slow.ps1" 'Start-Sleep -Seconds 5' | Set-Content -Path $jobScript -Encoding UTF8 $payload = New-Payload "LockJob" $jobScript $pwshPath = (Get-Process -Id $PID).Path $first = Start-Process -FilePath $pwshPath -PassThru -WindowStyle Hidden -ArgumentList @( "-NoProfile", "-Command", "`$env:OPS_ROOT='$script:TestOpsRoot'; & '$script:Runner' -PayloadBase64 '$payload'") Start-Sleep -Seconds 1 $secondExit = Invoke-Runner $payload $first.WaitForExit() $secondExit | Should -Be 0 $messages = @(Get-Content (Join-Path $script:TestOpsRoot "Logs\LockJob.history.json") | ForEach-Object { ($_ | ConvertFrom-Json).Message }) ($messages -join "`n") | Should -BeLike "*already running*" } It "rejects a payload that is not valid Base64 JSON" { $pwshPath = (Get-Process -Id $PID).Path & $pwshPath -NoProfile -Command "`$env:OPS_ROOT='$script:TestOpsRoot'; & '$script:Runner' -PayloadBase64 'not-base64!!'" *>$null $LASTEXITCODE | Should -Not -Be 0 } } Describe "Initialize-OrchestratorEnvironment" -Skip:(-not $IsWindows) { It "creates the Ops directory structure" { InModuleScope WinBatchOrchestrator { Mock New-EventLog { } Mock Get-Acl { New-Object System.Security.AccessControl.DirectorySecurity } Mock Set-Acl { } Mock Write-Host { } Initialize-OrchestratorEnvironment foreach ($dir in @("Bin", "Scripts", "Logs", "Secrets")) { Test-Path (Join-Path $script:OpsRoot $dir) | Should -BeTrue } } } } |