tests/DFPackageUniverse.Choco.Tests.ps1
|
BeforeAll { Import-Module PSSQLite . "$PSScriptRoot/../Private/DFCatalog.ps1" . "$PSScriptRoot/../Private/DFCatalog.Choco.ps1" . "$PSScriptRoot/../build/Private/DFPackageUniverse.Db.ps1" . "$PSScriptRoot/../build/Private/DFPackageUniverse.Choco.ps1" function New-FakeChocoODataEntry { param($Id, [switch]$Typed, [string]$Author = 'BurntSushi') # Mimics a raw feed entry: Authors is NOT under .properties -- OData # feed customization (FC_KeepInContent=false) puts it on the Atom # <author><name> element; see DFPackageUniverse.Choco.ps1 header. $wrap = { param($v) if ($Typed) { [pscustomobject]@{ '#text' = $v } } else { $v } } [pscustomobject]@{ properties = [pscustomobject]@{ Id = $Id Version = '14.1.1' Description = 'Recursively search directories for a regex pattern' ProjectUrl = 'https://github.com/BurntSushi/ripgrep' Tags = (& $wrap 'search grep cli') LicenseUrl = (& $wrap 'https://github.com/BurntSushi/ripgrep/blob/master/LICENSE-MIT') } author = [pscustomobject]@{ name = $Author } title = $Id } } # A REAL Atom/OData entry, parsed through the same production feed parser the # live walk uses, so extra-capture is tested against a genuine XmlElement # (whose PSObject surface differs wildly from a pscustomobject fake -- the # exact gap a live check exposed on 2026-07-16). -Sparse omits every optional # field, mirroring the ~34% of the catalog that ships minimal manifests. function New-RealChocoEntry { param([string]$Id = 'ripgrep', [switch]$Sparse) $propsXml = if ($Sparse) { "<d:Id>$Id</d:Id><d:Version>1.0</d:Version>" } else { @" <d:Id>$Id</d:Id> <d:Version>14.1.1</d:Version> <d:Description>Recursively search directories for a regex pattern</d:Description> <d:ProjectUrl>https://github.com/BurntSushi/ripgrep</d:ProjectUrl> <d:ProjectSourceUrl>https://github.com/BurntSushi/ripgrep</d:ProjectSourceUrl> <d:PackageSourceUrl>https://github.com/chocolatey-community/chocolatey-packages</d:PackageSourceUrl> <d:DocsUrl>https://github.com/BurntSushi/ripgrep/blob/master/README.md</d:DocsUrl> <d:BugTrackerUrl>https://github.com/BurntSushi/ripgrep/issues</d:BugTrackerUrl> <d:ReleaseNotes>https://github.com/BurntSushi/ripgrep/releases</d:ReleaseNotes> <d:Copyright>Copyright (c) 2016 Andrew Gallant</d:Copyright> <d:DownloadCount m:type="Edm.Int32">123456</d:DownloadCount> <d:Dependencies>chocolatey-core.extension:1.3.3</d:Dependencies> <d:Published m:type="Edm.DateTime">2024-01-05T00:00:00Z</d:Published> <d:Tags>search grep cli</d:Tags> <d:LicenseUrl>https://github.com/BurntSushi/ripgrep/blob/master/LICENSE-MIT</d:LicenseUrl> "@ } $atomExtra = if ($Sparse) { '' } else { '<summary>ripgrep recursively searches directories</summary><updated>2024-01-06T00:00:00Z</updated>' } $feedText = @" <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>$Id</title> $atomExtra <author><name>BurntSushi</name></author> <m:properties> $propsXml </m:properties> </entry> </feed> "@ @((ConvertFrom-DFPackageUniverseChocoFeedXml -Xml ([xml]$feedText)).Entries)[0] } # A fake fetch seam matching the production contract: param($Url) -> # @{ Entries; NextUrl }. Pages are returned by call order; NextUrl is a # non-null sentinel for every page except the last, mirroring the server's # <link rel="next"> continuation (its absence = end of walk). function New-FakeChocoFetch { param([object[]]$Pages) $counter = @{ Index = 0 } { param($Url) $i = $counter.Index $counter.Index++ $entries = if ($i -lt $Pages.Count) { $Pages[$i] } else { @() } $next = if (($i + 1) -lt $Pages.Count) { "https://fake/next/page$($i + 1)" } else { $null } [pscustomobject]@{ Entries = @($entries); NextUrl = $next } }.GetNewClosure() } } Describe 'DFPackageUniverse.Choco' { Context 'ConvertTo-DFPackageUniverseChocoRow' { It 'maps id/name/description/version/homepage plus tags/license from properties' { $row = ConvertTo-DFPackageUniverseChocoRow -Entry (New-FakeChocoODataEntry -Id 'ripgrep') $row.source | Should -Be 'choco' $row.package_id | Should -Be 'ripgrep' $row.name | Should -Be 'ripgrep' $row.version | Should -Be '14.1.1' $row.description | Should -Be 'Recursively search directories for a regex pattern' $row.homepage | Should -Be 'https://github.com/BurntSushi/ripgrep' $row.license | Should -Be 'https://github.com/BurntSushi/ripgrep/blob/master/LICENSE-MIT' (ConvertFrom-Json $row.tags) | Should -Be @('search', 'grep', 'cli') } It 'reads publisher from the Atom author element, not entry.properties.Authors' { $row = ConvertTo-DFPackageUniverseChocoRow -Entry (New-FakeChocoODataEntry -Id 'ripgrep' -Author 'BurntSushi') $row.publisher | Should -Be 'BurntSushi' $entry = New-FakeChocoODataEntry -Id 'ripgrep' $entry.properties.PSObject.Properties.Match('Authors').Count | Should -Be 0 } It 'handles m:typed (#text-wrapped) OData elements the same as plain strings' { $row = ConvertTo-DFPackageUniverseChocoRow -Entry (New-FakeChocoODataEntry -Id 'ripgrep' -Typed) $row.license | Should -Be 'https://github.com/BurntSushi/ripgrep/blob/master/LICENSE-MIT' (ConvertFrom-Json $row.tags) | Should -Be @('search', 'grep', 'cli') } It 'returns $null when the entry has no resolvable id' { $entry = [pscustomobject]@{ properties = [pscustomobject]@{ Version = '1.0' }; author = [pscustomobject]@{ name = '' } } ConvertTo-DFPackageUniverseChocoRow -Entry $entry | Should -BeNullOrEmpty } It 'does not throw under strict mode on an entry missing properties/author entirely' { # Strict mode is the point: absent members are normal in this feed, # so every read goes through Get-DFXmlMember. A bare $entry.properties # would throw here rather than return $null. Set-StrictMode -Version Latest { ConvertTo-DFPackageUniverseChocoRow -Entry ([pscustomobject]@{ title = 'lonely' }) } | Should -Not -Throw (ConvertTo-DFPackageUniverseChocoRow -Entry ([pscustomobject]@{ title = 'lonely' })).package_id | Should -Be 'lonely' } } Context 'extra (full-fidelity capture)' { It 'captures the full source property set into extra, including the dropped ProjectSourceUrl and sibling urls' { $row = ConvertTo-DFPackageUniverseChocoRow -Entry (New-RealChocoEntry -Id 'ripgrep') $extra = $row.extra | ConvertFrom-Json # the load-bearing repo signal Phase A discarded $extra.ProjectSourceUrl | Should -Be 'https://github.com/BurntSushi/ripgrep' $extra.PackageSourceUrl | Should -Be 'https://github.com/chocolatey-community/chocolatey-packages' $extra.DocsUrl | Should -Be 'https://github.com/BurntSushi/ripgrep/blob/master/README.md' $extra.BugTrackerUrl | Should -Be 'https://github.com/BurntSushi/ripgrep/issues' $extra.ReleaseNotes | Should -Be 'https://github.com/BurntSushi/ripgrep/releases' $extra.Copyright | Should -Be 'Copyright (c) 2016 Andrew Gallant' $extra.Dependencies | Should -Be 'chocolatey-core.extension:1.3.3' # Published is date-shaped; assert the persisted JSON string, since # ConvertFrom-Json would coerce it back to a [datetime]. $row.extra | Should -Match ([regex]::Escape('"Published":"2024-01-05T00:00:00Z"')) } It 'captures a typed (m:type) element by its text value, like DownloadCount' { $extra = (ConvertTo-DFPackageUniverseChocoRow -Entry (New-RealChocoEntry -Id 'ripgrep')).extra | ConvertFrom-Json $extra.DownloadCount | Should -Be '123456' } It 'captures Atom-level summary and updated (LastUpdated) into extra' { $row = ConvertTo-DFPackageUniverseChocoRow -Entry (New-RealChocoEntry -Id 'ripgrep') ($row.extra | ConvertFrom-Json).Summary | Should -Be 'ripgrep recursively searches directories' $row.extra | Should -Match ([regex]::Escape('"LastUpdated":"2024-01-06T00:00:00Z"')) } It 'is full-fidelity: fields also promoted to typed columns still appear in extra' { $extra = (ConvertTo-DFPackageUniverseChocoRow -Entry (New-RealChocoEntry -Id 'ripgrep')).extra | ConvertFrom-Json $extra.ProjectUrl | Should -Be 'https://github.com/BurntSushi/ripgrep' $extra.Version | Should -Be '14.1.1' } It 'captures ONLY real OData fields -- no XML-DOM members leak into extra' { # Regression lock for the 2026-07-16 finding: iterating a live # XmlElement's PSObject.Properties swept in InnerXml/BaseURI/etc. $names = ((ConvertTo-DFPackageUniverseChocoRow -Entry (New-RealChocoEntry -Id 'ripgrep')).extra | ConvertFrom-Json).PSObject.Properties.Name foreach ($junk in 'InnerXml', 'OuterXml', 'BaseURI', 'ChildNodes', 'LocalName', 'NamespaceURI', 'NodeType', 'Attributes') { $names | Should -Not -Contain $junk } } It 'sets extra to $null when there is nothing beyond a bare title to capture' { Set-StrictMode -Version Latest $row = ConvertTo-DFPackageUniverseChocoRow -Entry ([pscustomobject]@{ title = 'lonely' }) $row.package_id | Should -Be 'lonely' $row.extra | Should -BeNullOrEmpty } It 'does not throw under strict on a sparse real entry and captures only the properties present' { Set-StrictMode -Version Latest $entry = New-RealChocoEntry -Id 'mini' -Sparse { ConvertTo-DFPackageUniverseChocoRow -Entry $entry } | Should -Not -Throw $extra = (ConvertTo-DFPackageUniverseChocoRow -Entry $entry).extra | ConvertFrom-Json $extra.Id | Should -Be 'mini' $extra.Version | Should -Be '1.0' $extra.PSObject.Properties.Name | Should -Not -Contain 'ProjectSourceUrl' } } Context 'Build-DFPackageUniverseChocoRequestUrl' { It 'asks for IsLatestVersion only -- no date filter, no $skip, no $top' { # THE regression test for the 2026-07-15 redesign. A Published or # LastUpdated clause creeping back in would still produce correct # data, just ~16x more slowly (~4,480 requests instead of ~281), # because this server applies filters AFTER pagination. That is # invisible to every other test, so it is asserted here explicitly. $url = [uri]::UnescapeDataString((Build-DFPackageUniverseChocoRequestUrl)) $url | Should -Match '\$filter=IsLatestVersion' $url | Should -Not -Match 'Published' $url | Should -Not -Match 'LastUpdated' $url | Should -Not -Match '\$skip' $url | Should -Not -Match '\$top' } } Context 'ConvertFrom-DFPackageUniverseChocoFeedXml (the production fetch parser)' { # Every other test injects a fake fetch seam, so this parser -- the one # that actually runs against the live server -- had NO coverage at all # until 2026-07-15, when it turned out to throw on the last page of # every walk. Strict mode here on purpose: the script runs strict, and # StrictMode is dynamically scoped, so a default Pester run would not # reproduce production. BeforeEach { Set-StrictMode -Version Latest } It 'reads entries and the next-link from a mid-walk page' { $xml = [xml]@' <feed xmlns="http://www.w3.org/2005/Atom"> <link rel="self" href="https://community.chocolatey.org/api/v2/Packages" /> <link rel="next" href="http://community.chocolatey.org/api/v2/Packages?$skiptoken='11','adobeair'" /> <entry><title>0ad</title></entry> <entry><title>AdobeAIR</title></entry> </feed> '@ $page = ConvertFrom-DFPackageUniverseChocoFeedXml -Xml $xml $page.Entries.Count | Should -Be 2 $page.NextUrl | Should -Match 'skiptoken' # http -> https: the server hands back an http next-link. $page.NextUrl | Should -BeLike 'https://*' } It 'returns NextUrl = $null on the LAST page (no next link) instead of throwing' { # THE regression test. `($xml.feed.link | Where-Object {...}).href` # throws here under strict -- and the walk would misread its own # correct termination as a failed page, retry 3x, and report # Complete=$false, skipping the prune. $xml = [xml]@' <feed xmlns="http://www.w3.org/2005/Atom"> <link rel="self" href="https://community.chocolatey.org/api/v2/Packages" /> <entry><title>zzz-last-package</title></entry> </feed> '@ $page = ConvertFrom-DFPackageUniverseChocoFeedXml -Xml $xml $page.Entries.Count | Should -Be 1 $page.NextUrl | Should -BeNullOrEmpty } It 'returns zero entries for an empty feed instead of throwing' { $xml = [xml]@' <feed xmlns="http://www.w3.org/2005/Atom"> <link rel="self" href="https://community.chocolatey.org/api/v2/Packages" /> </feed> '@ $page = ConvertFrom-DFPackageUniverseChocoFeedXml -Xml $xml $page.Entries.Count | Should -Be 0 $page.NextUrl | Should -BeNullOrEmpty } # NB: no angle brackets in this name -- Pester 5 treats <...> in a test # name as a -ForEach template placeholder and tries to expand it, so # 'no <link> elements' fails with "The variable '$link' cannot be # retrieved" under strict mode. The code is fine; the name was not. It 'handles a feed with no link elements at all' { $xml = [xml]'<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>a</title></entry></feed>' $page = ConvertFrom-DFPackageUniverseChocoFeedXml -Xml $xml $page.Entries.Count | Should -Be 1 $page.NextUrl | Should -BeNullOrEmpty } } Context 'Invoke-DFPackageUniverseChocoPageWithRetry' { BeforeEach { $script:logged = @() $script:slept = @() $script:log = { param($Level, $PackageId, $Message) $script:logged += , @($Level, $PackageId, $Message) } $script:sleep = { param($Milliseconds) $script:slept += $Milliseconds } } It 'returns the page and its NextUrl on first success without sleeping' { $fetch = { param($Url) [pscustomobject]@{ Entries = @('a', 'b'); NextUrl = 'https://fake/next' } } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.Ok | Should -BeTrue $result.Entries.Count | Should -Be 2 $result.NextUrl | Should -Be 'https://fake/next' $budget.Used | Should -Be 1 $script:slept.Count | Should -Be 0 } It 'treats an empty feed as zero entries, not one (the @($null) null-guard)' { # @($xml.feed.entry) yields @($null) -- Count 1 -- on a page with no # entries. That artifact is why the 2026-07-15 run logged empty pages # as "1 entries, 0 new", an arithmetic impossibility that exposed the # bug. Nulls must be filtered out, not counted. $fetch = { param($Url) [pscustomobject]@{ Entries = @($null); NextUrl = $null } } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.Ok | Should -BeTrue $result.Entries.Count | Should -Be 0 } It 'retries with 1s/2s/4s backoff then succeeds for a transient error' { $script:attempts = 0 $fetch = { param($Url) $script:attempts++ if ($script:attempts -le 2) { throw 'transient network error' } [pscustomobject]@{ Entries = @('a'); NextUrl = $null } } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.Ok | Should -BeTrue $script:slept | Should -Be @(1000, 2000) $script:logged.Count | Should -Be 0 } It 'gives up and logs an error after 3 retries of a transient failure' { $fetch = { param($Url) throw 'always fails' } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.Ok | Should -BeFalse $result.CapHit | Should -BeFalse $script:slept | Should -Be @(1000, 2000, 4000) $script:logged.Count | Should -Be 1 $script:logged[0][0] | Should -Be 'error' } It 'recognizes a 406 (item-cap) immediately, without retrying or sleeping' { $fetch = { param($Url) throw [System.Exception]::new('406 Not Acceptable: Traversing more than 10000 items is no longer supported.') } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.Ok | Should -BeFalse $result.CapHit | Should -BeTrue $script:slept.Count | Should -Be 0 $budget.Used | Should -Be 1 $script:logged.Count | Should -Be 0 } It 'recognizes a 429 (rate limit) immediately, aborts the run, and never retries' { $fetch = { param($Url) throw [System.Exception]::new('Response status code does not indicate success: 429 (Too Many Requests).') } $budget = @{ Used = 0; Max = 100; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.RateLimited | Should -BeTrue $result.Ok | Should -BeFalse $script:slept.Count | Should -Be 0 $budget.Used | Should -Be 1 $budget.Aborted | Should -BeTrue $budget.AbortReason | Should -Match '429' @($script:logged | Where-Object { $_[0] -eq 'error' }).Count | Should -Be 1 } It 'stops immediately once the request budget is exhausted' { $fetch = { param($Url) [pscustomobject]@{ Entries = @('a'); NextUrl = $null } } $budget = @{ Used = 5; Max = 5; Aborted = $false; AbortReason = $null; ErrorCount = 0 } $result = Invoke-DFPackageUniverseChocoPageWithRetry -FetchPage $fetch -Url 'https://fake/p1' -Log $script:log -Sleep $script:sleep -RequestBudget $budget $result.BudgetExhausted | Should -BeTrue $budget.Used | Should -Be 5 $budget.Aborted | Should -BeTrue } } Context 'Get-DFPackageUniverseChocoRows (the walk)' { BeforeEach { $script:logged = @() $script:log = { param($Level, $PackageId, $Message) $script:logged += , @($Level, $PackageId, $Message) } $script:sleep = { param($Milliseconds) } } It 'follows next-links across all pages until there is no next link' { $fetch = New-FakeChocoFetch -Pages @( @((New-FakeChocoODataEntry 'a'), (New-FakeChocoODataEntry 'b'), (New-FakeChocoODataEntry 'c')), @((New-FakeChocoODataEntry 'd')), @((New-FakeChocoODataEntry 'e'), (New-FakeChocoODataEntry 'f')) ) $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Rows.Count | Should -Be 6 $result.Rows.package_id | Should -Be @('a', 'b', 'c', 'd', 'e', 'f') # 3 pages, then the third has no next link -> stop. No wasted request. $result.RequestsUsed | Should -Be 3 $result.PageCount | Should -Be 3 $result.Complete | Should -BeTrue } It 'stops after a single page with no next link' { $fetch = New-FakeChocoFetch -Pages @(@((New-FakeChocoODataEntry 'a'))) $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Rows.Count | Should -Be 1 $result.RequestsUsed | Should -Be 1 $result.Complete | Should -BeTrue } It 'deduplicates a package_id redelivered across pages (defensive safety net)' { # Dedup must NOT be the termination signal -- treating "no new ids" # as end-of-walk is what produced the 125-row crawl. Here page 2 is # entirely redelivered yet the walk continues to page 3. $fetch = New-FakeChocoFetch -Pages @( @((New-FakeChocoODataEntry 'a'), (New-FakeChocoODataEntry 'b')), @((New-FakeChocoODataEntry 'a'), (New-FakeChocoODataEntry 'b')), @((New-FakeChocoODataEntry 'c')) ) $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Rows.Count | Should -Be 3 $result.Rows.package_id | Should -Be @('a', 'b', 'c') $result.PageCount | Should -Be 3 $result.Complete | Should -BeTrue } It 'flushes each page to WriteRows as it arrives, not once at the end' { # An interrupted run must not lose what it already fetched: the # 2026-07-15 run lost ~430 requests' worth of rows to end-of-walk # batching. Page 3 throws here; pages 1-2 must already be written. $script:written = @() $writeRows = { param($PageRows) $script:written += , @($PageRows.package_id) } $script:calls = 0 $fetch = { param($Url) $script:calls++ if ($script:calls -ge 3) { throw 'network died mid-walk' } [pscustomobject]@{ Entries = @((New-FakeChocoODataEntry "pkg$($script:calls)")) NextUrl = 'https://fake/next' } } $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -WriteRows $writeRows -DelayMinMs 0 -DelayMaxMs 1 $script:written.Count | Should -Be 2 $script:written[0] | Should -Be @('pkg1') $script:written[1] | Should -Be @('pkg2') $result.Complete | Should -BeFalse } It 'treats an unexpected 406 as a hard error, not a signal to partition' { # Under skiptoken paging the cap should be unreachable. If it ever # fires, the server changed and the design's central assumption # needs re-testing -- so it must be loud, and must block the prune. $fetch = { param($Url) throw [System.Exception]::new('406 Not Acceptable') } $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Rows.Count | Should -Be 0 $result.Complete | Should -BeFalse $result.ErrorCount | Should -Be 1 $errors = @($script:logged | Where-Object { $_[0] -eq 'error' }) $errors.Count | Should -Be 1 $errors[0][2] | Should -Match '406' } It 'logs a warning and skips an entry that fails conversion, without aborting the page' { Mock ConvertTo-DFPackageUniverseChocoRow { param($Entry) if ($Entry.title -eq 'broken') { throw 'boom' } [pscustomobject]@{ source = 'choco'; package_id = $Entry.title } } $fetch = New-FakeChocoFetch -Pages @(@((New-FakeChocoODataEntry 'broken'), (New-FakeChocoODataEntry 'fd'))) $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Rows.Count | Should -Be 1 $result.Rows[0].package_id | Should -Be 'fd' @($script:logged | Where-Object { $_[0] -eq 'warning' }).Count | Should -Be 1 $result.Complete | Should -BeTrue } It 'does not throw when DelayMin equals DelayMax (Get-Random guard)' { $fetch = New-FakeChocoFetch -Pages @( @((New-FakeChocoODataEntry 'a')), @((New-FakeChocoODataEntry 'b')) ) { Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep { param($ms) } -Log $script:log -DelayMinMs 500 -DelayMaxMs 500 } | Should -Not -Throw } It 'reports Complete=$false when a 429 aborts the walk' { $fetch = { param($Url) throw [System.Exception]::new('Response status code does not indicate success: 429 (Too Many Requests).') } $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Complete | Should -BeFalse $result.AbortReason | Should -Match '429' } It 'reports Complete=$false when the request budget is exhausted' { # Every page has a next link, so only the budget stops it. $fetch = { param($Url) [pscustomobject]@{ Entries = @((New-FakeChocoODataEntry 'a')); NextUrl = 'https://fake/next' } } $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -MaxRequests 3 -DelayMinMs 0 -DelayMaxMs 1 $result.Complete | Should -BeFalse $result.RequestsUsed | Should -Be 3 $result.AbortReason | Should -Match 'budget' } It 'reports Complete=$false when a page fails after its retries' { $fetch = { param($Url) throw 'always fails' } $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.Complete | Should -BeFalse $result.ErrorCount | Should -Be 1 } It 'exposes SeenIds for the caller''s prune gate' { $fetch = New-FakeChocoFetch -Pages @(@((New-FakeChocoODataEntry 'a'), (New-FakeChocoODataEntry 'b'))) $result = Get-DFPackageUniverseChocoRows -FetchPage $fetch -Sleep $script:sleep -Log $script:log -DelayMinMs 0 -DelayMaxMs 1 $result.SeenIds.Contains('a') | Should -BeTrue $result.SeenIds.Contains('b') | Should -BeTrue $result.SeenIds.Contains('nope') | Should -BeFalse } } } |