tests/DFCatalog.WebProviders.Tests.ps1
|
BeforeAll { . "$PSScriptRoot/../Public/New-DFDirectory.ps1" . "$PSScriptRoot/../Private/DFCatalog.ps1" . "$PSScriptRoot/../Private/Start-DFCatalogRefreshJob.ps1" . "$PSScriptRoot/../Private/DFCatalog.Crates.ps1" . "$PSScriptRoot/../Private/DFCatalog.Npm.ps1" . "$PSScriptRoot/../Private/DFCatalog.Pypi.ps1" . "$PSScriptRoot/../Private/DFCatalog.Choco.ps1" . "$PSScriptRoot/../Private/DFCatalog.PSGallery.ps1" } Describe 'Search-DFCatalogQueryCache engine (via crates)' { BeforeEach { $script:SavedXdgCache = $Env:XDG_CACHE_HOME $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache' Mock Start-DFCatalogRefreshJob { } Mock Invoke-RestMethod { [pscustomobject]@{ crates = @([pscustomobject]@{ name = 'ripgrep'; max_version = '14.1.1' description = 'line-oriented search'; homepage = 'https://rg.example' repository = 'https://github.com/x'; updated_at = '2026-06-01T00:00:00Z' }) } } } AfterEach { $Env:XDG_CACHE_HOME = $script:SavedXdgCache Remove-Item (Join-Path $TestDrive 'cache') -Recurse -Force -ErrorAction Ignore } It 'fetches live on a cache miss and writes the query cache' { $r = @(Search-DFCatalogCrates -Query 'ripgrep') $r.Count | Should -Be 1 $r[0].PSObject.TypeNames[0] | Should -Be 'DotForge.ToolSourceInfo' Should -Invoke Invoke-RestMethod -Times 1 -Exactly Get-ChildItem (Join-Path $Env:XDG_CACHE_HOME 'dotforge/catalogs/crates/queries') -Filter '*.json' | Should -Not -BeNullOrEmpty } It 'serves a fresh cache hit without any web call or refresh job' { $null = Search-DFCatalogCrates -Query 'ripgrep' $r = @(Search-DFCatalogCrates -Query 'ripgrep') $r.Count | Should -Be 1 $r[0].Name | Should -Be 'ripgrep' Should -Invoke Invoke-RestMethod -Times 1 -Exactly # only the first call Should -Invoke Start-DFCatalogRefreshJob -Times 0 -Exactly } It 'serves a stale cache hit and kicks a background refresh instead of blocking' { $null = Search-DFCatalogCrates -Query 'ripgrep' # Age the envelope past the TTL. $file = Get-ChildItem (Join-Path $Env:XDG_CACHE_HOME 'dotforge/catalogs/crates/queries') -Filter '*.json' | Select-Object -First 1 $envelope = Get-Content $file.FullName -Raw | ConvertFrom-Json $envelope.timestamp = [datetime]::UtcNow.AddDays(-3).ToString('o') $envelope | ConvertTo-Json -Depth 6 | Set-Content $file.FullName $r = @(Search-DFCatalogCrates -Query 'ripgrep') $r.Count | Should -Be 1 Should -Invoke Invoke-RestMethod -Times 1 -Exactly # no inline re-fetch Should -Invoke Start-DFCatalogRefreshJob -Times 1 -Exactly } It 'fetches inline under -Fresh even with a valid cache' { $null = Search-DFCatalogCrates -Query 'ripgrep' $null = Search-DFCatalogCrates -Query 'ripgrep' -Fresh Should -Invoke Invoke-RestMethod -Times 2 -Exactly Should -Invoke Start-DFCatalogRefreshJob -Times 0 -Exactly } It 'falls back to the stale cache when the live fetch fails' { $null = Search-DFCatalogCrates -Query 'ripgrep' Mock Invoke-RestMethod { throw 'network down' } $r = @(Search-DFCatalogCrates -Query 'ripgrep' -Fresh) $r.Count | Should -Be 1 $r[0].Name | Should -Be 'ripgrep' } } Describe 'DFCatalog.Crates' { BeforeEach { $script:SavedXdgCache = $Env:XDG_CACHE_HOME $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache' } AfterEach { $Env:XDG_CACHE_HOME = $script:SavedXdgCache Remove-Item (Join-Path $TestDrive 'cache') -Recurse -Force -ErrorAction Ignore } It 'sends a User-Agent header (crates.io requires one)' { Mock Invoke-RestMethod { [pscustomobject]@{ crates = @() } } $null = Search-DFCatalogCrates -Query 'ripgrep' -Fresh Should -Invoke Invoke-RestMethod -ParameterFilter { $Headers['User-Agent'] -match 'DotForge' } } It 'maps crate fields and match kinds' { Mock Invoke-RestMethod { [pscustomobject]@{ crates = @( [pscustomobject]@{ name = 'ripgrep'; max_version = '14.1.1'; description = 'search'; homepage = $null; repository = 'https://repo'; updated_at = '2026-06-01T00:00:00Z' }, [pscustomobject]@{ name = 'ripgrep-all'; max_version = '0.10.9'; description = 'rg for docs'; homepage = 'https://h'; repository = $null; updated_at = $null } ) } } $r = @(Search-DFCatalogCrates -Query 'ripgrep' -Fresh) $r.Count | Should -Be 2 $r[0].Source | Should -Be 'crates' $r[0].MatchKind | Should -Be 'exact-name' $r[0].LatestVersion | Should -Be '14.1.1' $r[0].Homepage | Should -Be 'https://repo' # repository fallback $r[0].PublishedAt | Should -Not -BeNullOrEmpty $r[1].MatchKind | Should -Be 'keyword' $r[1].Homepage | Should -Be 'https://h' } It 'reads installed crates from .crates2.json' { $cargoHome = Join-Path $TestDrive 'cargo' New-Item -ItemType Directory $cargoHome -Force | Out-Null @{ installs = @{ 'ripgrep 14.1.0 (registry+https://github.com/rust-lang/crates.io-index)' = @{ bins = @('rg.exe') } 'fd-find 10.2.0 (registry+https://github.com/rust-lang/crates.io-index)' = @{ bins = @('fd.exe') } } } | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $cargoHome '.crates2.json') $r = @(Get-DFCatalogCratesInstalled -CargoHome $cargoHome) | Sort-Object Name $r.Count | Should -Be 2 $r[0].Name | Should -Be 'fd-find' $r[0].InstalledVersion | Should -Be '10.2.0' $r[1].Name | Should -Be 'ripgrep' } } Describe 'DFCatalog.Npm' { BeforeEach { $script:SavedXdgCache = $Env:XDG_CACHE_HOME $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache' } AfterEach { $Env:XDG_CACHE_HOME = $script:SavedXdgCache Remove-Item (Join-Path $TestDrive 'cache') -Recurse -Force -ErrorAction Ignore } It 'maps npm search results' { Mock Invoke-RestMethod { if ($Uri -match '/-/v1/search') { [pscustomobject]@{ objects = @([pscustomobject]@{ package = [pscustomobject]@{ name = 'prettier'; version = '3.3.0'; description = 'code formatter' links = [pscustomobject]@{ homepage = 'https://prettier.io' } date = '2026-05-01T00:00:00Z' } }) } } else { throw [System.Net.Http.HttpRequestException]::new('404') } } $r = @(Search-DFCatalogNpm -Query 'prettier' -Fresh) $r.Count | Should -BeGreaterOrEqual 1 $r[0].Source | Should -Be 'npm' $r[0].Name | Should -Be 'prettier' $r[0].LatestVersion | Should -Be '3.3.0' $r[0].Homepage | Should -Be 'https://prettier.io' $r[0].MatchKind | Should -Be 'exact-name' } It 'reads globally installed packages from the npm prefix' { $prefix = Join-Path $TestDrive 'npm-prefix' $mods = Join-Path $prefix 'node_modules/prettier' New-Item -ItemType Directory $mods -Force | Out-Null @{ name = 'prettier'; version = '3.2.0' } | ConvertTo-Json | Set-Content (Join-Path $mods 'package.json') $r = @(Get-DFCatalogNpmInstalled -NpmPrefix $prefix) $r.Count | Should -Be 1 $r[0].Name | Should -Be 'prettier' $r[0].InstalledVersion | Should -Be '3.2.0' } } Describe 'DFCatalog.Pypi' { BeforeEach { $script:SavedXdgCache = $Env:XDG_CACHE_HOME $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache' } AfterEach { $Env:XDG_CACHE_HOME = $script:SavedXdgCache Remove-Item (Join-Path $TestDrive 'cache') -Recurse -Force -ErrorAction Ignore } It 'maps the PyPI JSON API for exact names' { Mock Invoke-RestMethod { [pscustomobject]@{ info = [pscustomobject]@{ name = 'httpie'; version = '3.2.2'; summary = 'CLI HTTP client' home_page = 'https://httpie.io'; license = 'BSD' } } } $r = @(Search-DFCatalogPypi -Query 'httpie' -Fresh) $r.Count | Should -Be 1 $r[0].Source | Should -Be 'pypi' $r[0].LatestVersion | Should -Be '3.2.2' $r[0].Description | Should -Be 'CLI HTTP client' $r[0].MatchKind | Should -Be 'exact-name' } It 'returns nothing for multi-word queries (no PyPI search API)' { Mock Invoke-RestMethod { throw 'should not be called' } @(Search-DFCatalogPypi -Query 'http client tool' -Fresh) | Should -BeNullOrEmpty Should -Invoke Invoke-RestMethod -Times 0 -Exactly } } Describe 'OData providers (choco / psgallery)' { BeforeAll { $script:ODataFixture = [xml]@' <feed xmlns="http://www.w3.org/2005/Atom" xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"> <entry> <title type="text">ripgrep</title> <m:properties> <d:Version>14.1.0</d:Version> <d:Description>Line-oriented search tool.</d:Description> <d:ProjectUrl>https://github.com/BurntSushi/ripgrep</d:ProjectUrl> <d:Published m:type="Edm.DateTime">2026-06-01T00:00:00</d:Published> </m:properties> </entry> </feed> '@ } BeforeEach { $script:SavedXdgCache = $Env:XDG_CACHE_HOME $Env:XDG_CACHE_HOME = Join-Path $TestDrive 'cache' } AfterEach { $Env:XDG_CACHE_HOME = $script:SavedXdgCache Remove-Item (Join-Path $TestDrive 'cache') -Recurse -Force -ErrorAction Ignore } It 'maps OData entries for choco' { Mock Invoke-RestMethod { $script:ODataFixture.feed.entry } $r = @(Search-DFCatalogChoco -Query 'ripgrep' -Fresh) $r.Count | Should -Be 1 $r[0].Source | Should -Be 'choco' $r[0].Name | Should -Be 'ripgrep' $r[0].LatestVersion | Should -Be '14.1.0' $r[0].Homepage | Should -Be 'https://github.com/BurntSushi/ripgrep' $r[0].PublishedAt | Should -Not -BeNullOrEmpty $r[0].MatchKind | Should -Be 'exact-name' } It 'maps OData entries for psgallery' { Mock Invoke-RestMethod { $script:ODataFixture.feed.entry } $r = @(Search-DFCatalogPSGallery -Query 'ripgrep' -Fresh) $r.Count | Should -Be 1 $r[0].Source | Should -Be 'psgallery' } It 'reads choco-installed packages from lib nuspecs' { $lib = Join-Path $TestDrive 'choco/lib/ripgrep' New-Item -ItemType Directory $lib -Force | Out-Null @' <?xml version="1.0"?> <package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"> <metadata><id>ripgrep</id><version>14.1.0</version></metadata> </package> '@ | Set-Content (Join-Path $lib 'ripgrep.nuspec') $r = @(Get-DFCatalogChocoInstalled -ChocolateyInstall (Join-Path $TestDrive 'choco')) $r.Count | Should -Be 1 $r[0].Name | Should -Be 'ripgrep' $r[0].InstalledVersion | Should -Be '14.1.0' } } Describe 'web provider registration' { It 'registers all five query-cache providers' { foreach ($name in 'choco', 'npm', 'pypi', 'crates', 'psgallery') { $script:DFCatalogProviders[$name] | Should -Not -BeNullOrEmpty -Because $name $script:DFCatalogProviders[$name].Kind | Should -Be 'query-cache' -Because $name } } } |