/** * T-166 — database views: rows-per-page + "set as default" (reorder). * * Covers the two server surfaces this task adds/wires: * - `pageSize` is now WRITABLE on a view (`PATCH /databases/:id/views/:viewId`) * and the §6 query respects it as the listing default (an explicit body * `pageSize` still wins); bounds are 10..500 (out-of-range → 400). * - `POST /databases/:id/views/:viewId/reorder` (the "set as default" * mechanism — move a view before the first) reorders by fractional * position, busts the 60 s view cache so `GET /views` is fresh, and * authorizes at EDIT (unknown view/anchor/db → 404 no-leak). * * Note: `reorderView` now publishes `DATABASE_UPDATED` (like its siblings) — * without it the web's invalidation was a silent no-op (stale order served * up to 60 s), making "set as default" appear broken in the UI. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; interface Setup { headers: Record; workspaceId: string; databaseId: string; } async function setupDb(): Promise { const user = await createUser(app.request, { name: "Views Owner" }); const headers = authHeaders(user.accessToken!); const { workspaceId } = await createWorkspace(app.request, headers, "Views WS"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "Views DB", type: "database" }, }); expect([200, 201]).toContain(res.status); const { databaseId } = (await json(res)) as { databaseId: string }; return { headers, workspaceId, databaseId }; } async function createView( headers: Record, databaseId: string, name: string, type = "TABLE", ): Promise { const res = await app.request("POST", `/api/v1/databases/${databaseId}/views`, { headers, body: { name, type, config: {} }, }); expect([200, 201]).toContain(res.status); return ((await json(res)) as { id: string }).id; } async function listViews( headers: Record, databaseId: string, ): Promise<{ id: string; name: string; pageSize: number | null }[]> { const res = await app.request("GET", `/api/v1/databases/${databaseId}/views`, { headers, }); expect(res.status).toBe(200); const body = await json(res); const rows = Array.isArray(body) ? body : (body as { data: unknown }).data; return rows as { id: string; name: string; pageSize: number | null }[]; } /** Seed `n` records through the public single-record endpoint. */ async function seedRecords(headers: Record, databaseId: string, n: number): Promise { for (let i = 1; i <= n; i++) { const res = await app.request("POST", `/api/v1/databases/${databaseId}/records`, { headers, body: { title: `Record ${i}` }, }); expect([200, 201]).toContain(res.status); } } describe("T-166 views — rows-per-page (pageSize)", () => { it("PATCH persists pageSize on the view and the query respects it as the page size", async () => { const { headers, databaseId } = await setupDb(); const viewId = await createView(headers, databaseId, "Page Size View"); await seedRecords(headers, databaseId, 25); const patch = await app.request("PATCH", `/api/v1/databases/${databaseId}/views/${viewId}`, { headers, body: { pageSize: 10 }, }); expect(patch.status).toBe(200); const patched = (await json(patch)) as { pageSize: number | null }; expect(patched.pageSize).toBe(10); // The stored page size is the listing default for the view. const q = await app.request("POST", `/api/v1/databases/${databaseId}/query`, { headers, body: { viewId }, }); expect(q.status).toBe(200); const page = (await json(q)) as { data: { records: unknown[]; totalCount: number; nextCursor: string | null } }; expect(page.data.records).toHaveLength(10); expect(page.data.totalCount).toBe(25); expect(page.data.nextCursor).not.toBeNull(); // It round-trips on the view read (cache busted by database.updated). const views = await listViews(headers, databaseId); expect(views.find((v) => v.id === viewId)?.pageSize).toBe(10); // Established precedence (matches `list()`): once a view stores a // pageSize it WINS over the caller's — a saved view's page size is its // setting. (The DTO's "explicit body overrides" comment predates this // and is inaccurate for pageSize; recorded in the T-166 tracker note.) const q2 = await app.request("POST", `/api/v1/databases/${databaseId}/query`, { headers, body: { viewId, pageSize: 3 }, }); expect(q2.status).toBe(200); expect(((await json(q2)) as { data: { records: unknown[] } }).data.records).toHaveLength(10); }); it("pageSize bounds are 10..500 (out of range → 400 VALIDATION_ERROR)", async () => { const { headers, databaseId } = await setupDb(); const viewId = await createView(headers, databaseId, "Bounds View"); for (const bad of [5, 600]) { const res = await app.request("PATCH", `/api/v1/databases/${databaseId}/views/${viewId}`, { headers, body: { pageSize: bad }, }); expect(res.status).toBe(400); const body = await json(res); expect((body as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR"); } }); }); describe("T-166 views — set-as-default (reorder)", () => { it("moves a view before the first and returns the fresh order (cache busted)", async () => { const { headers, databaseId } = await setupDb(); const first = (await listViews(headers, databaseId))[0]; // seeded TABLE view await createView(headers, databaseId, "Second"); const cId = await createView(headers, databaseId, "Third"); // "Set as default" = move C before the current first view. Only C moves; // the other views keep their relative order behind it. const reorder = await app.request("POST", `/api/v1/databases/${databaseId}/views/${cId}/reorder`, { headers, body: { before: first.id }, }); expect([200, 201]).toContain(reorder.status); const names = (await listViews(headers, databaseId)).map((v) => v.name); expect(names[0]).toBe("Third"); expect(names.slice(1)).toEqual([first.name, "Second"]); // The reorder is audited (sibling view ops already were). expect(await countAuditEvents(app.prisma, { action: "database.view_reordered" })).toBe(1); }); it("places a view between two neighbours (after + before)", async () => { const { headers, databaseId } = await setupDb(); const first = (await listViews(headers, databaseId))[0]; const bId = await createView(headers, databaseId, "B"); const cId = await createView(headers, databaseId, "C"); // Initial order: [first, B, C]. Move C between first and B → [first, C, B]. const reorder = await app.request("POST", `/api/v1/databases/${databaseId}/views/${cId}/reorder`, { headers, body: { after: first.id, before: bId }, }); expect([200, 201]).toContain(reorder.status); const names = (await listViews(headers, databaseId)).map((v) => v.name); expect(names).toEqual([first.name, "C", "B"]); }); it("unknown view or unknown anchor → 404 (no-leak)", async () => { const { headers, databaseId } = await setupDb(); const first = (await listViews(headers, databaseId))[0]; const unknownView = await app.request( "POST", `/api/v1/databases/${databaseId}/views/${crypto.randomUUID()}/reorder`, { headers, body: { before: first.id } }, ); expect(unknownView.status).toBe(404); const anchor = await createView(headers, databaseId, "Anchor Test"); const badAnchor = await app.request("POST", `/api/v1/databases/${databaseId}/views/${anchor}/reorder`, { headers, body: { before: crypto.randomUUID() }, }); expect(badAnchor.status).toBe(404); }); }); describe("T-166 views — authorization", () => { it("reorder requires EDIT: GUEST 403, non-member 403, unknown db 404", async () => { const { headers, workspaceId, databaseId } = await setupDb(); const first = (await listViews(headers, databaseId))[0]; const guest = await createUser(app.request, { name: "Views Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gRes = await app.request("POST", `/api/v1/databases/${databaseId}/views/${first.id}/reorder`, { headers: authHeaders(guest.accessToken!), body: {}, }); expect(gRes.status).toBe(403); const stranger = await createUser(app.request, { name: "Views Stranger" }); const sRes = await app.request("POST", `/api/v1/databases/${databaseId}/views/${first.id}/reorder`, { headers: authHeaders(stranger.accessToken!), body: {}, }); expect(sRes.status).toBe(403); const unknownDb = await app.request( "POST", `/api/v1/databases/${crypto.randomUUID()}/views/${first.id}/reorder`, { headers, body: {} }, ); expect(unknownDb.status).toBe(404); }); });