~james/core

core/web/src/lib/api/pagination.test.ts -rw-r--r-- 4.0 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { describe, expect, it, vi, type Mock } from "vitest";
import { createBobbinClient, type BobbinContext } from "#lib/api/client.ts";
import { collect, pages, paginateBy } from "#lib/api/pagination.ts";
import type { Fetch } from "#lib/fetch.ts";

const NAME = "org.tangled.feed.listStars";
const PARAMS = { subject: "did:plc:x" } as const;

interface Page {
	items: readonly { uri: string }[];
	cursor?: string;
}

const pageResponse = (page: Page): Response =>
	new Response(JSON.stringify(page), {
		status: 200,
		headers: { "content-type": "application/json" }
	});

const makeCtx = (fetchMock: Fetch): BobbinContext =>
	createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });

const cursorOf = (mock: Mock<Fetch>, n: number): string | null =>
	new URL(String(mock.mock.calls[n][0])).searchParams.get("cursor");

const threePageFetch = (): Mock<Fetch> =>
	vi
		.fn<Fetch>()
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: "a" }, { uri: "b" }], cursor: "c1" }))
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: "c" }], cursor: "c2" }))
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: "d" }] }));

describe("pages / items / collect follow the cursor", () => {
	it("pages() yields every page then stops when a page omits the cursor", async () => {
		const fetchMock = threePageFetch();
		const seen: Page[] = [];
		for await (const p of pages(makeCtx(fetchMock), NAME, PARAMS)) seen.push(p as Page);
		expect(fetchMock).toHaveBeenCalledTimes(3);
		expect(seen.map((p) => p.cursor)).toEqual(["c1", "c2", undefined]);
		expect(cursorOf(fetchMock, 0)).toBeNull();
		expect(cursorOf(fetchMock, 1)).toBe("c1");
		expect(cursorOf(fetchMock, 2)).toBe("c2");
	});
});

describe("pagination caps", () => {
	it("maxPages caps the number of network round-trips even with unbounded cursors", async () => {
		const fetchMock = vi
			.fn<Fetch>()
			.mockImplementation(async () =>
				pageResponse({ items: [{ uri: "x" }], cursor: "always" })
			);
		const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { maxPages: 2 });
		expect(fetchMock).toHaveBeenCalledTimes(2);
		expect(out).toHaveLength(2);
	});

	it("collect({ max }) caps items mid-page without fetching the next page", async () => {
		const fetchMock = vi
			.fn<Fetch>()
			.mockResolvedValueOnce(
				pageResponse({ items: [{ uri: "a" }, { uri: "b" }, { uri: "c" }], cursor: "c1" })
			);
		const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { max: 2 });
		expect(out).toEqual([{ uri: "a" }, { uri: "b" }]);
		expect(fetchMock).toHaveBeenCalledTimes(1);
	});
});

describe("paginateBy", () => {
	it("treats cursor: null as the end of the stream", async () => {
		const load = vi.fn(async () => ({
			items: [1, 2] as const,
			cursor: null
		}));
		const out: number[] = [];
		for await (const n of paginateBy(load)) out.push(n);
		expect(out).toEqual([1, 2]);
		expect(load).toHaveBeenCalledTimes(1);
	});

	it("follows a string cursor and forwards it to the loader", async () => {
		const load = vi
			.fn<
				(
					cursor: string | undefined
				) => Promise<{ items: readonly number[]; cursor?: string | null }>
			>()
			.mockResolvedValueOnce({ items: [1], cursor: "p2" })
			.mockResolvedValueOnce({ items: [2], cursor: undefined });
		const out: number[] = [];
		for await (const n of paginateBy(load)) out.push(n);
		expect(out).toEqual([1, 2]);
		expect(load).toHaveBeenCalledTimes(2);
		expect(load.mock.calls[0][0]).toBeUndefined();
		expect(load.mock.calls[1][0]).toBe("p2");
	});

	it("maxPages caps loader invocations", async () => {
		let page = 0;
		const load = vi.fn(async () => {
			page += 1;
			return { items: [0], cursor: `p${page}` };
		});
		const out: number[] = [];
		for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n);
		expect(load).toHaveBeenCalledTimes(3);
		expect(out).toHaveLength(3);
	});

	it("stops when the cursor stops advancing", async () => {
		const load = vi.fn(async () => ({
			items: [0],
			cursor: "always"
		}));
		const out: number[] = [];
		for await (const n of paginateBy(load)) out.push(n);
		expect(load).toHaveBeenCalledTimes(2);
	});
});