diff --git a/app/pages/search.vue b/app/pages/search.vue index b306359ca7..58fac08c02 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -265,6 +265,18 @@ const effectiveTotal = computed(() => { return displayResults.value.length }) +/** + * Total items for pagination purposes. + * Capped by EAGER_LOAD_SIZE so that the page count only reflects pages we can + * actually fetch — e.g. with a 500-result cap, max pages = ceil(500 / pageSize). + * Without this cap, a search returning total=92,000 would show 3,680 pages but + * navigation beyond page 20 (at 25/page) would silently fail. + */ +const paginationTotal = computed(() => { + const cap = EAGER_LOAD_SIZE[searchProvider.value] + return Math.min(effectiveTotal.value, cap) +}) + // Handle filter chip removal function handleClearFilter(chip: FilterChip) { clearFilter(chip) @@ -878,7 +890,7 @@ onBeforeUnmount(() => { v-model:mode="paginationMode" v-model:page-size="preferredPageSize" v-model:current-page="currentPage" - :total-items="effectiveTotal" + :total-items="paginationTotal" :view-mode="viewMode" /> diff --git a/test/unit/app/utils/pagination.spec.ts b/test/unit/app/utils/pagination.spec.ts new file mode 100644 index 0000000000..64a08a6050 --- /dev/null +++ b/test/unit/app/utils/pagination.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +/** + * Tests for the pagination total capping logic used in search.vue. + * + * The search page caps the displayed pagination total to EAGER_LOAD_SIZE + * so that page links only reflect pages that can actually be fetched. + * Without the cap, a search returning total=92,000 items would show 3,680 + * pages at 25 items/page, but navigation beyond page 20 silently fails. + */ + +const EAGER_LOAD_SIZE = { algolia: 500, npm: 500 } as const + +function paginationTotal(effectiveTotal: number, provider: keyof typeof EAGER_LOAD_SIZE): number { + const cap = EAGER_LOAD_SIZE[provider] + return Math.min(effectiveTotal, cap) +} + +describe('paginationTotal capping logic', () => { + it('returns the total as-is when it is below the cap', () => { + expect(paginationTotal(100, 'npm')).toBe(100) + expect(paginationTotal(100, 'algolia')).toBe(100) + }) + + it('returns the cap when the total exceeds it', () => { + expect(paginationTotal(92_000, 'npm')).toBe(500) + expect(paginationTotal(92_000, 'algolia')).toBe(500) + }) + + it('returns exactly the cap when the total equals the cap', () => { + expect(paginationTotal(500, 'npm')).toBe(500) + expect(paginationTotal(500, 'algolia')).toBe(500) + }) + + it('returns 0 when total is 0', () => { + expect(paginationTotal(0, 'npm')).toBe(0) + }) + + it('caps algolia and npm identically (both have 500 limit)', () => { + const total = 10_000 + expect(paginationTotal(total, 'algolia')).toBe(paginationTotal(total, 'npm')) + }) + + it('page count derived from capped total stays within fetchable range', () => { + const pageSize = 25 + const rawTotal = 92_000 + const cappedTotal = paginationTotal(rawTotal, 'npm') + const maxPages = Math.ceil(cappedTotal / pageSize) + // Should be 20 pages (500 / 25), not 3680 (92000 / 25) + expect(maxPages).toBe(20) + }) +})