/** * T-159…T-161 — Tasks (page-backed first-class tasks, A-006). * * A task is a `pages.kind='task'` page + a 1:1 `tasks` sidecar. This spec * covers the full HTTP contract: * - create: EDIT-gated; seeds the task page (kind=task) + title block; * non-member assignee → 400 (a notify to an outsider is a leak) * - inbox (scope=inbox): the caller's assigned tasks; a task whose PAGE * grant is revoked (explicit NONE) NEVER leaks — negative test * - filters: status, overdue, month (calendar) * - update: title mirrors onto the page; an ILLEGAL status transition → 400 * - TASK_ASSIGNED is created for the assignee (NOT the creator) * - non-member → 403 (get) / 404 (unknown id, no-leak) * The T-160 due-reminder sweep is worker-side (covered by its unit test); * this spec covers the API surface it feeds (the task row's dueAt/status). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; interface TaskRow { id: string; pageId: string; title: string; status: string; dueAt: string | null; assigneeIds: string[]; pageTitle?: string; } beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); describe("T-159…T-161: tasks (page-backed)", () => { it("create: EDIT-gated, seeds kind=task page + title block; non-member assignee → 400", async () => { const owner = await createUser(app.request, { name: "Task Owner" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Task WS"); // non-member assignee → 400 (would be a leak to notify an outsider) const outsider = await createUser(app.request, { name: "Task Outsider" }); const bad = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "T", assigneeIds: [outsider.userId] } }); expect(bad.status).toBe(400); // valid create const res = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "Ship the report", status: "todo", dueAt: new Date(Date.now() + 86_400_000).toISOString() } }); expect(res.status).toBe(201); const body = (await json(res)) as { id: string; pageId: string; title: string; status: string; dueAt: string }; expect(body.title).toBe("Ship the report"); expect(body.status).toBe("todo"); // the task page is kind=task (A-006) and has the seeded title block const page = await app.prisma.page.findUnique({ where: { id: body.pageId }, select: { kind: true, title: true, workspaceId: true } }); expect(page?.kind).toBe("task"); expect(page?.title).toBe("Ship the report"); const titleBlock = await app.prisma.block.findFirst({ where: { pageId: body.pageId, properties: { path: ["isTitle"], equals: true } }, select: { type: true, content: true } }); expect(titleBlock?.type).toBe("PARAGRAPH"); expect((titleBlock?.content as { text: string }).text).toBe("Ship the report"); }); it("inbox: the caller's assigned tasks; a REVOKED page grant never leaks", async () => { const owner = await createUser(app.request, { name: "Inbox Owner" }); const member = await createUser(app.request, { name: "Inbox Member" }); const h = authHeaders(owner.accessToken!); const hm = authHeaders(member.accessToken!); const ws = await createWorkspace(app.request, h, "Inbox WS"); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); // owner assigns to the member; the member is in their inbox const res = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "Member task", assigneeIds: [member.userId] } }); const task = (await json(res)) as TaskRow; const inbox = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: hm }))) as { data: TaskRow[] }; expect(inbox.data.some((t) => t.id === task.id)).toBe(true); // the owner (creator, not assignee) is NOT in their own inbox const ownerInbox = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: h }))) as { data: TaskRow[] }; expect(ownerInbox.data.some((t) => t.id === task.id)).toBe(false); // TASK_ASSIGNED created for the member, NOT the owner const notifs = await app.prisma.notification.findMany({ where: { workspaceId: ws.workspaceId, type: "TASK_ASSIGNED" }, select: { userId: true } }); expect(notifs.some((n) => n.userId === member.userId)).toBe(true); expect(notifs.some((n) => n.userId === owner.userId)).toBe(false); // Now revoke the member's PAGE access (explicit NONE grant — a hard stop // that overrides the workspace membership floor). The task must vanish // from their inbox AND their get must 403. await app.prisma.permission.create({ data: { workspaceId: ws.workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: task.pageId, level: "NONE" } }); const inbox2 = (await json(await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=inbox`, { headers: hm }))) as { data: TaskRow[] }; expect(inbox2.data.some((t) => t.id === task.id)).toBe(false); const denied = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: hm }); expect(denied.status).toBe(403); }); it("update: title mirrors to the page; illegal transition → 400; non-member → 403", async () => { const owner = await createUser(app.request, { name: "Upd Owner" }); const member = await createUser(app.request, { name: "Upd Member" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Upd WS"); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); const task = (await json( await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "Original", assigneeIds: [member.userId] } }) )) as TaskRow; // legal transition todo → in_progress + title change mirrors to the page const upd = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: h, body: { title: "Renamed", status: "in_progress" } }); expect(upd.status).toBe(200); const body = (await json(upd)) as TaskRow; expect(body.status).toBe("in_progress"); expect(body.title).toBe("Renamed"); const page = await app.prisma.page.findUnique({ where: { id: task.pageId }, select: { title: true } }); expect(page?.title).toBe("Renamed"); const titleBlock = await app.prisma.block.findFirst({ where: { pageId: task.pageId, properties: { path: ["isTitle"], equals: true } }, select: { content: true } }); expect((titleBlock?.content as { text: string }).text).toBe("Renamed"); // illegal transition in_progress → cancelled is legal, but done → todo is NOT await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: h, body: { status: "done" } }); const illegal = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: h, body: { status: "todo" } }); expect(illegal.status).toBe(400); // the assignee may also update (page-level EDIT via membership floor) const byAssignee = await app.request("PATCH", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: authHeaders(member.accessToken!), body: { status: "in_progress" } }); expect(byAssignee.status).toBe(200); }); it("filters: status, overdue, month (calendar)", async () => { const owner = await createUser(app.request, { name: "Filt Owner" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Filt WS"); const base = { title: "x", assigneeIds: [owner.userId] }; const mk = (title: string, due: string, status?: string) => app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { ...base, title, dueAt: due, ...(status ? { status } : {}) } }); // one overdue (due yesterday, still todo), one due next month, one done await mk("Overdue", new Date(Date.now() - 86_400_000).toISOString()); const nextMonth = new Date(); nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1, 15); const nm = nextMonth.toISOString().slice(0, 7); // YYYY-MM await mk("Next month", nextMonth.toISOString()); await mk("Done one", new Date(Date.now() - 86_400_000).toISOString(), "done"); const list = async (q: string): Promise<{ data: TaskRow[] }> => (await json( await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=all${q}`, { headers: h }) )) as { data: TaskRow[] }; const overdue = (await list("&overdue=true")).data; expect(overdue.map((t) => t.title)).toEqual(["Overdue"]); const done = (await list("&status=done")).data; expect(done.map((t) => t.title)).toEqual(["Done one"]); const month = (await list(`&month=${nm}`)).data; expect(month.map((t) => t.title)).toEqual(["Next month"]); }); it("non-member → 403; unknown task id → 404 (no-leak)", async () => { const owner = await createUser(app.request, { name: "Leak Owner" }); const outsider = await createUser(app.request, { name: "Leak Outsider" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Leak WS"); const task = (await json( await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/tasks`, { headers: h, body: { title: "Secret" } }) )) as TaskRow; // a non-member of the space cannot read the task (page-level 403) const denied = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/${task.id}`, { headers: authHeaders(outsider.accessToken!) }); expect(denied.status).toBe(403); // an unknown id is 404 (no-leak — the id does not resolve) const missing = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks/does_not_exist`, { headers: h }); expect(missing.status).toBe(404); }); });