1import { describe, expect, it } from "vitest";
2
3import { runRecordQuery } from "./index.js";
4import { encodeCursor, decodeCursor, paginateRows } from "./paginate.js";
5import { sortRecords } from "./sort.js";
6import { groupRecords, EMPTY_KEY } from "./group.js";
7import { pivotValues, assembleRows } from "./pivot.js";
8import { propertiesMap } from "../filter/index.js";
9import type { RecordRow, PropertyInfo, PropertyValue } from "../values/types.js";
10
11const props: PropertyInfo[] = [
12 { id: "p-title", name: "Title", type: "title", config: {} },
13 { id: "p-num", name: "Estimate", type: "number", config: {} },
14 { id: "p-date", name: "Due", type: "date", config: {} },
15 {
16 id: "p-status",
17 name: "Status",
18 type: "select",
19 config: {
20 options: [
21 { id: "todo", name: "To Do", color: "gray" },
22 { id: "done", name: "Done", color: "green" },
23 ],
24 },
25 },
26];
27const map = propertiesMap(props);
28
29const row = (
30 id: string,
31 position: string,
32 values: Record<string, PropertyValue>,
33 archivedAt?: number,
34): RecordRow => ({
35 id,
36 pageId: `pg-${id}`,
37 position,
38 createdTime: 1,
39 lastEditedTime: 1,
40 values,
41 ...(archivedAt ? { archivedAt } : {}),
42});
43
44const rows: RecordRow[] = [
45 row("r1", "a0", { "p-title": "One", "p-num": 5, "p-status": "todo", "p-date": "2025-06-01" }),
46 row("r2", "a1", { "p-title": "Two", "p-num": 15, "p-status": "done", "p-date": "2025-06-02" }),
47 row("r3", "a2", { "p-title": "Three", "p-num": 10, "p-status": "todo", "p-date": "2025-06-03" }),
48 row("r4", "a3", { "p-title": "Four", "p-status": "done", "p-date": "2025-06-04" }),
49 row("r5", "a4", { "p-title": "Archived", "p-num": 99 }, 123),
50];
51
52describe("sort", () => {
53 it("multi-key stable with (position, id) tiebreak", () => {
54 const sorted = sortRecords(
55 [...rows],
56 [
57 { propertyId: "p-status", direction: "asc" },
58 { propertyId: "p-num", direction: "desc" },
59 ],
60 map,
61 );
62
63 expect(sorted.map((r) => r.id)).toEqual(["r3", "r1", "r2", "r4", "r5"]);
64 });
65
66 it("nulls last for asc, first for desc (nullOrder)", () => {
67 const asc = sortRecords([...rows], [{ propertyId: "p-num", direction: "asc" }], map);
68 expect(asc[asc.length - 1]!.id).toBe("r4");
69 const desc = sortRecords(
70 [...rows],
71 [{ propertyId: "p-num", direction: "desc", nullOrder: "first" }],
72 map,
73 );
74 expect(desc[0]!.id).toBe("r4");
75 });
76
77 it("rejects >5 keys and duplicates", () => {
78 expect(() =>
79 sortRecords(
80 rows,
81 [
82 { propertyId: "p-num", direction: "asc" },
83 { propertyId: "p-num", direction: "asc" },
84 ],
85 map,
86 ),
87 ).toThrow(/duplicate/);
88 });
89});
90
91describe("group", () => {
92 it("select grouping: option order + empty last", () => {
93 const groups = groupRecords(rows, [{ propertyId: "p-status", direction: "asc" }], map);
94 expect(groups.map((g) => [g.key, g.count])).toEqual([
95 ["todo", 2],
96 ["done", 2],
97 [EMPTY_KEY, 1],
98 ]);
99 });
100
101 it("date grouping by day bucket + direction", () => {
102 const groups = groupRecords(rows, [{ propertyId: "p-date", direction: "desc" }], map);
103 expect(groups[0]!.key).toBe("2025-06-04");
104 expect(groups[groups.length - 1]!.key).toBe(EMPTY_KEY);
105 });
106
107 it("nested 2-level groups", () => {
108 const groups = groupRecords(
109 rows,
110 [
111 { propertyId: "p-status", direction: "asc" },
112 { propertyId: "p-num", direction: "asc" },
113 ],
114 map,
115 );
116 const todo = groups.find((g) => g.key === "todo")!;
117 expect(todo.children).toBeDefined();
118 expect(todo.children!.length).toBeGreaterThanOrEqual(2);
119 });
120});
121
122describe("paginate (opaque keyset cursor)", () => {
123 it("round-trips the cursor and pages", () => {
124 const page1 = paginateRows(rows, undefined, 2);
125 expect(page1.items.map((r) => r.id)).toEqual(["r1", "r2"]);
126 expect(page1.cursor).not.toBeNull();
127 const page2 = paginateRows(rows, page1.cursor!, 2);
128 expect(page2.items.map((r) => r.id)).toEqual(["r3", "r4"]);
129 const page3 = paginateRows(rows, page2.cursor!, 2);
130 expect(page3.items.map((r) => r.id)).toEqual(["r5"]);
131 expect(page3.cursor).toBeNull();
132 });
133
134 it("cursor decode is safe against garbage", () => {
135 expect(decodeCursor("!!!not-base64!!!")).toBeNull();
136 expect(decodeCursor(Buffer.from("nope").toString("base64url"))).toBeNull();
137 const c = encodeCursor({ position: "a0", id: "r1" });
138 expect(decodeCursor(c)).toEqual({ position: "a0", id: "r1" });
139 });
140});
141
142describe("runRecordQuery pipeline", () => {
143 it("excludes archived by default, includes with flag", () => {
144 const base = runRecordQuery({ records: rows, properties: props, pageSize: 100 });
145 expect(base.total).toBe(4);
146 const all = runRecordQuery({
147 records: rows,
148 properties: props,
149 pageSize: 100,
150 includeArchived: true,
151 });
152 expect(all.total).toBe(5);
153 });
154
155 it("filter → sort → paginate in one pass", () => {
156 const res = runRecordQuery({
157 records: rows,
158 properties: props,
159 filter: { op: "and", conditions: [{ propertyId: "p-status", operator: "=", value: "todo" }] },
160 sort: [{ propertyId: "p-num", direction: "asc" }],
161 pageSize: 100,
162 });
163 expect(res.records.map((r) => r.id)).toEqual(["r1", "r3"]);
164 });
165
166 it("grouped query returns groups + full record set", () => {
167 const res = runRecordQuery({
168 records: rows,
169 properties: props,
170 group: [{ propertyId: "p-status", direction: "asc" }],
171 pageSize: 100,
172 });
173 expect(res.groups).not.toBeNull();
174
175
176
177
178 expect(res.groups!.length).toBe(2);
179 expect(res.total).toBe(4);
180 });
181
182 it("visiblePropertyIds masks values", () => {
183 const res = runRecordQuery({
184 records: rows,
185 properties: props,
186 pageSize: 100,
187 visiblePropertyIds: ["p-title"],
188 });
189 expect(Object.keys(res.records[0]!.values)).toEqual(["p-title"]);
190 });
191});
192
193describe("EAV pivot", () => {
194 it("pivots flat rows into record→property→value (codec-normalized)", () => {
195 const piv = pivotValues(
196 [
197 { recordId: "r1", propertyId: "p-num", value: "7" },
198 { recordId: "r1", propertyId: "p-status", value: "done" },
199 { recordId: "r2", propertyId: "p-num", value: 3 },
200 ],
201 map,
202 );
203 expect(piv.get("r1")!["p-num"]).toBe(7);
204 expect(piv.get("r1")!["p-status"]).toBe("done");
205 expect(piv.get("r2")!["p-num"]).toBe(3);
206 });
207
208 it("assembles rows with title from the title property", () => {
209 const piv = pivotValues([{ recordId: "r1", propertyId: "p-title", value: "Hello" }], map);
210 const out = assembleRows(
211 [
212 {
213 id: "r1",
214 pageId: "pg",
215 position: "a0",
216 createdTime: 1,
217 lastEditedTime: 1,
218 archivedAt: null,
219 },
220 ],
221 piv,
222 "p-title",
223 );
224 expect(out[0]!.title).toBe("Hello");
225 });
226});
227