Public/Tests/New-FunctionTemplate.Tests.ps1

BeforeDiscovery {
    $HasScriptAnalyzer = [bool](Get-Module -ListAvailable -Name PSScriptAnalyzer)
}

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.utils.psd1') -Force
}

AfterAll {
    Remove-Module -Name tcs.utils -Force -ErrorAction SilentlyContinue
}

Describe 'New-FunctionTemplate' {
    BeforeAll {
        function Get-ParseErrorCount {
            param([string]$Path)
            $tokens = $null
            $errors = $null
            $null = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
            @($errors).Count
        }
        $SettingsPath = Join-Path -Path (Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent) -ChildPath 'PSScriptAnalyzerSettings.psd1'
    }

    BeforeEach {
        $Target = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N'))
        $null = New-Item -Path $Target -ItemType Directory
    }

    Context 'With telemetry (default)' {
        BeforeEach {
            New-FunctionTemplate -Name 'Get-Widget' -Path $Target
            $File = Join-Path -Path $Target -ChildPath 'Get-Widget.ps1'
            $Content = Get-Content -Path $File -Raw
        }

        It 'Creates a file that parses without errors' {
            Test-Path -Path $File | Should -BeTrue
            Get-ParseErrorCount -Path $File | Should -Be 0
        }

        It 'Uses the function name and the current telemetry pattern' {
            $Content | Should -Match 'function Get-Widget \{'
            $Content | Should -Not -Match '###TEMPLATE'
            $Content | Should -Match ([regex]::Escape('$TelemetryArgs = @{'))
            $Content | Should -Match ([regex]::Escape('Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer'))
            $Content | Should -Match ([regex]::Escape('Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_'))
            $Content | Should -Match ([regex]::Escape('Invoke-TelemetryCollection @TelemetryArgs -Stage End'))
        }

        It 'Does not contain obsolete telemetry code' {
            $Content | Should -Not -Match 'NOTYETDEFINED'
            $Content | Should -Not -Match 'TelmetryArgs'
            $Content | Should -Not -Match 'Minimal'
            $Content | Should -Not -Match 'Get-ModuleConfig'
            $Content | Should -Not -Match 'BasicTelemetry'
        }

        It 'Sends Start and End when the generated function succeeds' {
            . $File
            Mock Invoke-TelemetryCollection { }
            Get-Widget -Name 'a'
            Should -Invoke Invoke-TelemetryCollection -Times 1 -Exactly -ParameterFilter { $Stage -eq 'Start' }
            Should -Invoke Invoke-TelemetryCollection -Times 1 -Exactly -ParameterFilter { $Stage -eq 'End' -and -not $Failed }
        }

        It 'Sends End with -Failed and rethrows when the generated function fails' {
            $failing = $Content.Replace("Write-Verbose `"Processing '`$Name'.`"", "throw 'boom'")
            Set-Content -Path $File -Value $failing
            . $File
            Mock Invoke-TelemetryCollection { }
            { Get-Widget -Name 'a' } | Should -Throw 'boom'
            Should -Invoke Invoke-TelemetryCollection -Times 1 -Exactly -ParameterFilter { $Stage -eq 'End' -and $Failed -eq $true }
            Should -Invoke Invoke-TelemetryCollection -Times 0 -Exactly -ParameterFilter { $Stage -eq 'End' -and -not $Failed }
        }

        It 'Has no PSScriptAnalyzer findings with the repository settings' -Skip:(-not $HasScriptAnalyzer) {
            Invoke-ScriptAnalyzer -Path $File -Settings $SettingsPath | Should -BeNullOrEmpty
        }
    }

    Context 'Without telemetry' {
        It 'Creates a parseable function without telemetry calls' {
            New-FunctionTemplate -Name 'Set-Widget' -Path $Target -ExcludeTelemetryCollection
            $file = Join-Path -Path $Target -ChildPath 'Set-Widget.ps1'
            Get-ParseErrorCount -Path $file | Should -Be 0
            $content = Get-Content -Path $file -Raw
            $content | Should -Match 'function Set-Widget \{'
            $content | Should -Not -Match 'Invoke-TelemetryCollection'
            $content | Should -Not -Match '###TEMPLATE'
        }

        It 'Has no PSScriptAnalyzer findings with the repository settings' -Skip:(-not $HasScriptAnalyzer) {
            New-FunctionTemplate -Name 'Set-Widget' -Path $Target -ExcludeTelemetryCollection
            Invoke-ScriptAnalyzer -Path (Join-Path -Path $Target -ChildPath 'Set-Widget.ps1') -Settings $SettingsPath | Should -BeNullOrEmpty
        }
    }

    Context 'State-changing verbs' {
        It 'Adds SupportsShouldProcess and a ShouldProcess check for <Name>' -TestCases @(
            @{ Name = 'Set-Widget'; Telemetry = $false }
            @{ Name = 'Remove-Widget'; Telemetry = $true }
        ) {
            New-FunctionTemplate -Name $Name -Path $Target -ExcludeTelemetryCollection:(-not $Telemetry)
            $file = Join-Path -Path $Target -ChildPath "$Name.ps1"
            Get-ParseErrorCount -Path $file | Should -Be 0
            $content = Get-Content -Path $file -Raw
            $content | Should -Match ([regex]::Escape('[CmdletBinding(SupportsShouldProcess = $true)]'))
            $content | Should -Match ([regex]::Escape('$PSCmdlet.ShouldProcess($Name'))
            . $file
            Mock Invoke-TelemetryCollection { }
            { & $Name -Name 'a' -WhatIf } | Should -Not -Throw
        }

        It 'Does not add ShouldProcess for read-only verbs' {
            New-FunctionTemplate -Name 'Get-Widget' -Path $Target
            Get-Content -Path (Join-Path -Path $Target -ChildPath 'Get-Widget.ps1') -Raw | Should -Not -Match 'SupportsShouldProcess'
        }

        It 'Has no PSScriptAnalyzer findings for a Remove- function' -Skip:(-not $HasScriptAnalyzer) {
            New-FunctionTemplate -Name 'Remove-Widget' -Path $Target
            Invoke-ScriptAnalyzer -Path (Join-Path -Path $Target -ChildPath 'Remove-Widget.ps1') -Settings $SettingsPath | Should -BeNullOrEmpty
        }
    }

    Context 'Behaviour' {
        It 'Creates one file per name, including piped names' {
            'Get-A', 'Get-B' | New-FunctionTemplate -Path $Target
            (Get-ChildItem -Path $Target -Filter '*.ps1').Name | Sort-Object | Should -Be @('Get-A.ps1', 'Get-B.ps1')
        }

        It 'Writes nothing to the pipeline unless -PassThru is used' {
            New-FunctionTemplate -Name 'Get-Quiet' -Path $Target | Should -BeNullOrEmpty
            $result = New-FunctionTemplate -Name 'Get-Loud' -Path $Target -PassThru
            $result | Should -BeOfType [System.IO.FileInfo]
            $result.Name | Should -Be 'Get-Loud.ps1'
        }

        It 'Overwrites an existing file with -Force' {
            $file = Join-Path -Path $Target -ChildPath 'Get-Old.ps1'
            Set-Content -Path $file -Value 'old'
            New-FunctionTemplate -Name 'Get-Old' -Path $Target -Force
            Get-Content -Path $file -Raw | Should -Match 'function Get-Old'
        }

        It 'Creates nothing with -WhatIf' {
            New-FunctionTemplate -Name 'Get-Nothing' -Path $Target -WhatIf
            Test-Path -Path (Join-Path -Path $Target -ChildPath 'Get-Nothing.ps1') | Should -BeFalse
        }

        It 'Rejects a path that is not a folder' {
            { New-FunctionTemplate -Name 'Get-X' -Path (Join-Path -Path $Target -ChildPath 'missing') } | Should -Throw
        }
    }
}