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