tests/Unit/Public/Invoke-sqmPreferredPrimaryCheck.Tests.ps1

#Requires -Modules Pester
<#
.SYNOPSIS
    Unit Tests fuer Invoke-sqmPreferredPrimaryCheck.
    dbatools und Invoke-sqmFailover werden vollstaendig gemockt - getestet wird die
    Entscheidungslogik (schwenken / nicht schwenken), nicht der Failover selbst.
#>


BeforeAll {
    . "$PSScriptRoot\..\..\..\tests\TestHelpers.ps1"
    Import-sqmTestModule

    # Liefert je nach abgesetzter Abfrage die passende Zeilenform zurueck. Die Zuordnung laeuft
    # ueber je einen Spaltenalias, der nur in genau einer der Abfragen vorkommt.
    function New-AgMockQueryResult
    {
        param(
            [string]$Query,
            [string]$PrimaryReplica = 'SQL02',
            [string]$TargetRole = 'SECONDARY',
            [string]$AvailMode = 'SYNCHRONOUS_COMMIT',
            [string]$SyncHealth = 'HEALTHY',
            [int]$TargetUptimeMinutes = 600,
            [int]$RoleAgeMinutes = 600,
            [string]$DbSyncState = 'SYNCHRONIZED',
            [int]$JoinedDbCount = 2,
            [int]$AgDbCount = 2,
            [int]$RedoQueueKB = 0,
            [switch]$SuspendedDbNull
        )

        switch -Regex ($Query)
        {
            'AS ReplicaServer' {
                return @(
                    [PSCustomObject]@{ ReplicaServer = 'SQL01'; AvailabilityMode = 'SYNCHRONOUS_COMMIT'; FailoverMode = 'MANUAL' }
                    [PSCustomObject]@{ ReplicaServer = 'SQL02'; AvailabilityMode = 'SYNCHRONOUS_COMMIT'; FailoverMode = 'MANUAL' }
                )
            }
            'AS PrimaryReplica' {
                return @([PSCustomObject]@{
                        PrimaryReplica        = $PrimaryReplica
                        AgSyncHealth          = 'HEALTHY'
                        PrimaryRecoveryHealth = 'ONLINE'
                    })
            }
            'AS SqlStartTime' {
                return @([PSCustomObject]@{
                        Role         = $TargetRole
                        OperState    = 'ONLINE'
                        ConnState    = 'CONNECTED'
                        SyncHealth   = $SyncHealth
                        AvailMode    = $AvailMode
                        FailoverMode = 'MANUAL'
                        SqlStartTime = (Get-Date).AddMinutes(-$TargetUptimeMinutes)
                    })
            }
            'AS AgDbCount' {
                $rows = @()
                for ($i = 1; $i -le $JoinedDbCount; $i++)
                {
                    $rows += [PSCustomObject]@{
                        DatabaseName = "DB$i"
                        SyncState    = $DbSyncState
                        DbState      = 'ONLINE'
                        IsSuspended  = if ($SuspendedDbNull) { [DBNull]::Value } else { $false }
                        RedoQueueKB  = $RedoQueueKB
                        AgDbCount    = $AgDbCount
                    }
                }
                return $rows
            }
            'AS RoleStartUtc' {
                return @([PSCustomObject]@{ RoleStartUtc = (Get-Date).ToUniversalTime().AddMinutes(-$RoleAgeMinutes) })
            }
        }

        return @()
    }

    function New-FailoverSuccessResult
    {
        [PSCustomObject]@{
            AvailabilityGroup   = 'AG_Test'
            OldPrimary          = 'SQL02'
            NewPrimary          = 'SQL01'
            Status              = 'Success'
            PreCheckPassed      = $true
            PostCheckPassed     = $true
            FailoverDurationSec = 4.2
            Message             = 'ok'
        }
    }
}

AfterAll {
    if (Get-Module sqmSQLTool) { Remove-Module sqmSQLTool -Force }
    $env:MSSQLTOOLS_SKIP_AUTO_UPDATE = $null
}

Describe 'Invoke-sqmPreferredPrimaryCheck' {

    Context 'Parameter-Validierung' {
        It 'Funktion existiert' {
            Get-Command Invoke-sqmPreferredPrimaryCheck | Should -Not -BeNullOrEmpty
        }

        It '<_> Parameter existiert' -ForEach @(
            'AvailabilityGroup', 'PreferredReplica', 'MaxRedoQueueMB', 'MinRoleAgeMinutes',
            'MinTargetUptimeMinutes', 'AllowedDay', 'AllowedTimeStart', 'AllowedTimeEnd',
            'CheckOnly', 'FailOnBlocked'
        ) {
            (Get-Command Invoke-sqmPreferredPrimaryCheck).Parameters.ContainsKey($_) | Should -Be $true
        }

        It 'Unterstuetzt WhatIf' {
            (Get-Command Invoke-sqmPreferredPrimaryCheck).Parameters.ContainsKey('WhatIf') | Should -Be $true
        }

        It 'AllowedTimeStart ohne AllowedTimeEnd wird abgelehnt' {
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                    -PreferredReplica 'SQL01' -AllowedTimeStart '06:00' -Confirm:$false
            } | Should -Throw
        }

        It 'Ungueltige Zeitangabe wird abgelehnt' {
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                    -PreferredReplica 'SQL01' -AllowedTimeStart '25:00' -AllowedTimeEnd '06:00' -Confirm:$false
            } | Should -Throw
        }
    }

    Context 'Bevorzugtes Replikat ist bereits Primary' {
        BeforeAll {
            Mock -ModuleName sqmSQLTool Invoke-sqmLogging { }
            Mock -ModuleName sqmSQLTool Invoke-sqmFailover { New-FailoverSuccessResult }
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -PrimaryReplica 'SQL01' }
            InModuleScope sqmSQLTool { $script:dbatoolsAvailable = $true }
        }

        It 'Meldet AlreadyPrimary und schwenkt nicht' {
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL01' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'AlreadyPrimary'
            $r.Action | Should -Be 'None'
            $r.CurrentPrimary | Should -Be 'SQL01'
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Fragt die Zustaende der anderen Replikate gar nicht erst ab' {
            Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL01' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false | Out-Null
            Should -Invoke -ModuleName sqmSQLTool Invoke-DbaQuery -Exactly 2
        }
    }

    Context 'Falscher Primary, alle Bedingungen erfuellt' {
        BeforeAll {
            Mock -ModuleName sqmSQLTool Invoke-sqmLogging { }
            Mock -ModuleName sqmSQLTool Invoke-sqmFailover { New-FailoverSuccessResult }
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query }
            InModuleScope sqmSQLTool { $script:dbatoolsAvailable = $true }
        }

        It 'Schwenkt auf das bevorzugte Replikat zurueck' {
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'FailedOver'
            $r.Action | Should -Be 'Failover'
            $r.NewPrimary | Should -Be 'SQL01'
        }

        It 'Ruft Invoke-sqmFailover mit dem aktuellen Primary und dem bevorzugten Ziel auf' {
            Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false | Out-Null
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 1 -ParameterFilter {
                $SqlInstance -eq 'SQL02' -and $TargetReplica -eq 'SQL01' -and $AvailabilityGroup -eq 'AG_Test'
            }
        }

        It 'Loest mit -WhatIf keinen Failover aus' {
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -WhatIf
            $r.Status | Should -Be 'FailoverRequired'
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Loest mit -CheckOnly keinen Failover aus, meldet aber den Bedarf' {
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -CheckOnly -Confirm:$false
            $r.Status | Should -Be 'FailoverRequired'
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Erkennt das bevorzugte Replikat auch in FQDN-Schreibweise' {
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'sql01.contoso.local' -Confirm:$false
            $r.PreferredReplica | Should -Be 'SQL01'
            $r.Status | Should -Be 'FailedOver'
        }

        It 'Schwenkt ausserhalb der erlaubten Wochentage nicht' {
            $otherDay = (Get-Date).AddDays(2).DayOfWeek.ToString()
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                -PreferredReplica 'SQL01' -AllowedDay $otherDay -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'AllowedDay').Passed | Should -Be $false
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Schwenkt innerhalb des erlaubten Zeitfensters' {
            $start = (Get-Date).AddMinutes(-30).ToString('HH:mm')
            $end = (Get-Date).AddMinutes(30).ToString('HH:mm')
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                -PreferredReplica 'SQL01' -AllowedTimeStart $start -AllowedTimeEnd $end -Confirm:$false
            ($r.Checks | Where-Object Name -eq 'AllowedTimeWindow').Passed | Should -Be $true
            $r.Status | Should -Be 'FailedOver'
        }
    }

    Context 'Falscher Primary, aber Bedingungen verletzt' {
        BeforeAll {
            Mock -ModuleName sqmSQLTool Invoke-sqmLogging { }
            Mock -ModuleName sqmSQLTool Invoke-sqmFailover { New-FailoverSuccessResult }
            InModuleScope sqmSQLTool { $script:dbatoolsAvailable = $true }
        }

        It 'Nicht synchronisierte Datenbank blockiert den Schwenk' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -DbSyncState 'SYNCHRONIZING' }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'AllDatabasesSynchronized').Passed | Should -Be $false
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Nicht gejointe Datenbank blockiert den Schwenk' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -JoinedDbCount 1 -AgDbCount 2 }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'AllDatabasesJoined').Passed | Should -Be $false
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Asynchrones Zielreplikat blockiert den Schwenk' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -AvailMode 'ASYNCHRONOUS_COMMIT' }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'TargetSynchronousCommit').Passed | Should -Be $false
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Zu kurze Laufzeit des Zielknotens blockiert den Schwenk' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -TargetUptimeMinutes 2 }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'TargetUptime').Passed | Should -Be $false
        }

        It 'Zu junger Rollenwechsel blockiert den Schwenk (Flatterschutz)' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -RoleAgeMinutes 3 }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'PrimaryRoleAge').Passed | Should -Be $false
        }

        It 'MinRoleAgeMinutes 0 schaltet den Flatterschutz ab' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -RoleAgeMinutes 3 }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                -PreferredReplica 'SQL01' -MinRoleAgeMinutes 0 -Confirm:$false
            $r.Status | Should -Be 'FailedOver'
        }

        It 'NULL in is_suspended wird nicht als suspendiert gewertet' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -SuspendedDbNull }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            ($r.Checks | Where-Object Name -eq 'AllDatabasesSynchronized').Passed | Should -Be $true
            $r.Status | Should -Be 'FailedOver'
        }

        It 'Zu grosse Redo-Queue blockiert den Schwenk' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -RedoQueueKB 204800 }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                -PreferredReplica 'SQL01' -MaxRedoQueueMB 50 -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            ($r.Checks | Where-Object Name -eq 'RedoQueue').Passed | Should -Be $false
        }

        It '-FailOnBlocked meldet den blockierten Schwenk als Fehler' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -DbSyncState 'SYNCHRONIZING' }
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                    -PreferredReplica 'SQL01' -FailOnBlocked -EnableException -Confirm:$false
            } | Should -Throw
        }

        It 'Ohne -FailOnBlocked bleibt ein blockierter Schwenk fehlerfrei' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -DbSyncState 'SYNCHRONIZING' }
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                    -PreferredReplica 'SQL01' -EnableException -Confirm:$false
            } | Should -Not -Throw
        }
    }

    Context 'Konfigurations- und Zustandsfehler' {
        BeforeAll {
            Mock -ModuleName sqmSQLTool Invoke-sqmLogging { }
            Mock -ModuleName sqmSQLTool Invoke-sqmFailover { New-FailoverSuccessResult }
            InModuleScope sqmSQLTool { $script:dbatoolsAvailable = $true }
        }

        It 'Unbekanntes bevorzugtes Replikat ist ein harter Fehler (Tippfehler wuerde sonst nie auffallen)' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query }
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                    -PreferredReplica 'SQL99' -EnableException -Confirm:$false
            } | Should -Throw
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Unbekannte Availability Group ist ein harter Fehler' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { @() }
            {
                Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Falsch' `
                    -PreferredReplica 'SQL01' -EnableException -Confirm:$false
            } | Should -Throw
        }

        It 'Kein ermittelbarer Primary (RESOLVING) blockiert statt zu schwenken' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query -PrimaryReplica '' }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' -PreferredReplica 'SQL01' -Confirm:$false
            $r.Status | Should -Be 'Blocked'
            Should -Invoke -ModuleName sqmSQLTool Invoke-sqmFailover -Exactly 0
        }

        It 'Fehlgeschlagener Failover wird als FailoverFailed gemeldet' {
            Mock -ModuleName sqmSQLTool Invoke-DbaQuery { New-AgMockQueryResult -Query $Query }
            Mock -ModuleName sqmSQLTool Invoke-sqmFailover {
                [PSCustomObject]@{ Status = 'Failed'; NewPrimary = $null; Message = 'Redo-Queue zu gross'; FailoverDurationSec = 0 }
            }
            $r = Invoke-sqmPreferredPrimaryCheck -SqlInstance 'SQL02' -AvailabilityGroup 'AG_Test' `
                -PreferredReplica 'SQL01' -Confirm:$false -ErrorAction SilentlyContinue
            $r.Status | Should -Be 'FailoverFailed'
        }
    }
}