/** * T-153/T-154 — Recently-viewed items + saved searches (per-user). * * - PUT /users/me/recent upserts + bumps (re-viewing the same page does * not duplicate); the list is capped at 50, newest first * - a "page" recent ref must be a page the caller can VIEW (non-member / * unknown id → 404 no-leak) * - saved searches: CRUD strictly per-user — another user's id → 404 */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 60_000); afterAll(async () => { await app?.close(); }); async function mkPage(wsId: string, headers: Record, title = "Recent Page"): Promise { const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } }); expect(res.status).toBe(201); return ((await res.json()) as { id: string }).id; } describe("T-153/T-154: recent items + saved searches", () => { it("records a page view, dedupes re-views, lists newest-first", async () => { const owner = await createUser(app.request, { name: "Recent Owner" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Recent WS"); const h = authHeaders(owner.accessToken!); const p1 = await mkPage(ws.workspaceId, h, "First"); const p2 = await mkPage(ws.workspaceId, h, "Second"); const put1 = await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p1 } }); expect(put1.status).toBe(200); await new Promise((r) => setTimeout(r, 5)); // distinct timestamps const put2 = await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p2 } }); expect(put2.status).toBe(200); const list = await app.request("GET", "/api/v1/users/me/recent", { headers: h }); expect(list.status).toBe(200); const body = (await list.json()) as { data: { itemType: string; refId: string }[] }; expect(body.data).toHaveLength(2); expect(body.data[0]?.refId).toBe(p2); // newest first expect(body.data[1]?.refId).toBe(p1); // Re-view p1: no duplicate, it moves to the front await new Promise((r) => setTimeout(r, 5)); await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p1 } }); const list2 = await app.request("GET", "/api/v1/users/me/recent", { headers: h }); const body2 = (await list2.json()) as { data: { refId: string }[] }; expect(body2.data).toHaveLength(2); expect(body2.data[0]?.refId).toBe(p1); }); it("no-leak: non-member page id and unknown id → 404", async () => { const owner = await createUser(app.request, { name: "Leak Owner" }); const outsider = await createUser(app.request, { name: "Leak Outsider" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Leak WS"); const pageId = await mkPage(ws.workspaceId, authHeaders(owner.accessToken!), "Hidden"); const foreign = await app.request("PUT", "/api/v1/users/me/recent", { headers: authHeaders(outsider.accessToken!), body: { itemType: "page", refId: pageId } }); expect(foreign.status).toBe(404); const unknown = await app.request("PUT", "/api/v1/users/me/recent", { headers: authHeaders(outsider.accessToken!), body: { itemType: "page", refId: "does-not-exist" } }); expect(unknown.status).toBe(404); // nothing was recorded for the outsider const list = await app.request("GET", "/api/v1/users/me/recent", { headers: authHeaders(outsider.accessToken!) }); expect(((await list.json()) as { data: unknown[] }).data).toHaveLength(0); }); it("saved searches: per-user CRUD, cross-user id → 404", async () => { const a = await createUser(app.request, { name: "SS A" }); const b = await createUser(app.request, { name: "SS B" }); const ha = authHeaders(a.accessToken!); const hb = authHeaders(b.accessToken!); const create = await app.request("POST", "/api/v1/users/me/saved-searches", { headers: ha, body: { name: "My bugs", query: { q: "bug", types: ["page", "block"] } } }); expect(create.status).toBe(201); const saved = (await create.json()) as { id: string; name: string; queryJson: { q: string } }; expect(saved.name).toBe("My bugs"); expect(saved.queryJson.q).toBe("bug"); // owner can list/get/update const listA = (await (await app.request("GET", "/api/v1/users/me/saved-searches", { headers: ha })).json()) as { data: unknown[] }; expect(listA.data).toHaveLength(1); const upd = await app.request("PATCH", `/api/v1/users/me/saved-searches/${saved.id}`, { headers: ha, body: { name: "My bugs v2", query: { q: "regression" } } }); expect(upd.status).toBe(200); expect(((await upd.json()) as { name: string; queryJson: { q: string } }).queryJson.q).toBe("regression"); // user B: cross-user id → 404 on get/update/delete; B's list is empty for (const [method, path, opts] of [ ["GET", `/api/v1/users/me/saved-searches/${saved.id}`, undefined], ["PATCH", `/api/v1/users/me/saved-searches/${saved.id}`, { body: { name: "hax" } }], ["DELETE", `/api/v1/users/me/saved-searches/${saved.id}`, undefined] ] as const) { const res = await app.request(method, path, { headers: hb, ...(opts as object | undefined) }); expect(res.status).toBe(404); } const listB = (await (await app.request("GET", "/api/v1/users/me/saved-searches", { headers: hb })).json()) as { data: unknown[] }; expect(listB.data).toHaveLength(0); // delete by owner works const del = await app.request("DELETE", `/api/v1/users/me/saved-searches/${saved.id}`, { headers: ha }); expect(del.status).toBe(200); }); it("saved search validation: bad query shape → 400", async () => { const a = await createUser(app.request, { name: "SS Valid" }); const bad = await app.request("POST", "/api/v1/users/me/saved-searches", { headers: authHeaders(a.accessToken!), body: { name: "bad", query: { q: "" } } // empty q }); expect(bad.status).toBe(400); const badType = await app.request("POST", "/api/v1/users/me/saved-searches", { headers: authHeaders(a.accessToken!), body: { name: "bad", query: { q: "x", types: ["not-a-type"] } } }); expect(badType.status).toBe(400); }); });