Tests/GitHub-RepoWatch.Tests.ps1
|
#Requires -Module Pester <# Pester v5 tests for GitHub-RepoWatch module. Covers module loading, manifest validation, parameter validation, and mock-based functional tests for all major components. #> BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent Import-Module $modulePath -Force } Describe 'Module Loading' { It 'Imports the module without errors' { $module = Get-Module -Name 'GitHub-RepoWatch' $module | Should -Not -BeNullOrEmpty } It 'Exports exactly 5 public functions' { $module = Get-Module -Name 'GitHub-RepoWatch' $module.ExportedFunctions.Count | Should -Be 5 } It 'Exports Get-RepoActivity' { Get-Command -Module 'GitHub-RepoWatch' -Name 'Get-RepoActivity' | Should -Not -BeNullOrEmpty } It 'Exports Get-PSGalleryStats' { Get-Command -Module 'GitHub-RepoWatch' -Name 'Get-PSGalleryStats' | Should -Not -BeNullOrEmpty } It 'Exports Send-ActivityDigest' { Get-Command -Module 'GitHub-RepoWatch' -Name 'Send-ActivityDigest' | Should -Not -BeNullOrEmpty } It 'Exports Invoke-RepoWatch' { Get-Command -Module 'GitHub-RepoWatch' -Name 'Invoke-RepoWatch' | Should -Not -BeNullOrEmpty } It 'Exports Register-RepoWatchTask' { Get-Command -Module 'GitHub-RepoWatch' -Name 'Register-RepoWatchTask' | Should -Not -BeNullOrEmpty } It 'Does NOT export Get-GitHubAPI (private)' { $cmd = Get-Command -Module 'GitHub-RepoWatch' -Name 'Get-GitHubAPI' -ErrorAction SilentlyContinue $cmd | Should -BeNullOrEmpty } It 'Does NOT export Get-LastCheckTime (private)' { $cmd = Get-Command -Module 'GitHub-RepoWatch' -Name 'Get-LastCheckTime' -ErrorAction SilentlyContinue $cmd | Should -BeNullOrEmpty } It 'Does NOT export New-HtmlDigest (private)' { $cmd = Get-Command -Module 'GitHub-RepoWatch' -Name 'New-HtmlDigest' -ErrorAction SilentlyContinue $cmd | Should -BeNullOrEmpty } } Describe 'Module Manifest Validation' { BeforeAll { $manifestPath = Join-Path (Split-Path -Path $PSScriptRoot -Parent) 'GitHub-RepoWatch.psd1' $manifest = Test-ModuleManifest -Path $manifestPath } It 'Has the correct GUID' { $manifest.Guid.ToString() | Should -Be 'd0e1f2a3-7b68-4de4-c5f6-1b2c3d4e5f67' } It 'Requires PowerShell 5.1 or later' { $manifest.PowerShellVersion | Should -Be '5.1' } It 'Has the correct author' { $manifest.Author | Should -BeLike '*Larry Roberts*' } It 'Has a description' { $manifest.Description | Should -Not -BeNullOrEmpty } It 'Has the expected tags' { $tags = $manifest.PrivateData.PSData.Tags $tags | Should -Contain 'GitHub' $tags | Should -Contain 'Monitoring' $tags | Should -Contain 'PSGallery' $tags | Should -Contain 'Email' $tags | Should -Contain 'Digest' $tags | Should -Contain 'Automation' } It 'Has a ProjectUri' { $manifest.PrivateData.PSData.ProjectUri | Should -Be 'https://github.com/larro1991/GitHub-RepoWatch' } It 'Has a LicenseUri' { $manifest.PrivateData.PSData.LicenseUri | Should -Not -BeNullOrEmpty } It 'Declares 5 exported functions' { $manifest.ExportedFunctions.Count | Should -Be 5 } } Describe 'Parameter Validation' { Context 'Get-RepoActivity' { It 'Owner parameter is mandatory' { (Get-Command Get-RepoActivity).Parameters['Owner'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'SinceHours has ValidateRange(1, 720)' { $attrs = (Get-Command Get-RepoActivity).Parameters['SinceHours'].Attributes $rangeAttr = $attrs | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } $rangeAttr | Should -Not -BeNullOrEmpty $rangeAttr.MinRange | Should -Be 1 $rangeAttr.MaxRange | Should -Be 720 } It 'SinceHours is type int' { (Get-Command Get-RepoActivity).Parameters['SinceHours'].ParameterType.Name | Should -Be 'Int32' } } Context 'Send-ActivityDigest' { It 'SmtpServer parameter is mandatory' { (Get-Command Send-ActivityDigest).Parameters['SmtpServer'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'EmailTo parameter is mandatory' { (Get-Command Send-ActivityDigest).Parameters['EmailTo'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'EmailFrom parameter is mandatory' { (Get-Command Send-ActivityDigest).Parameters['EmailFrom'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'Activity parameter accepts pipeline input' { $pipelineAttr = (Get-Command Send-ActivityDigest).Parameters['Activity'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.ValueFromPipeline } $pipelineAttr | Should -Not -BeNullOrEmpty } } Context 'Register-RepoWatchTask' { It 'Owner parameter is mandatory' { (Get-Command Register-RepoWatchTask).Parameters['Owner'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'Schedule has ValidateSet (Daily, TwiceDaily, Hourly)' { $attrs = (Get-Command Register-RepoWatchTask).Parameters['Schedule'].Attributes $validateSet = $attrs | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } $validateSet | Should -Not -BeNullOrEmpty $validateSet.ValidValues | Should -Contain 'Daily' $validateSet.ValidValues | Should -Contain 'TwiceDaily' $validateSet.ValidValues | Should -Contain 'Hourly' } } Context 'Invoke-RepoWatch' { It 'Owner parameter is mandatory' { (Get-Command Invoke-RepoWatch).Parameters['Owner'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Contain $true } It 'SinceHours has ValidateRange(1, 720)' { $attrs = (Get-Command Invoke-RepoWatch).Parameters['SinceHours'].Attributes $rangeAttr = $attrs | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } $rangeAttr | Should -Not -BeNullOrEmpty $rangeAttr.MinRange | Should -Be 1 $rangeAttr.MaxRange | Should -Be 720 } } } Describe 'Get-GitHubAPI (Mock-Based)' { BeforeAll { # Re-import module internals so we can test private function $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\Get-GitHubAPI.ps1') # Store call details in a script-scope variable since splatted params # are not accessible via Pester ParameterFilter on Invoke-RestMethod $script:lastApiCall = $null } It 'Constructs the correct URL for an endpoint' { $script:capturedUri = $null function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body, [switch]$UseBasicParsing, $ErrorAction, $ResponseHeadersVariable) $script:capturedUri = $Uri return @{ id = 1; name = 'test-repo' } } $result = Get-GitHubAPI -Endpoint '/repos/testowner/testrepo' $script:capturedUri | Should -Be 'https://api.github.com/repos/testowner/testrepo' $result.name | Should -Be 'test-repo' Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue } It 'Includes authorization header when token is provided' { # Wrap Invoke-RestMethod to capture the headers hashtable $script:capturedHeaders = $null $originalIRM = Get-Command Invoke-RestMethod -CommandType Cmdlet function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body, [switch]$UseBasicParsing, $ErrorAction, $ResponseHeadersVariable) $script:capturedHeaders = $Headers return @{ id = 1 } } Get-GitHubAPI -Endpoint '/repos/test/test' -Token 'ghp_testtoken123' -ErrorAction SilentlyContinue $script:capturedHeaders['Authorization'] | Should -Be 'Bearer ghp_testtoken123' # Restore original Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue } It 'Sets required GitHub API headers' { $script:capturedHeaders = $null function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body, [switch]$UseBasicParsing, $ErrorAction, $ResponseHeadersVariable) $script:capturedHeaders = $Headers return @{ id = 1 } } Get-GitHubAPI -Endpoint '/repos/test/test' -ErrorAction SilentlyContinue $script:capturedHeaders['Accept'] | Should -Be 'application/vnd.github+json' $script:capturedHeaders['X-GitHub-Api-Version'] | Should -Be '2022-11-28' $script:capturedHeaders['User-Agent'] | Should -Be 'GitHub-RepoWatch-PowerShell' Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue } It 'Works without a token (no Authorization header)' { $originalToken = $env:GITHUB_TOKEN $env:GITHUB_TOKEN = $null try { $script:capturedHeaders = $null function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body, [switch]$UseBasicParsing, $ErrorAction, $ResponseHeadersVariable) $script:capturedHeaders = $Headers return @{ id = 1 } } Get-GitHubAPI -Endpoint '/repos/test/test' -ErrorAction SilentlyContinue $script:capturedHeaders.ContainsKey('Authorization') | Should -Be $false } finally { $env:GITHUB_TOKEN = $originalToken Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue } } } Describe 'Get-RepoActivity (Mock-Based)' { BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\Get-GitHubAPI.ps1') . (Join-Path $modulePath 'Private\Get-LastCheckTime.ps1') . (Join-Path $modulePath 'Public\Get-RepoActivity.ps1') } It 'Returns repos with correct HasActivity flags' { $sinceIso = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') # Mock state file Mock Get-LastCheckTime { return [PSCustomObject]@{ last_check = $sinceIso repos = [PSCustomObject]@{ 'active-repo' = [PSCustomObject]@{ stars = 10; forks = 2 } 'quiet-repo' = [PSCustomObject]@{ stars = 5; forks = 1 } } psgallery = [PSCustomObject]@{} } } # Mock API calls Mock Get-GitHubAPI { $ep = $Endpoint if ($ep -match '/users/.*/repos') { return @( @{ name = 'active-repo'; html_url = 'https://github.com/test/active-repo'; stargazers_count = 12; forks_count = 2; fork = $false }, @{ name = 'quiet-repo'; html_url = 'https://github.com/test/quiet-repo'; stargazers_count = 5; forks_count = 1; fork = $false } ) } elseif ($ep -match 'active-repo/issues\?state=open') { $newDate = (Get-Date).AddHours(-2).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') return @( @{ number = 1; title = 'Bug report'; user = @{ login = 'tester' }; created_at = $newDate; html_url = 'https://github.com/test/active-repo/issues/1'; pull_request = $null } ) } elseif ($ep -match 'quiet-repo/issues\?state=open') { return @() } elseif ($ep -match '/issues/comments') { return @() } elseif ($ep -match '/pulls') { return @() } else { return @() } } $results = Get-RepoActivity -Owner 'test' -SinceHours 24 $results | Should -Not -BeNullOrEmpty $results.Count | Should -Be 2 $active = $results | Where-Object { $_.RepoName -eq 'active-repo' } $quiet = $results | Where-Object { $_.RepoName -eq 'quiet-repo' } $active.HasActivity | Should -Be $true $active.StarsChange | Should -Be 2 $active.NewIssues.Count | Should -Be 1 $quiet.HasActivity | Should -Be $false } } Describe 'Get-PSGalleryStats (Mock-Based)' { BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\Get-LastCheckTime.ps1') . (Join-Path $modulePath 'Public\Get-PSGalleryStats.ps1') } It 'Calculates download deltas correctly against state file' { Mock Get-LastCheckTime { return [PSCustomObject]@{ last_check = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') repos = [PSCustomObject]@{} psgallery = [PSCustomObject]@{ 'TestModule' = [PSCustomObject]@{ downloads = 100 } } } } # Override Find-Module locally since dot-sourced functions use local scope function Find-Module { param($Name, $Filter, [switch]$ErrorAction) $mod = [PSCustomObject]@{ Name = 'TestModule' Version = [version]'1.2.3' Author = 'TestAuthor' Description = 'A test module' AdditionalMetadata = [PSCustomObject]@{ downloadCount = '147' } PublishedDate = (Get-Date).AddDays(-10) } return $mod } $results = Get-PSGalleryStats -ModuleNames 'TestModule' $results | Should -Not -BeNullOrEmpty @($results).Count | Should -Be 1 $results[0].ModuleName | Should -Be 'TestModule' $results[0].TotalDownloads | Should -Be 147 $results[0].DownloadChange | Should -Be 47 $results[0].HasNewDownloads | Should -Be $true } } Describe 'Get-LastCheckTime (State File Roundtrip)' { BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\Get-LastCheckTime.ps1') $tempState = Join-Path $TestDrive 'test-state.json' } It 'Returns default state when file does not exist' { $state = Get-LastCheckTime -StatePath $tempState -Owner 'test' $state.last_check | Should -BeNullOrEmpty } It 'Writes and reads state correctly (roundtrip)' { $writeData = @{ last_check = '2026-02-16T08:00:00Z' repos = @{ 'my-repo' = @{ stars = 42; forks = 7 } } psgallery = @{ 'MyModule' = @{ downloads = 256 } } } Get-LastCheckTime -StatePath $tempState -Owner 'test' -Write -Data $writeData Test-Path $tempState | Should -Be $true $readState = Get-LastCheckTime -StatePath $tempState -Owner 'test' $readState.last_check | Should -Be '2026-02-16T08:00:00Z' $readState.repos.'my-repo'.stars | Should -Be 42 $readState.repos.'my-repo'.forks | Should -Be 7 $readState.psgallery.'MyModule'.downloads | Should -Be 256 } It 'Creates the state directory if it does not exist' { $deepPath = Join-Path $TestDrive 'deep\nested\dir\state.json' Get-LastCheckTime -StatePath $deepPath -Owner 'test' -Write -Data @{ last_check = '2026-02-16T12:00:00Z' } Test-Path $deepPath | Should -Be $true } } Describe 'New-HtmlDigest' { BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\New-HtmlDigest.ps1') } It 'Generates valid HTML with DOCTYPE' { $html = New-HtmlDigest -Activity @() $html | Should -BeLike '<!DOCTYPE html>*' } It 'Includes repo names and links in output' { $activity = @( [PSCustomObject]@{ RepoName = 'TestRepo' RepoUrl = 'https://github.com/test/TestRepo' Stars = 10 StarsChange = 2 Forks = 3 ForksChange = 0 NewIssues = @( [PSCustomObject]@{ number = 1; title = 'Test Issue'; user = 'tester'; created_at = '2026-02-16T10:00:00Z'; url = 'https://github.com/test/TestRepo/issues/1' } ) NewComments = @() NewPRs = @() UpdatedIssues = @() HasActivity = $true } ) $html = New-HtmlDigest -Activity $activity $html | Should -BeLike '*TestRepo*' $html | Should -BeLike '*https://github.com/test/TestRepo*' $html | Should -BeLike '*Test Issue*' $html | Should -BeLike '*tester*' } It 'Includes PSGallery section when stats are provided' { $stats = @( [PSCustomObject]@{ ModuleName = 'SampleModule' Version = '1.0.0' TotalDownloads = 500 DownloadChange = 25 GalleryUrl = 'https://www.powershellgallery.com/packages/SampleModule' HasNewDownloads = $true } ) $html = New-HtmlDigest -PSGalleryStats $stats $html | Should -BeLike '*PowerShell Gallery*' $html | Should -BeLike '*SampleModule*' $html | Should -BeLike '*500*' } It 'Uses only inline CSS (no <style> blocks)' { $html = New-HtmlDigest -Activity @() $html | Should -Not -BeLike '*<style*' } It 'Includes the footer with project link' { $html = New-HtmlDigest -Activity @() $html | Should -BeLike '*GitHub-RepoWatch*' $html | Should -BeLike '*https://github.com/larro1991/GitHub-RepoWatch*' } } Describe 'Register-RepoWatchTask (Mock-Based)' { BeforeAll { $modulePath = Split-Path -Path $PSScriptRoot -Parent . (Join-Path $modulePath 'Private\Get-GitHubAPI.ps1') . (Join-Path $modulePath 'Private\Get-LastCheckTime.ps1') . (Join-Path $modulePath 'Private\New-HtmlDigest.ps1') . (Join-Path $modulePath 'Public\Get-RepoActivity.ps1') . (Join-Path $modulePath 'Public\Get-PSGalleryStats.ps1') . (Join-Path $modulePath 'Public\Send-ActivityDigest.ps1') . (Join-Path $modulePath 'Public\Invoke-RepoWatch.ps1') . (Join-Path $modulePath 'Public\Register-RepoWatchTask.ps1') } It 'Creates wrapper script with correct owner and schedule parameters' { # Instead of mocking Register-ScheduledTask (which has CIM type constraints), # we test that the wrapper script is generated correctly. Mock Get-ScheduledTask { return $null } Mock Register-ScheduledTask { return [PSCustomObject]@{ TaskName = 'GitHub-RepoWatch'; State = 'Ready' } } Mock Unregister-ScheduledTask { } Mock Get-ScheduledTaskInfo { return [PSCustomObject]@{ NextRunTime = (Get-Date).AddDays(1) } } # Run the function and allow it to fail on Register-ScheduledTask type constraints Register-RepoWatchTask -Owner 'testowner' -Schedule 'Daily' -Time '09:00' -Confirm:$false -ErrorAction SilentlyContinue # Verify the wrapper script was created with correct content $wrapperPath = Join-Path $env:USERPROFILE '.repowatch\Run-RepoWatch.ps1' Test-Path $wrapperPath | Should -Be $true $wrapperContent = Get-Content -Path $wrapperPath -Raw $wrapperContent | Should -BeLike "*-Owner 'testowner'*" $wrapperContent | Should -BeLike '*Import-Module*' $wrapperContent | Should -BeLike '*Invoke-RepoWatch*' } It 'Includes -SkipIfEmpty in wrapper when specified' { Mock Get-ScheduledTask { return $null } Mock Register-ScheduledTask { return [PSCustomObject]@{ TaskName = 'GitHub-RepoWatch'; State = 'Ready' } } Mock Unregister-ScheduledTask { } Mock Get-ScheduledTaskInfo { return [PSCustomObject]@{ NextRunTime = (Get-Date).AddHours(1) } } Register-RepoWatchTask -Owner 'testowner' -Schedule 'Hourly' -SkipIfEmpty -Confirm:$false -ErrorAction SilentlyContinue $wrapperPath = Join-Path $env:USERPROFILE '.repowatch\Run-RepoWatch.ps1' $wrapperContent = Get-Content -Path $wrapperPath -Raw $wrapperContent | Should -BeLike '*-SkipIfEmpty*' $wrapperContent | Should -BeLike '*-SinceHours 2*' } It 'Includes GitHub token in wrapper when provided' { Mock Get-ScheduledTask { return $null } Mock Register-ScheduledTask { return [PSCustomObject]@{ TaskName = 'GitHub-RepoWatch'; State = 'Ready' } } Mock Unregister-ScheduledTask { } Mock Get-ScheduledTaskInfo { return [PSCustomObject]@{ NextRunTime = (Get-Date).AddDays(1) } } Register-RepoWatchTask -Owner 'testowner' -Token 'ghp_test123' -Schedule 'Daily' -Confirm:$false -ErrorAction SilentlyContinue $wrapperPath = Join-Path $env:USERPROFILE '.repowatch\Run-RepoWatch.ps1' $wrapperContent = Get-Content -Path $wrapperPath -Raw $wrapperContent | Should -BeLike '*GITHUB_TOKEN*' $wrapperContent | Should -BeLike '*ghp_test123*' } } AfterAll { Remove-Module -Name 'GitHub-RepoWatch' -Force -ErrorAction SilentlyContinue } |