1import { describe, expect, it } from "vitest";
2import { ApiError } from "../../common/errors.js";
3import {
4 DEFAULT_PAGE_SIZE,
5 decodeCursor,
6 encodeCursor,
7 paginate,
8 parsePagination
9} from "../pagination.js";
10
11describe("pagination", () => {
12 it("defaults: limit 50, no cursor", () => {
13 const p = parsePagination({});
14 expect(p.limit).toBe(DEFAULT_PAGE_SIZE);
15 expect(p.cursor).toBeUndefined();
16 });
17
18 it("caps limit at 200 and rejects garbage", () => {
19 expect(parsePagination({ limit: "99999" }).limit).toBe(200);
20 expect(parsePagination({ limit: 5 }).limit).toBe(5);
21 expect(() => parsePagination({ limit: "abc" })).toThrowError(ApiError);
22 expect(() => parsePagination({ limit: "0" })).toThrowError(ApiError);
23 expect(() => parsePagination({ limit: "-3" })).toThrowError(ApiError);
24 });
25
26 it("encodes and decodes cursors losslessly", () => {
27 const c = encodeCursor("row-123", "position-xyz");
28 expect(typeof c).toBe("string");
29 expect(decodeCursor(c)).toEqual({ id: "row-123", position: "position-xyz" });
30 });
31
32 it("rejects malformed cursors with VALIDATION_ERROR", () => {
33 expect(() => decodeCursor("%%%not-base64-json")).toThrowError(ApiError);
34 expect(() => decodeCursor(Buffer.from(JSON.stringify({ id: 42 })).toString("base64url"))).toThrowError(
35 ApiError
36 );
37 });
38
39 it("paginate: fetches limit+1, drops the extra, encodes nextCursor from the last kept row", async () => {
40 const rows = Array.from({ length: 8 }, (_, i) => ({
41 id: `r${i}`,
42 position: `p${i}`,
43 n: i
44 }));
45 const query = async (
46 _where: Record<string, unknown>,
47 cursor: { id: string; position: string } | undefined,
48 limit: number
49 ) => {
50
51 const start = cursor ? rows.findIndex((r) => r.position === cursor.position) + 1 : 0;
52 return rows.slice(start, start + limit);
53 };
54 const page1 = await paginate(query, { limit: 5 });
55 expect(page1.data).toHaveLength(5);
56 expect(page1.nextCursor).not.toBeNull();
57 const decoded = decodeCursor(page1.nextCursor!);
58 expect(decoded).toEqual({ id: "r4", position: "p4" });
59
60 const page2 = await paginate(query, { cursor: decoded, limit: 5 });
61 expect(page2.data).toHaveLength(3);
62 expect(page2.nextCursor).toBeNull();
63 });
64
65 it("paginate: exact page boundary → nextCursor null (no extra fetch)", async () => {
66 const rows = Array.from({ length: 5 }, (_, i) => ({ id: `r${i}`, position: `p${i}` }));
67 const query = async (
68 _where: Record<string, unknown>,
69 cursor: { id: string; position: string } | undefined,
70 limit: number
71 ) => {
72 const start = cursor ? rows.findIndex((r) => r.position === cursor.position) + 1 : 0;
73 return rows.slice(start, start + limit);
74 };
75 const page = await paginate(query, { limit: 5 });
76 expect(page.data).toHaveLength(5);
77 expect(page.nextCursor).toBeNull();
78 });
79
80 it("paginate: empty result → empty data, null cursor", async () => {
81 const page = await paginate(async () => [], { limit: 50 });
82 expect(page.data).toEqual([]);
83 expect(page.nextCursor).toBeNull();
84 });
85});
86