1
17import { afterAll, beforeAll, describe, expect, it } from "vitest";
18import { createTestApp, type TestAppHandle } from "./helpers/api";
19import {
20 addMemberViaSeam,
21 authHeaders,
22 countAuditEvents,
23 createWorkspace,
24 createUser
25} from "./helpers/seed";
26
27let app: TestAppHandle;
28
29beforeAll(async () => {
30 app = await createTestApp();
31}, 180_000);
32afterAll(async () => {
33 await app?.close();
34});
35
36const json = async (res: Response): Promise<Record<string, unknown>> =>
37 (await res.json().catch(() => ({}))) as Record<string, unknown>;
38
39interface Setup {
40 headers: Record<string, string>;
41 workspaceId: string;
42 databaseId: string;
43}
44
45async function setupDb(): Promise<Setup> {
46 const user = await createUser(app.request, { name: "Views Owner" });
47 const headers = authHeaders(user.accessToken!);
48 const { workspaceId } = await createWorkspace(app.request, headers, "Views WS");
49 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
50 headers,
51 body: { title: "Views DB", type: "database" },
52 });
53 expect([200, 201]).toContain(res.status);
54 const { databaseId } = (await json(res)) as { databaseId: string };
55 return { headers, workspaceId, databaseId };
56}
57
58async function createView(
59 headers: Record<string, string>,
60 databaseId: string,
61 name: string,
62 type = "TABLE",
63): Promise<string> {
64 const res = await app.request("POST", `/api/v1/databases/${databaseId}/views`, {
65 headers,
66 body: { name, type, config: {} },
67 });
68 expect([200, 201]).toContain(res.status);
69 return ((await json(res)) as { id: string }).id;
70}
71
72async function listViews(
73 headers: Record<string, string>,
74 databaseId: string,
75): Promise<{ id: string; name: string; pageSize: number | null }[]> {
76 const res = await app.request("GET", `/api/v1/databases/${databaseId}/views`, {
77 headers,
78 });
79 expect(res.status).toBe(200);
80 const body = await json(res);
81 const rows = Array.isArray(body) ? body : (body as { data: unknown }).data;
82 return rows as { id: string; name: string; pageSize: number | null }[];
83}
84
85
86async function seedRecords(headers: Record<string, string>, databaseId: string, n: number): Promise<void> {
87 for (let i = 1; i <= n; i++) {
88 const res = await app.request("POST", `/api/v1/databases/${databaseId}/records`, {
89 headers,
90 body: { title: `Record ${i}` },
91 });
92 expect([200, 201]).toContain(res.status);
93 }
94}
95
96describe("T-166 views — rows-per-page (pageSize)", () => {
97 it("PATCH persists pageSize on the view and the query respects it as the page size", async () => {
98 const { headers, databaseId } = await setupDb();
99 const viewId = await createView(headers, databaseId, "Page Size View");
100 await seedRecords(headers, databaseId, 25);
101
102 const patch = await app.request("PATCH", `/api/v1/databases/${databaseId}/views/${viewId}`, {
103 headers,
104 body: { pageSize: 10 },
105 });
106 expect(patch.status).toBe(200);
107 const patched = (await json(patch)) as { pageSize: number | null };
108 expect(patched.pageSize).toBe(10);
109
110
111 const q = await app.request("POST", `/api/v1/databases/${databaseId}/query`, {
112 headers,
113 body: { viewId },
114 });
115 expect(q.status).toBe(200);
116 const page = (await json(q)) as { data: { records: unknown[]; totalCount: number; nextCursor: string | null } };
117 expect(page.data.records).toHaveLength(10);
118 expect(page.data.totalCount).toBe(25);
119 expect(page.data.nextCursor).not.toBeNull();
120
121
122 const views = await listViews(headers, databaseId);
123 expect(views.find((v) => v.id === viewId)?.pageSize).toBe(10);
124
125
126
127
128
129 const q2 = await app.request("POST", `/api/v1/databases/${databaseId}/query`, {
130 headers,
131 body: { viewId, pageSize: 3 },
132 });
133 expect(q2.status).toBe(200);
134 expect(((await json(q2)) as { data: { records: unknown[] } }).data.records).toHaveLength(10);
135 });
136
137 it("pageSize bounds are 10..500 (out of range → 400 VALIDATION_ERROR)", async () => {
138 const { headers, databaseId } = await setupDb();
139 const viewId = await createView(headers, databaseId, "Bounds View");
140 for (const bad of [5, 600]) {
141 const res = await app.request("PATCH", `/api/v1/databases/${databaseId}/views/${viewId}`, {
142 headers,
143 body: { pageSize: bad },
144 });
145 expect(res.status).toBe(400);
146 const body = await json(res);
147 expect((body as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR");
148 }
149 });
150});
151
152describe("T-166 views — set-as-default (reorder)", () => {
153 it("moves a view before the first and returns the fresh order (cache busted)", async () => {
154 const { headers, databaseId } = await setupDb();
155 const first = (await listViews(headers, databaseId))[0];
156 await createView(headers, databaseId, "Second");
157 const cId = await createView(headers, databaseId, "Third");
158
159
160
161 const reorder = await app.request("POST", `/api/v1/databases/${databaseId}/views/${cId}/reorder`, {
162 headers,
163 body: { before: first.id },
164 });
165 expect([200, 201]).toContain(reorder.status);
166
167 const names = (await listViews(headers, databaseId)).map((v) => v.name);
168 expect(names[0]).toBe("Third");
169 expect(names.slice(1)).toEqual([first.name, "Second"]);
170
171
172 expect(await countAuditEvents(app.prisma, { action: "database.view_reordered" })).toBe(1);
173 });
174
175 it("places a view between two neighbours (after + before)", async () => {
176 const { headers, databaseId } = await setupDb();
177 const first = (await listViews(headers, databaseId))[0];
178 const bId = await createView(headers, databaseId, "B");
179 const cId = await createView(headers, databaseId, "C");
180
181 const reorder = await app.request("POST", `/api/v1/databases/${databaseId}/views/${cId}/reorder`, {
182 headers,
183 body: { after: first.id, before: bId },
184 });
185 expect([200, 201]).toContain(reorder.status);
186 const names = (await listViews(headers, databaseId)).map((v) => v.name);
187 expect(names).toEqual([first.name, "C", "B"]);
188 });
189
190 it("unknown view or unknown anchor → 404 (no-leak)", async () => {
191 const { headers, databaseId } = await setupDb();
192 const first = (await listViews(headers, databaseId))[0];
193
194 const unknownView = await app.request(
195 "POST",
196 `/api/v1/databases/${databaseId}/views/${crypto.randomUUID()}/reorder`,
197 { headers, body: { before: first.id } },
198 );
199 expect(unknownView.status).toBe(404);
200
201 const anchor = await createView(headers, databaseId, "Anchor Test");
202 const badAnchor = await app.request("POST", `/api/v1/databases/${databaseId}/views/${anchor}/reorder`, {
203 headers,
204 body: { before: crypto.randomUUID() },
205 });
206 expect(badAnchor.status).toBe(404);
207 });
208});
209
210describe("T-166 views — authorization", () => {
211 it("reorder requires EDIT: GUEST 403, non-member 403, unknown db 404", async () => {
212 const { headers, workspaceId, databaseId } = await setupDb();
213 const first = (await listViews(headers, databaseId))[0];
214
215 const guest = await createUser(app.request, { name: "Views Guest" });
216 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
217 const gRes = await app.request("POST", `/api/v1/databases/${databaseId}/views/${first.id}/reorder`, {
218 headers: authHeaders(guest.accessToken!),
219 body: {},
220 });
221 expect(gRes.status).toBe(403);
222
223 const stranger = await createUser(app.request, { name: "Views Stranger" });
224 const sRes = await app.request("POST", `/api/v1/databases/${databaseId}/views/${first.id}/reorder`, {
225 headers: authHeaders(stranger.accessToken!),
226 body: {},
227 });
228 expect(sRes.status).toBe(403);
229
230 const unknownDb = await app.request(
231 "POST",
232 `/api/v1/databases/${crypto.randomUUID()}/views/${first.id}/reorder`,
233 { headers, body: {} },
234 );
235 expect(unknownDb.status).toBe(404);
236 });
237});
238