This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / pagination.test.ts
3.9 kB 106 lines
1import { describe, expect, it, vi, type Mock } from 'vitest'; 2import { createBobbinClient, type BobbinContext } from './client'; 3import { collect, pages, paginateBy } from './pagination'; 4 5const NAME = 'sh.tangled.feed.listStars'; 6const PARAMS = { subject: 'did:plc:x' } as const; 7 8interface Page { 9 items: readonly { uri: string }[]; 10 cursor?: string; 11} 12 13const pageResponse = (page: Page): Response => 14 new Response(JSON.stringify(page), { 15 status: 200, 16 headers: { 'content-type': 'application/json' } 17 }); 18 19const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => 20 createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); 21 22const cursorOf = (mock: Mock<typeof globalThis.fetch>, n: number): string | null => 23 new URL(String(mock.mock.calls[n][0])).searchParams.get('cursor'); 24 25const threePageFetch = (): Mock<typeof globalThis.fetch> => 26 vi 27 .fn<typeof globalThis.fetch>() 28 .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }], cursor: 'c1' })) 29 .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'c' }], cursor: 'c2' })) 30 .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'd' }] })); 31 32describe('pages / items / collect follow the cursor', () => { 33 it('pages() yields every page then stops when a page omits the cursor', async () => { 34 const fetchMock = threePageFetch(); 35 const seen: Page[] = []; 36 for await (const p of pages(makeCtx(fetchMock), NAME, PARAMS)) seen.push(p as Page); 37 expect(fetchMock).toHaveBeenCalledTimes(3); 38 expect(seen.map((p) => p.cursor)).toEqual(['c1', 'c2', undefined]); 39 expect(cursorOf(fetchMock, 0)).toBeNull(); 40 expect(cursorOf(fetchMock, 1)).toBe('c1'); 41 expect(cursorOf(fetchMock, 2)).toBe('c2'); 42 }); 43}); 44 45describe('pagination caps', () => { 46 it('maxPages caps the number of network round-trips even with unbounded cursors', async () => { 47 const fetchMock = vi 48 .fn<typeof globalThis.fetch>() 49 .mockImplementation(async () => pageResponse({ items: [{ uri: 'x' }], cursor: 'always' })); 50 const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { maxPages: 2 }); 51 expect(fetchMock).toHaveBeenCalledTimes(2); 52 expect(out).toHaveLength(2); 53 }); 54 55 it('collect({ max }) caps items mid-page without fetching the next page', async () => { 56 const fetchMock = vi 57 .fn<typeof globalThis.fetch>() 58 .mockResolvedValueOnce( 59 pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }, { uri: 'c' }], cursor: 'c1' }) 60 ); 61 const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { max: 2 }); 62 expect(out).toEqual([{ uri: 'a' }, { uri: 'b' }]); 63 expect(fetchMock).toHaveBeenCalledTimes(1); 64 }); 65}); 66 67describe('paginateBy', () => { 68 it('treats cursor: null as the end of the stream', async () => { 69 const load = vi.fn(async () => ({ 70 items: [1, 2] as const, 71 cursor: null 72 })); 73 const out: number[] = []; 74 for await (const n of paginateBy(load)) out.push(n); 75 expect(out).toEqual([1, 2]); 76 expect(load).toHaveBeenCalledTimes(1); 77 }); 78 79 it('follows a string cursor and forwards it to the loader', async () => { 80 const load = vi 81 .fn< 82 ( 83 cursor: string | undefined 84 ) => Promise<{ items: readonly number[]; cursor?: string | null }> 85 >() 86 .mockResolvedValueOnce({ items: [1], cursor: 'p2' }) 87 .mockResolvedValueOnce({ items: [2], cursor: undefined }); 88 const out: number[] = []; 89 for await (const n of paginateBy(load)) out.push(n); 90 expect(out).toEqual([1, 2]); 91 expect(load).toHaveBeenCalledTimes(2); 92 expect(load.mock.calls[0][0]).toBeUndefined(); 93 expect(load.mock.calls[1][0]).toBe('p2'); 94 }); 95 96 it('maxPages caps loader invocations', async () => { 97 const load = vi.fn(async () => ({ 98 items: [0], 99 cursor: 'always' 100 })); 101 const out: number[] = []; 102 for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n); 103 expect(load).toHaveBeenCalledTimes(3); 104 expect(out).toHaveLength(3); 105 }); 106});