1
17import { afterAll, beforeAll, describe, expect, it } from "vitest";
18import { createTestApp, type TestAppHandle } from "./helpers/api";
19import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
20
21let app: TestAppHandle;
22const json = async (res: Response): Promise<Record<string, unknown>> =>
23 (await res.json().catch(() => ({}))) as Record<string, unknown>;
24
25interface TaskRow {
26 id: string;
27 pageId: string;
28 title: string;
29 status: string;
30 dueAt: string | null;
31 assigneeIds: string[];
32 pageTitle?: string;
33}
34
35beforeAll(async () => {
36 app = await createTestApp();
37}, 180_000);
38afterAll(async () => {
39 await app?.close();
40});
41
42describe("T-159…T-161: tasks (page-backed)", () => {
43 it("create: EDIT-gated, seeds kind=task page + title block; non-member assignee → 400", async () => {
44 const owner = await createUser(app.request, { name: "Task Owner" });
45 const h = authHeaders(owner.accessToken!);
46 const ws = await createWorkspace(app.request, h, "Task WS");
47
48
49 const outsider = await createUser(app.request, { name: "Task Outsider" });
50 const bad = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, {
51 headers: h,
52 body: { title: "T", assigneeIds: [outsider.userId] }
53 });
54 expect(bad.status).toBe(400);
55
56
57 const res = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, {
58 headers: h,
59 body: { title: "Ship the report", status: "todo", dueAt: new Date(Date.now() + 86_400_000).toISOString() }
60 });
61 expect(res.status).toBe(201);
62 const body = (await json(res)) as { id: string; pageId: string; title: string; status: string; dueAt: string };
63 expect(body.title).toBe("Ship the report");
64 expect(body.status).toBe("todo");
65
66 const page = await app.prisma.page.findUnique({ where: { id: body.pageId }, select: { kind: true, title: true, workspaceId: true } });
67 expect(page?.kind).toBe("task");
68 expect(page?.title).toBe("Ship the report");
69 const titleBlock = await app.prisma.block.findFirst({
70 where: { pageId: body.pageId, properties: { path: ["isTitle"], equals: true } },
71 select: { type: true, content: true }
72 });
73 expect(titleBlock?.type).toBe("PARAGRAPH");
74 expect((titleBlock?.content as { text: string }).text).toBe("Ship the report");
75 });
76
77 it("inbox: the caller's assigned tasks; a REVOKED page grant never leaks", async () => {
78 const owner = await createUser(app.request, { name: "Inbox Owner" });
79 const member = await createUser(app.request, { name: "Inbox Member" });
80 const h = authHeaders(owner.accessToken!);
81 const hm = authHeaders(member.accessToken!);
82 const ws = await createWorkspace(app.request, h, "Inbox WS");
83 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
84
85
86 const res = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, {
87 headers: h,
88 body: { title: "Member task", assigneeIds: [member.userId] }
89 });
90 const task = (await json(res)) as TaskRow;
91 const inbox = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: hm }))) as { data: TaskRow[] };
92 expect(inbox.data.some((t) => t.id === task.id)).toBe(true);
93
94 const ownerInbox = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: h }))) as { data: TaskRow[] };
95 expect(ownerInbox.data.some((t) => t.id === task.id)).toBe(false);
96
97
98 const notifs = await app.prisma.notification.findMany({
99 where: { workspaceId: ws.workspaceId, type: "TASK_ASSIGNED" },
100 select: { userId: true }
101 });
102 expect(notifs.some((n) => n.userId === member.userId)).toBe(true);
103 expect(notifs.some((n) => n.userId === owner.userId)).toBe(false);
104
105
106
107
108 await app.prisma.permission.create({
109 data: {
110 workspaceId: ws.workspaceId,
111 granteeType: "user",
112 granteeId: member.userId,
113 resourceType: "page",
114 resourceId: task.pageId,
115 level: "NONE"
116 }
117 });
118 const inbox2 = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: hm }))) as { data: TaskRow[] };
119 expect(inbox2.data.some((t) => t.id === task.id)).toBe(false);
120 const denied = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: hm });
121 expect(denied.status).toBe(403);
122 });
123
124 it("update: title mirrors to the page; illegal transition → 400; non-member → 403", async () => {
125 const owner = await createUser(app.request, { name: "Upd Owner" });
126 const member = await createUser(app.request, { name: "Upd Member" });
127 const h = authHeaders(owner.accessToken!);
128 const ws = await createWorkspace(app.request, h, "Upd WS");
129 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
130 const task = (await json(
131 await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, {
132 headers: h,
133 body: { title: "Original", assigneeIds: [member.userId] }
134 })
135 )) as TaskRow;
136
137
138 const upd = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, {
139 headers: h,
140 body: { title: "Renamed", status: "in_progress" }
141 });
142 expect(upd.status).toBe(200);
143 const body = (await json(upd)) as TaskRow;
144 expect(body.status).toBe("in_progress");
145 expect(body.title).toBe("Renamed");
146 const page = await app.prisma.page.findUnique({ where: { id: task.pageId }, select: { title: true } });
147 expect(page?.title).toBe("Renamed");
148 const titleBlock = await app.prisma.block.findFirst({
149 where: { pageId: task.pageId, properties: { path: ["isTitle"], equals: true } },
150 select: { content: true }
151 });
152 expect((titleBlock?.content as { text: string }).text).toBe("Renamed");
153
154
155 await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: h, body: { status: "done" } });
156 const illegal = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, {
157 headers: h,
158 body: { status: "todo" }
159 });
160 expect(illegal.status).toBe(400);
161
162
163 const byAssignee = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, {
164 headers: authHeaders(member.accessToken!),
165 body: { status: "in_progress" }
166 });
167 expect(byAssignee.status).toBe(200);
168 });
169
170 it("filters: status, overdue, month (calendar)", async () => {
171 const owner = await createUser(app.request, { name: "Filt Owner" });
172 const h = authHeaders(owner.accessToken!);
173 const ws = await createWorkspace(app.request, h, "Filt WS");
174 const base = { title: "x", assigneeIds: [owner.userId] };
175 const mk = (title: string, due: string, status?: string) =>
176 app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, {
177 headers: h,
178 body: { ...base, title, dueAt: due, ...(status ? { status } : {}) }
179 });
180
181 await mk("Overdue", new Date(Date.now() - 86_400_000).toISOString());
182 const nextMonth = new Date();
183 nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1, 15);
184 const nm = nextMonth.toISOString().slice(0, 7);
185 await mk("Next month", nextMonth.toISOString());
186 await mk("Done one", new Date(Date.now() - 86_400_000).toISOString(), "done");
187
188 const list = async (q: string): Promise<{ data: TaskRow[] }> =>
189 (await json(
190 await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=all${q}`, { headers: h })
191 )) as { data: TaskRow[] };
192
193 const overdue = (await list("&overdue=true")).data;
194 expect(overdue.map((t) => t.title)).toEqual(["Overdue"]);
195 const done = (await list("&status=done")).data;
196 expect(done.map((t) => t.title)).toEqual(["Done one"]);
197 const month = (await list(`&month=${nm}`)).data;
198 expect(month.map((t) => t.title)).toEqual(["Next month"]);
199 });
200
201 it("non-member → 403; unknown task id → 404 (no-leak)", async () => {
202 const owner = await createUser(app.request, { name: "Leak Owner" });
203 const outsider = await createUser(app.request, { name: "Leak Outsider" });
204 const h = authHeaders(owner.accessToken!);
205 const ws = await createWorkspace(app.request, h, "Leak WS");
206 const task = (await json(
207 await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "Secret" } })
208 )) as TaskRow;
209
210 const denied = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, {
211 headers: authHeaders(outsider.accessToken!)
212 });
213 expect(denied.status).toBe(403);
214
215 const missing = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/does_not_exist`, { headers: h });
216 expect(missing.status).toBe(404);
217 });
218});
219