Tests/ntp/Test-NTPSync.Tests.ps1

#Requires -Version 5.1
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
.SYNOPSIS
    Pester v5 tests for the Test-NTPSync function
 
.DESCRIPTION
    Comprehensive test coverage for Test-NTPSync, including:
    - Happy path local and remote scenarios
    - Pipeline input with multiple machines
    - Offset exceeding threshold (IsSynced = false)
    - Unsynced sources (Free-Running, Local CMOS Clock)
    - Per-machine failure isolation and error handling
    - Parameter validation (MaxOffsetMs bounds, empty ComputerName)
    - Custom MaxOffsetMs threshold behaviour
 
.EXAMPLE
    Invoke-Pester -Path .\Test-NTPSync.Tests.ps1 -Output Detailed
 
    Runs all tests with detailed output.
 
.EXAMPLE
    Invoke-Pester -Path .\Test-NTPSync.Tests.ps1 -Output Detailed -Tag 'Nominal'
 
    Runs only tests tagged as nominal scenarios.
 
.NOTES
    Author: Ecritel IT Team
    Version: 1.0.0
    Last Modified: 2026-03-12
    Requires: Pester 5.x, PowerShell 5.1+
    Permissions: None required (all external calls are mocked)
#>


BeforeAll {
    # Dot-source the function under test
    $script:functionPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Public\ntp\Test-NTPSync.ps1'
    . $script:functionPath

    #region Mock data -- English locale, synced with small offset
    $script:mockOutputSynced = @(
        'Leap Indicator: 0(no warning)'
        'Stratum: 3 (secondary reference - syncd by (S)NTP)'
        'Precision: -23 (119.209ns per tick)'
        'Root Delay: 0.0312500s'
        'Root Dispersion: 0.0512345s'
        'ReferenceId: 0xC0A80101 (source IP: 192.168.1.1)'
        'Last Successful Sync Time: 3/12/2026 8:00:00 PM'
        'Source: ntp.example.com'
        'Poll Interval: 10 (1024s)'
        'Phase Offset: 0.0023456s'
    )
    #endregion

    #region Mock data -- high offset (2500ms, exceeds default 1000ms)
    $script:mockOutputHighOffset = @(
        'Leap Indicator: 0(no warning)'
        'Stratum: 3 (secondary reference - syncd by (S)NTP)'
        'Precision: -23 (119.209ns per tick)'
        'Root Delay: 0.0312500s'
        'Root Dispersion: 0.0512345s'
        'ReferenceId: 0xC0A80101 (source IP: 192.168.1.1)'
        'Last Successful Sync Time: 3/12/2026 8:00:00 PM'
        'Source: ntp.example.com'
        'Poll Interval: 10 (1024s)'
        'Phase Offset: 2.5000000s'
    )
    #endregion

    #region Mock data -- Free-Running System Clock source
    $script:mockOutputFreeRunning = @(
        'Leap Indicator: 0(no warning)'
        'Stratum: 0 (unspecified)'
        'Precision: -23 (119.209ns per tick)'
        'Root Delay: 0.0000000s'
        'Root Dispersion: 0.0000000s'
        'ReferenceId: 0x00000000 (unspecified)'
        'Last Successful Sync Time: 1/1/1601 12:00:00 AM'
        'Source: Free-Running System Clock'
        'Poll Interval: 10 (1024s)'
        'Phase Offset: 0.0000000s'
    )
    #endregion

    #region Mock data -- Local CMOS Clock source
    $script:mockOutputLocalCmos = @(
        'Leap Indicator: 0(no warning)'
        'Stratum: 1 (primary reference - syncd by radio clock)'
        'Precision: -23 (119.209ns per tick)'
        'Root Delay: 0.0000000s'
        'Root Dispersion: 0.0100000s'
        'ReferenceId: 0x4C4F434C (source name: LOCL)'
        'Last Successful Sync Time: 3/12/2026 7:00:00 PM'
        'Source: Local CMOS Clock'
        'Poll Interval: 6 (64s)'
        'Phase Offset: 0.0001000s'
    )
    #endregion
}

Describe -Name 'Test-NTPSync' -Fixture {

    # -------------------------------------------------------------------
    # Context 1: Happy path -- local machine, synced
    # -------------------------------------------------------------------
    Context -Name 'Happy path - local machine, synced' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputSynced
            }
        }

        It -Name 'Should return a PSCustomObject with IsSynced true' -Test {
            $result = Test-NTPSync
            $result | Should -BeOfType ([PSCustomObject])
            $result.IsSynced | Should -BeTrue
        }

        It -Name 'Should expose all expected properties' -Test {
            $result = Test-NTPSync
            $script:expectedProperties = @(
                'ComputerName', 'IsSynced', 'Source', 'Stratum',
                'OffsetMs', 'MaxOffsetMs', 'LastSyncTime',
                'LeapIndicator', 'PollInterval', 'Timestamp'
            )
            foreach ($prop in $script:expectedProperties) {
                $result.PSObject.Properties.Name | Should -Contain $prop
            }
        }

        It -Name 'Should parse Source correctly' -Test {
            $result = Test-NTPSync
            $result.Source | Should -Be 'ntp.example.com'
        }

        It -Name 'Should parse Stratum as integer equal to 3' -Test {
            $result = Test-NTPSync
            $result.Stratum | Should -BeOfType ([int])
            $result.Stratum | Should -Be 3
        }

        It -Name 'Should parse OffsetMs from Phase Offset line' -Test {
            $result = Test-NTPSync
            $result.OffsetMs | Should -BeGreaterThan 0
            $result.OffsetMs | Should -BeLessOrEqual 1000
        }

        It -Name 'Should parse LastSyncTime as datetime' -Test {
            $result = Test-NTPSync
            $result.LastSyncTime | Should -BeOfType ([datetime])
        }

        It -Name 'Should parse PollInterval as 10' -Test {
            $result = Test-NTPSync
            $result.PollInterval | Should -Be 10
        }

        It -Name 'Should default MaxOffsetMs to 1000' -Test {
            $result = Test-NTPSync
            $result.MaxOffsetMs | Should -Be 1000
        }

        It -Name 'Should have a valid ISO 8601 Timestamp' -Test {
            $result = Test-NTPSync
            { [datetime]::Parse($result.Timestamp) } | Should -Not -Throw
        }

        It -Name 'Should parse LeapIndicator' -Test {
            $result = Test-NTPSync
            $result.LeapIndicator | Should -Not -Be 'Unknown'
        }
    }

    # -------------------------------------------------------------------
    # Context 2: Happy path -- explicit remote machine name
    # -------------------------------------------------------------------
    Context -Name 'Happy path - explicit remote machine name' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputSynced
            }
        }

        It -Name 'Should return IsSynced true for remote machine' -Test {
            $result = Test-NTPSync -ComputerName 'REMOTE-DC01'
            $result.IsSynced | Should -BeTrue
            $result.ComputerName | Should -Be 'REMOTE-DC01'
        }

        It -Name 'Should call Invoke-Command with -ComputerName for remote target' -Test {
            Test-NTPSync -ComputerName 'REMOTE-DC01'
            Should -Invoke -CommandName 'Invoke-Command' -Times 1 -Exactly -ParameterFilter {
                $ComputerName -eq 'REMOTE-DC01'
            }
        }
    }

    # -------------------------------------------------------------------
    # Context 3: Pipeline input -- multiple machine names
    # -------------------------------------------------------------------
    Context -Name 'Pipeline input - multiple machine names' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputSynced
            }
        }

        It -Name 'Should return one result per piped machine' -Test {
            $result = @('Server1', 'Server2', 'Server3') | Test-NTPSync
            $result | Should -HaveCount 3
        }

        It -Name 'Should preserve ComputerName for each result' -Test {
            $result = @('Server1', 'Server2') | Test-NTPSync
            $result[0].ComputerName | Should -Be 'Server1'
            $result[1].ComputerName | Should -Be 'Server2'
        }
    }

    # -------------------------------------------------------------------
    # Context 4: Not synced -- offset exceeds MaxOffsetMs
    # -------------------------------------------------------------------
    Context -Name 'Not synced - offset exceeds MaxOffsetMs' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputHighOffset
            }
        }

        It -Name 'Should return IsSynced false when offset exceeds threshold' -Test {
            $result = Test-NTPSync -ComputerName 'REMOTE-DC01'
            $result.IsSynced | Should -BeFalse
        }

        It -Name 'Should report OffsetMs greater than default MaxOffsetMs' -Test {
            $result = Test-NTPSync -ComputerName 'REMOTE-DC01'
            $result.OffsetMs | Should -BeGreaterThan 1000
        }
    }

    # -------------------------------------------------------------------
    # Context 5: Not synced -- Free-Running System Clock source
    # -------------------------------------------------------------------
    Context -Name 'Not synced - Free-Running System Clock source' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputFreeRunning
            }
        }

        It -Name 'Should return IsSynced false for Free-Running source' -Test {
            $result = Test-NTPSync -ComputerName 'STANDALONE01'
            $result.IsSynced | Should -BeFalse
        }

        It -Name 'Should report the Free-Running source name' -Test {
            $result = Test-NTPSync -ComputerName 'STANDALONE01'
            $result.Source | Should -Be 'Free-Running System Clock'
        }
    }

    # -------------------------------------------------------------------
    # Context 5b: Not synced -- Local CMOS Clock source
    # -------------------------------------------------------------------
    Context -Name 'Not synced - Local CMOS Clock source' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputLocalCmos
            }
        }

        It -Name 'Should return IsSynced false for Local CMOS Clock' -Test {
            $result = Test-NTPSync -ComputerName 'ISOLATED01'
            $result.IsSynced | Should -BeFalse
        }

        It -Name 'Should report the Local CMOS Clock source name' -Test {
            $result = Test-NTPSync -ComputerName 'ISOLATED01'
            $result.Source | Should -Be 'Local CMOS Clock'
        }
    }

    # -------------------------------------------------------------------
    # Context 6: Per-machine failure isolation
    # -------------------------------------------------------------------
    Context -Name 'Per-machine failure isolation' -Fixture {

        BeforeEach {
            # Default mock returns good data
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputSynced
            }

            # Override for the failing machine
            Mock -CommandName 'Invoke-Command' -MockWith {
                throw 'WinRM connection refused'
            } -ParameterFilter { $ComputerName -eq 'BADSERVER' }
        }

        It -Name 'Should return results for healthy machines and write error for failing one' -Test {
            $result = Test-NTPSync -ComputerName 'GOODSERVER', 'BADSERVER', 'OTHERSERVER' -ErrorAction SilentlyContinue -ErrorVariable capturedError
            $result | Should -HaveCount 2
            $capturedError | Should -Not -BeNullOrEmpty
        }

        It -Name 'Should continue processing after a per-machine failure' -Test {
            $result = Test-NTPSync -ComputerName 'BADSERVER', 'GOODSERVER' -ErrorAction SilentlyContinue
            $result | Should -HaveCount 1
            $result[0].ComputerName | Should -Be 'GOODSERVER'
        }
    }

    # -------------------------------------------------------------------
    # Context 7: Parameter validation
    # -------------------------------------------------------------------
    Context -Name 'Parameter validation' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputSynced
            }
        }

        It -Name 'Should throw when MaxOffsetMs is zero' -Test {
            { Test-NTPSync -MaxOffsetMs 0 } | Should -Throw
        }

        It -Name 'Should throw when MaxOffsetMs is negative' -Test {
            { Test-NTPSync -MaxOffsetMs -1 } | Should -Throw
        }

        It -Name 'Should throw when ComputerName is empty string' -Test {
            { Test-NTPSync -ComputerName '' } | Should -Throw
        }
    }

    # -------------------------------------------------------------------
    # Context 8: Custom MaxOffsetMs threshold
    # -------------------------------------------------------------------
    Context -Name 'Custom MaxOffsetMs threshold' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -MockWith {
                return $script:mockOutputHighOffset
            }
        }

        It -Name 'Should return IsSynced true when custom threshold is large enough' -Test {
            $result = Test-NTPSync -ComputerName 'REMOTE-DC01' -MaxOffsetMs 5000
            $result.IsSynced | Should -BeTrue
            $result.MaxOffsetMs | Should -Be 5000
        }

        It -Name 'Should return IsSynced false when custom threshold is too small' -Test {
            $result = Test-NTPSync -ComputerName 'REMOTE-DC01' -MaxOffsetMs 100
            $result.IsSynced | Should -BeFalse
            $result.MaxOffsetMs | Should -Be 100
        }
    }
}