tests/ModuleUpdate.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for the module auto-UPDATE check (AB#369).

.DESCRIPTION
    Tests Test-AZSCModuleUpdate (Modules\Private\Main\Test-AZSCModuleUpdate.ps1), the
    function AzureScout.psm1 calls once on import to surface a newer PSGallery release.
    Find-Module, Get-Module, and Update-Module are mocked throughout so these tests never
    touch the network or the real PSGallery. The marker file used for the 24h throttle is
    a real file (matching this repo's existing Private/Main test convention of exercising
    real temp-file I/O -- see Private.Main.Tests.ps1), isolated to its own path and cleaned
    up before/after every test so runs never interfere with each other or with a real
    session's throttle state.

.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Work item: AB#369
#>


BeforeAll {
    $script:ModuleRoot   = Split-Path -Parent $PSScriptRoot
    $script:MainPath     = Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main'
    $script:FunctionFile = Join-Path $script:MainPath 'Test-AZSCModuleUpdate.ps1'
    $script:ManifestPath = Join-Path $script:ModuleRoot 'AzureScout.psd1'
    $script:ThrottleFile = Join-Path ([System.IO.Path]::GetTempPath()) 'azurescout-update-check.txt'

    # Find-Module/Update-Module are function exports of PowerShellGet, loaded on demand.
    # Import explicitly so Pester's Mock can find a command to shim -- without this,
    # command discovery inside the Pester runner scope can fail before the mock is set up.
    Import-Module -Name PowerShellGet -ErrorAction SilentlyContinue

    . $script:FunctionFile
}

Describe 'Test-AZSCModuleUpdate' {

    BeforeEach {
        # Isolate every test from the real machine throttle marker and from any CI /
        # opt-out / opt-in env vars that might be set in the outer shell.
        Remove-Item -Path $script:ThrottleFile -Force -ErrorAction SilentlyContinue
        foreach ($v in @('CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'AZURESCOUT_SKIP_UPDATE_CHECK', 'AZURESCOUT_AUTO_UPDATE')) {
            Remove-Item -Path "Env:$v" -ErrorAction SilentlyContinue
        }
    }

    AfterAll {
        Remove-Item -Path $script:ThrottleFile -Force -ErrorAction SilentlyContinue
        foreach ($v in @('CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'AZURESCOUT_SKIP_UPDATE_CHECK', 'AZURESCOUT_AUTO_UPDATE')) {
            Remove-Item -Path "Env:$v" -ErrorAction SilentlyContinue
        }
    }

    Context 'newer version available on PSGallery' {

        BeforeEach {
            Mock -CommandName Get-Module -MockWith { $null }
            Mock -CommandName Find-Module -MockWith {
                [pscustomobject]@{ Name = 'AzureScout'; Version = [Version]'99.0.0' }
            }
            Mock -CommandName Update-Module -MockWith { }
        }

        It 'notifies (Write-Warning) with the newer version and does not update without opt-in' {
            $warnings = @()
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningVariable warnings -WarningAction SilentlyContinue
            ($warnings -join ' ') | Should -Match '99\.0\.0'
            ($warnings -join ' ') | Should -Match 'Update-Module AzureScout'
            Should -Invoke -CommandName Update-Module -Times 0
        }

        It 'updates via Update-Module when AZURESCOUT_AUTO_UPDATE is set (explicit opt-in)' {
            $env:AZURESCOUT_AUTO_UPDATE = '1'
            { Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue } | Should -Not -Throw
            Should -Invoke -CommandName Update-Module -Times 1 -ParameterFilter { $Name -eq 'AzureScout' }
        }

        It 'prefers an already-imported module instance over the manifest when Get-Module finds one' {
            Mock -CommandName Get-Module -MockWith {
                [pscustomobject]@{ Name = 'AzureScout'; Version = [Version]'0.0.1' }
            }
            $warnings = @()
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningVariable warnings -WarningAction SilentlyContinue
            ($warnings -join ' ') | Should -Match '0\.0\.1'
        }
    }

    Context 'offline / PSGallery unreachable' {

        BeforeEach {
            Mock -CommandName Get-Module -MockWith { $null }
            Mock -CommandName Find-Module -MockWith { throw 'Unable to resolve package source (no network).' }
        }

        It 'does not throw and does not block the caller' {
            { Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath } | Should -Not -Throw
        }

        It 'degrades to Write-Verbose only (no warning surfaced)' {
            $warnings = @()
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningVariable warnings -WarningAction SilentlyContinue
            $warnings | Should -BeNullOrEmpty
        }
    }

    Context 'already at the latest version' {

        BeforeEach {
            Mock -CommandName Get-Module -MockWith { $null }
            Mock -CommandName Find-Module -MockWith {
                [pscustomobject]@{ Name = 'AzureScout'; Version = [Version]'2.1.0' }
            }
            Mock -CommandName Update-Module -MockWith { }
        }

        It 'is a no-op: no warning, no update, when local and gallery versions match' {
            $warnings = @()
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningVariable warnings -WarningAction SilentlyContinue
            $warnings | Should -BeNullOrEmpty
            Should -Invoke -CommandName Update-Module -Times 0
        }
    }

    Context 'CI / automation guard' {

        BeforeEach {
            Mock -CommandName Get-Module -MockWith { $null }
            Mock -CommandName Find-Module -MockWith {
                [pscustomobject]@{ Name = 'AzureScout'; Version = [Version]'99.0.0' }
            }
        }

        It 'skips the check entirely under GITHUB_ACTIONS (never calls Find-Module)' {
            $env:GITHUB_ACTIONS = 'true'
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Should -Invoke -CommandName Find-Module -Times 0
        }

        It 'skips the check entirely under TF_BUILD (Azure DevOps) (never calls Find-Module)' {
            $env:TF_BUILD = 'True'
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Should -Invoke -CommandName Find-Module -Times 0
        }

        It 'skips the check entirely when AZURESCOUT_SKIP_UPDATE_CHECK is set (explicit opt-out)' {
            $env:AZURESCOUT_SKIP_UPDATE_CHECK = '1'
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Should -Invoke -CommandName Find-Module -Times 0
        }
    }

    Context 'throttle (once per 24 hours)' {

        BeforeEach {
            Mock -CommandName Get-Module -MockWith { $null }
            Mock -CommandName Find-Module -MockWith {
                [pscustomobject]@{ Name = 'AzureScout'; Version = [Version]'2.1.0' }
            }
        }

        It 'only calls Find-Module once across two calls made back-to-back' {
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Should -Invoke -CommandName Find-Module -Times 1
        }

        It 'checks again once the marker is older than 24 hours' {
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Set-Content -Path $script:ThrottleFile -Value (Get-Date).AddHours(-25).ToString('o')
            Test-AZSCModuleUpdate -ManifestPath $script:ManifestPath -WarningAction SilentlyContinue
            Should -Invoke -CommandName Find-Module -Times 2
        }
    }
}