This repository has no description
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});