import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' import { OpenMeter, type IngestedEvent, type Meter } from '../src/index.js' import { PaginationLimitExceededError, paginateCursor, paginatePages, } from '../src/lib/paginate.js' import type { Result } from '../src/lib/types.js' beforeEach(() => { fetchMock.mockReset() }) function client() { return new OpenMeter({ baseUrl: 'https://eu.api.konghq.com/v3', apiKey: 'k', fetch: fetchMock.fetchHandler, }) } function meterFixture(key: string) { return { id: `01ARZ3NDEKTSV4RRFFQ69G5${key.toUpperCase().padStart(4, '0')}`, name: key, key, aggregation: 'count', event_type: 'api-request', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', } } function eventFixture(id: string) { return { event: { id, source: 'test', specversion: '1.0', type: 'test.event' }, ingested_at: '2024-01-01T00:00:00Z', stored_at: '2024-01-01T00:00:00Z', } } function jsonRoute(body: unknown) { return { body, headers: { 'Content-Type': 'application/json' } } } describe('paginatePages (page-number pagination)', () => { it('iterates every item across 3 pages via client.meters.listAll', async () => { // total 5 across pages of size 2, 2, 1 — the last page is short, which is // what actually stops the iteration (see the exact-total test below for // the other stop condition). const pageKeys = [['a', 'b'], ['c', 'd'], ['e']] fetchMock.route('*', (callLog) => { const number = Number(callLog.queryParams?.get('page[number]') ?? '1') const keys = pageKeys[number - 1] ?? [] return jsonRoute({ data: keys.map(meterFixture), meta: { page: { number, size: 2, total: 5 } }, }) }) const keys: string[] = [] for await (const meter of client().meters.listAll()) { keys.push(meter.key) } expect(keys).toEqual(['a', 'b', 'c', 'd', 'e']) expect(fetchMock.callHistory.calls()).toHaveLength(3) }) it('stops without an extra request when the final page exactly fills total', async () => { fetchMock.route('*', (callLog) => { const number = Number(callLog.queryParams?.get('page[number]') ?? '1') const keys = number === 1 ? ['a', 'b'] : [] return jsonRoute({ data: keys.map(meterFixture), meta: { page: { number, size: 2, total: 2 } }, }) }) const keys: string[] = [] for await (const meter of client().meters.listAll()) { keys.push(meter.key) } expect(keys).toEqual(['a', 'b']) expect(fetchMock.callHistory.calls()).toHaveLength(1) }) it('yields nothing and fetches once for an empty first page', async () => { fetchMock.route( '*', jsonRoute({ data: [], meta: { page: { number: 1, size: 10, total: 0 } }, }), ) const keys: string[] = [] for await (const meter of client().meters.listAll()) { keys.push(meter.key) } expect(keys).toEqual([]) expect(fetchMock.callHistory.calls()).toHaveLength(1) }) it('fires no further requests once the consumer breaks early', async () => { const pageKeys = [ ['a', 'b'], ['c', 'd'], ] fetchMock.route('*', (callLog) => { const number = Number(callLog.queryParams?.get('page[number]') ?? '1') return jsonRoute({ data: (pageKeys[number - 1] ?? []).map(meterFixture), meta: { page: { number, size: 2, total: 4 } }, }) }) for await (const meter of client().meters.listAll()) { expect(meter.key).toBe('a') break } expect(fetchMock.callHistory.calls()).toHaveLength(1) }) it('sends the caller-supplied filter and sort on every page, advancing only page.number', async () => { fetchMock.route('*', (callLog) => { const number = Number(callLog.queryParams?.get('page[number]') ?? '1') const keys = number < 2 ? ['a', 'b'] : ['c'] return jsonRoute({ data: keys.map(meterFixture), meta: { page: { number, size: 2, total: 3 } }, }) }) const items: Meter[] = [] for await (const meter of client().meters.listAll({ filter: { key: { eq: 'api' } }, sort: { by: 'createdAt', order: 'desc' }, })) { items.push(meter) } expect(items).toHaveLength(3) const calls = fetchMock.callHistory.calls() expect(calls).toHaveLength(2) for (const [i, call] of calls.entries()) { const q = new URL(call.url).searchParams expect(q.get('filter[key][eq]')).toBe('api') expect(q.get('sort')).toBe('created_at desc') // The first request is whatever the caller supplied — no page.number at // all, left to the server's own default — only later requests carry an // explicit page.number, advanced from the previous response. expect(q.get('page[number]')).toBe(i === 0 ? null : String(i + 1)) } }) it('propagates the AbortSignal to every page fetch, and aborting stops iteration', async () => { const controller = new AbortController() // ky composes the caller's signal into its own dependent AbortSignal // before it reaches fetch (its `.aborted` state tracks the original, but // it is not the same object), so propagation is verified behaviorally — // a signal reaches every dispatched request, and aborting mid-iteration // stops it — rather than by reference identity. const signals: (AbortSignal | undefined)[] = [] fetchMock.route('*', (callLog) => { signals.push(callLog.signal) const number = Number(callLog.queryParams?.get('page[number]') ?? '1') return jsonRoute({ data: [meterFixture(`m${number}`)], meta: { page: { number, size: 1, total: 5 } }, }) }) const keys: string[] = [] await expect(async () => { for await (const meter of client().meters.listAll(undefined, { signal: controller.signal, })) { keys.push(meter.key) controller.abort() } }).rejects.toThrow() // The abort races with the second page's already-dispatched request: the // mock still resolves a response for it (this route intercepts before // ky/fetch's own abort check settles the promise), but the abort still // wins — no page-2 item is yielded, and the iterable rejects instead of // completing normally. expect(keys).toEqual(['m1']) expect(signals.length).toBeGreaterThanOrEqual(1) expect(signals.every((signal) => signal !== undefined)).toBe(true) }) it('throws PaginationLimitExceededError instead of looping forever on a server that never signals the end', async () => { let calls = 0 const fetchPage = async (): Promise< Result<{ data: unknown[] meta: { page: { number: number; size: number; total: number } } }> > => { calls++ return { ok: true, value: { data: [{}], meta: { page: { number: calls, size: 1, total: Number.MAX_SAFE_INTEGER }, }, }, } } await expect(async () => { for await (const _ of paginatePages(fetchPage, {})) { // drain } }).rejects.toThrow(PaginationLimitExceededError) expect(calls).toBe(10_000) }) }) describe('paginateCursor (cursor pagination)', () => { it('iterates every item across 3 pages via client.events.listAll', async () => { const pages: Record = { '': { ids: ['1', '2'], next: 'cursor-2' }, 'cursor-2': { ids: ['3', '4'], next: 'cursor-3' }, 'cursor-3': { ids: ['5'], next: undefined }, } fetchMock.route('*', (callLog) => { const after = callLog.queryParams?.get('page[after]') ?? '' const page = pages[after]! return jsonRoute({ data: page.ids.map(eventFixture), meta: { page: { next: page.next ?? null } }, }) }) const ids: string[] = [] for await (const event of client().events.listAll()) { ids.push(event.event.id) } expect(ids).toEqual(['1', '2', '3', '4', '5']) expect(fetchMock.callHistory.calls()).toHaveLength(3) }) it('stops after one fetch when the first page has no next cursor', async () => { fetchMock.route( '*', jsonRoute({ data: [eventFixture('1')], meta: { page: { next: null } }, }), ) const ids: string[] = [] for await (const event of client().events.listAll()) { ids.push(event.event.id) } expect(ids).toEqual(['1']) expect(fetchMock.callHistory.calls()).toHaveLength(1) }) it('fires no further requests once the consumer breaks early', async () => { const pages: Record = { '': { ids: ['1', '2'], next: 'cursor-2' }, 'cursor-2': { ids: ['3'], next: undefined }, } fetchMock.route('*', (callLog) => { const after = callLog.queryParams?.get('page[after]') ?? '' const page = pages[after]! return jsonRoute({ data: page.ids.map(eventFixture), meta: { page: { next: page.next ?? null } }, }) }) for await (const event of client().events.listAll()) { expect(event.event.id).toBe('1') break } expect(fetchMock.callHistory.calls()).toHaveLength(1) }) it('feeds meta.page.next back as page.after verbatim (an opaque token, not a URI to fetch)', async () => { const pages: Record = { '': { ids: ['1'], next: 'opaque-token-not-a-url' }, 'opaque-token-not-a-url': { ids: ['2'], next: undefined }, } fetchMock.route('*', (callLog) => { const after = callLog.queryParams?.get('page[after]') ?? '' const page = pages[after]! return jsonRoute({ data: page.ids.map(eventFixture), meta: { page: { next: page.next ?? null } }, }) }) const ids: string[] = [] for await (const event of client().events.listAll()) { ids.push(event.event.id) } expect(ids).toEqual(['1', '2']) }) it('sends the caller-supplied filter on every page, advancing only page.after', async () => { const pages: Record = { '': { ids: ['1'], next: 'cursor-2' }, 'cursor-2': { ids: ['2'], next: undefined }, } fetchMock.route('*', (callLog) => { const after = callLog.queryParams?.get('page[after]') ?? '' const page = pages[after]! return jsonRoute({ data: page.ids.map(eventFixture), meta: { page: { next: page.next ?? null } }, }) }) for await (const _ of client().events.listAll({ filter: { subject: { eq: 'customer-1' } }, })) { // drain } const calls = fetchMock.callHistory.calls() expect(calls).toHaveLength(2) for (const call of calls) { expect(new URL(call.url).searchParams.get('filter[subject][eq]')).toBe( 'customer-1', ) } }) it('throws PaginationLimitExceededError instead of looping forever on a server that never stops returning a next cursor', async () => { let calls = 0 const fetchPage = async (): Promise< Result<{ data: unknown[]; meta: { page: { next: string | null } } }> > => { calls++ return { ok: true, value: { data: [{}], meta: { page: { next: 'again' } } }, } } await expect(async () => { for await (const _ of paginateCursor(fetchPage, {})) { // drain } }).rejects.toThrow(PaginationLimitExceededError) expect(calls).toBe(10_000) }) }) describe('AsyncIterable type inference', () => { // Compile-time proof that the facade companions infer the ITEM type, not // the page envelope — `listAll`'s return type must be exactly // `AsyncIterable`/`AsyncIterable`, with no cast at the // call site required to get there. type ListAllItem = T extends AsyncIterable ? Item : never type _MetersListAllYieldsMeter = [ ListAllItem>, ] extends [Meter] ? [Meter] extends [ListAllItem>] ? true : { __error: 'meters.listAll yields something narrower than Meter' } : { __error: 'meters.listAll does not yield Meter' } const _metersListAllYieldsMeter: _MetersListAllYieldsMeter = true type _EventsListAllYieldsIngestedEvent = [ ListAllItem>, ] extends [IngestedEvent] ? [IngestedEvent] extends [ ListAllItem>, ] ? true : { __error: 'events.listAll yields something narrower than IngestedEvent' } : { __error: 'events.listAll does not yield IngestedEvent' } const _eventsListAllYieldsIngestedEvent: _EventsListAllYieldsIngestedEvent = true it('type-checks the item-level inference probes above', () => { expect(_metersListAllYieldsMeter).toBe(true) expect(_eventsListAllYieldsIngestedEvent).toBe(true) }) })