Test/Search-PatternInFile.Tests.ps1

using namespace System
using namespace System.IO
using namespace System.Security.Cryptography
using namespace System.Collections.Immutable

Set-StrictMode -Version Latest

BeforeAll {
    $moduleRoot = Split-Path -Path $PSScriptRoot -Parent
    Import-Module (Join-Path -Path $moduleRoot -ChildPath 'PSSecurity.psd1') -Force
}
AfterAll {
    Remove-Module PSSecurity -Force -ErrorAction SilentlyContinue
}

#### <h2 style="color: #DCA657;">Search-PatternInFile</h2>
####
Describe 'Search-PatternInFile' {
    # One file holds the needle and one does not, so a match and a non-match are
    # both present in every run.
    BeforeEach {
        $script:dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
        $script:out = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
        New-Item -ItemType Directory -Path $script:dir | Out-Null
        New-Item -ItemType Directory -Path $script:out | Out-Null
        Set-Content -LiteralPath (Join-Path $script:dir 'hit.txt') -Value 'the NEEDLE is here'
        Set-Content -LiteralPath (Join-Path $script:dir 'miss.txt') -Value 'nothing to see'
        # Two occurrences on one line and one on another, so a per-file count and a
        # per-line count cannot be mistaken for each other.
        Set-Content -LiteralPath (Join-Path $script:dir 'many.txt') -Value @(
            'NEEDLE and NEEDLE again'
            'nothing here'
            'one more NEEDLE'
        )
    }
    # Search-PatternInFile raises a real desktop toast when BurntToast is present.
    # Unmocked, a suite run fires one notification per case at whoever is sitting
    # at the machine. The mock is scoped to the module because the call happens
    # inside Search-PatternInFile, not at the test's own scope.
    BeforeAll {
        Mock -ModuleName PSSecurity Show-DesktopNotification { }
    }

    AfterEach {
        Remove-Item -LiteralPath $script:dir -Recurse -Force -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $script:out -Recurse -Force -ErrorAction SilentlyContinue
    }

    ####
    #### <b style="color: #D2A8FF;">Cases</b>
    ####
    #### - The file holds exactly the matching paths. The matches live on disk, so
    #### this reads the file rather than the return value.
    It 'Records the matching files and excludes non-matching files' {
        Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out | Out-Null
        $entries = @(Get-Content -LiteralPath (Join-Path $script:out 'SearchPatternInFile.json') -Raw | ConvertFrom-Json)
        $entries.Count | Should -Be 2
        @($entries.Path) | Should -Not -Contain (Join-Path $script:dir 'miss.txt')
    }

    #### - Repeats on one line are counted, not collapsed. `many.txt` holds two on
    #### the first line and one on the third, so a line count and an occurrence
    #### count cannot be mistaken for each other.
    It 'Counts every occurrence and records one entry per matching line' {
        Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out | Out-Null
        $entries = @(Get-Content -LiteralPath (Join-Path $script:out 'SearchPatternInFile.json') -Raw | ConvertFrom-Json)
        $many = $entries | Where-Object { $_.Path -match 'many\.txt$' }
        $many.MatchCount | Should -Be 3
        @($many.Matches).Count | Should -Be 2
        @($many.Matches | Where-Object LineNumber -EQ 1).Values.Count | Should -Be 2
    }

    #### - Each entry carries what `Select-String` reports: the line it sits on, the
    #### whole line, and the values matched on it.
    It 'Carries the line number, the line, and the matched values' {
        Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out | Out-Null
        $entries = @(Get-Content -LiteralPath (Join-Path $script:out 'SearchPatternInFile.json') -Raw | ConvertFrom-Json)
        $hit = $entries | Where-Object { $_.Path -match 'hit\.txt$' }
        $match = @($hit.Matches)[0]
        $match.LineNumber | Should -Be 1
        $match.Line | Should -Be 'the NEEDLE is here'
        @($match.Values) | Should -Be @('NEEDLE')
    }

    #### - The result survives the session as a file under `OutPath`.
    It 'Writes SearchPatternInFile.json under OutPath' {
        Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out | Out-Null
        Test-Path -Path (Join-Path $script:out 'SearchPatternInFile.json') -PathType Leaf | Should -BeTrue
    }

    #### - The return value is a summary naming both counts and the file, not the
    #### matches. A long run can produce more matches than a terminal will hold.
    It 'Returns a summary naming both counts and the output file' {
        $summary = Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out
        $summary | Should -BeOfType [string]
        $summary | Should -Match '^2 files, 4 matches written to '
        $summary | Should -Match 'SearchPatternInFile\.json$'
    }

    #### - Emits exactly one object. Anything extra on the success stream would be
    #### the caller's problem to filter.
    It 'Emits a single summary line' {
        @(Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out).Count | Should -Be 1
    }

    #### - The notification is raised, carrying the same summary the caller receives.
    #### Asserting the mock ran is also what proves the real toast did not.
    It 'Raises a desktop notification carrying the summary' {
        $summary = Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out
        Should -Invoke -ModuleName PSSecurity Show-DesktopNotification -Times 1 -Exactly -ParameterFilter {
            $Message -eq $summary
        }
    }

    #### - Regression: without a launch path the toast activates the app it is
    #### registered under, which is PowerShell, so clicking the result opened a
    #### console instead of the findings.
    It 'Points the notification at the output file' {
        Search-PatternInFile -Directory $script:dir -Pattern 'NEEDLE' -OutPath $script:out | Out-Null
        $expected = Join-Path $script:out 'SearchPatternInFile.json'
        Should -Invoke -ModuleName PSSecurity Show-DesktopNotification -Times 1 -Exactly -ParameterFilter {
            $LaunchPath -eq $expected
        }
    }

    #### - No match still writes an empty JSON array and reports zero.
    It 'Reports zero and writes an empty array when nothing matches' {
        $summary = Search-PatternInFile -Directory $script:dir -Pattern 'NOTPRESENTANYWHERE' -OutPath $script:out
        $summary | Should -Match '^0 files, 0 matches written to '
        @(Get-Content -LiteralPath (Join-Path $script:out 'SearchPatternInFile.json') -Raw | ConvertFrom-Json).Count | Should -Be 0
    }
}
####
#### ---
####