1
10import { afterAll, beforeAll, describe, expect, it } from "vitest";
11import { createTestApp, type TestAppHandle } from "./helpers/api";
12import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
13
14let app: TestAppHandle;
15
16beforeAll(async () => {
17 app = await createTestApp();
18}, 60_000);
19afterAll(async () => {
20 await app?.close();
21});
22
23async function mkPage(wsId: string, headers: Record<string, string>, title = "Recent Page"): Promise<string> {
24 const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } });
25 expect(res.status).toBe(201);
26 return ((await res.json()) as { id: string }).id;
27}
28
29describe("T-153/T-154: recent items + saved searches", () => {
30 it("records a page view, dedupes re-views, lists newest-first", async () => {
31 const owner = await createUser(app.request, { name: "Recent Owner" });
32 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Recent WS");
33 const h = authHeaders(owner.accessToken!);
34 const p1 = await mkPage(ws.workspaceId, h, "First");
35 const p2 = await mkPage(ws.workspaceId, h, "Second");
36
37 const put1 = await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p1 } });
38 expect(put1.status).toBe(200);
39 await new Promise((r) => setTimeout(r, 5));
40 const put2 = await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p2 } });
41 expect(put2.status).toBe(200);
42
43 const list = await app.request("GET", "/api/v1/users/me/recent", { headers: h });
44 expect(list.status).toBe(200);
45 const body = (await list.json()) as { data: { itemType: string; refId: string }[] };
46 expect(body.data).toHaveLength(2);
47 expect(body.data[0]?.refId).toBe(p2);
48 expect(body.data[1]?.refId).toBe(p1);
49
50
51 await new Promise((r) => setTimeout(r, 5));
52 await app.request("PUT", "/api/v1/users/me/recent", { headers: h, body: { itemType: "page", refId: p1 } });
53 const list2 = await app.request("GET", "/api/v1/users/me/recent", { headers: h });
54 const body2 = (await list2.json()) as { data: { refId: string }[] };
55 expect(body2.data).toHaveLength(2);
56 expect(body2.data[0]?.refId).toBe(p1);
57 });
58
59 it("no-leak: non-member page id and unknown id → 404", async () => {
60 const owner = await createUser(app.request, { name: "Leak Owner" });
61 const outsider = await createUser(app.request, { name: "Leak Outsider" });
62 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Leak WS");
63 const pageId = await mkPage(ws.workspaceId, authHeaders(owner.accessToken!), "Hidden");
64
65 const foreign = await app.request("PUT", "/api/v1/users/me/recent", {
66 headers: authHeaders(outsider.accessToken!),
67 body: { itemType: "page", refId: pageId }
68 });
69 expect(foreign.status).toBe(404);
70 const unknown = await app.request("PUT", "/api/v1/users/me/recent", {
71 headers: authHeaders(outsider.accessToken!),
72 body: { itemType: "page", refId: "does-not-exist" }
73 });
74 expect(unknown.status).toBe(404);
75
76 const list = await app.request("GET", "/api/v1/users/me/recent", { headers: authHeaders(outsider.accessToken!) });
77 expect(((await list.json()) as { data: unknown[] }).data).toHaveLength(0);
78 });
79
80 it("saved searches: per-user CRUD, cross-user id → 404", async () => {
81 const a = await createUser(app.request, { name: "SS A" });
82 const b = await createUser(app.request, { name: "SS B" });
83 const ha = authHeaders(a.accessToken!);
84 const hb = authHeaders(b.accessToken!);
85
86 const create = await app.request("POST", "/api/v1/users/me/saved-searches", {
87 headers: ha,
88 body: { name: "My bugs", query: { q: "bug", types: ["page", "block"] } }
89 });
90 expect(create.status).toBe(201);
91 const saved = (await create.json()) as { id: string; name: string; queryJson: { q: string } };
92 expect(saved.name).toBe("My bugs");
93 expect(saved.queryJson.q).toBe("bug");
94
95
96 const listA = (await (await app.request("GET", "/api/v1/users/me/saved-searches", { headers: ha })).json()) as { data: unknown[] };
97 expect(listA.data).toHaveLength(1);
98 const upd = await app.request("PATCH", `/api/v1/users/me/saved-searches/${saved.id}`, {
99 headers: ha,
100 body: { name: "My bugs v2", query: { q: "regression" } }
101 });
102 expect(upd.status).toBe(200);
103 expect(((await upd.json()) as { name: string; queryJson: { q: string } }).queryJson.q).toBe("regression");
104
105
106 for (const [method, path, opts] of [
107 ["GET", `/api/v1/users/me/saved-searches/${saved.id}`, undefined],
108 ["PATCH", `/api/v1/users/me/saved-searches/${saved.id}`, { body: { name: "hax" } }],
109 ["DELETE", `/api/v1/users/me/saved-searches/${saved.id}`, undefined]
110 ] as const) {
111 const res = await app.request(method, path, { headers: hb, ...(opts as object | undefined) });
112 expect(res.status).toBe(404);
113 }
114 const listB = (await (await app.request("GET", "/api/v1/users/me/saved-searches", { headers: hb })).json()) as { data: unknown[] };
115 expect(listB.data).toHaveLength(0);
116
117
118 const del = await app.request("DELETE", `/api/v1/users/me/saved-searches/${saved.id}`, { headers: ha });
119 expect(del.status).toBe(200);
120 });
121
122 it("saved search validation: bad query shape → 400", async () => {
123 const a = await createUser(app.request, { name: "SS Valid" });
124 const bad = await app.request("POST", "/api/v1/users/me/saved-searches", {
125 headers: authHeaders(a.accessToken!),
126 body: { name: "bad", query: { q: "" } }
127 });
128 expect(bad.status).toBe(400);
129 const badType = await app.request("POST", "/api/v1/users/me/saved-searches", {
130 headers: authHeaders(a.accessToken!),
131 body: { name: "bad", query: { q: "x", types: ["not-a-type"] } }
132 });
133 expect(badType.status).toBe(400);
134 });
135});
136