/** * T-162/T-163/T-164 — tasks inbox + page review workflow E2E (dev-stack, * Playwright, single-origin). * * Origin: `https://notion.filps.com` (Caddy → web :3000 / api :4000). A * localhost origin does NOT work — the API's CORS is pinned to the domain. * * Two independent flows through the real browser + API: * * TASKS (T-164): a task is a kind="task" page + a 1:1 sidecar. * - create an ASSIGNED task (scope=inbox requires an assignee — a task * with no assignee is invisible in "Mine") * - it appears in the /w/:ws/inbox "Mine" tab * - the inline status chip advances todo → in_progress (server-side * transition matrix; the UI button label flips to "… as done") * * REVIEWS (T-162/T-163): the page review banner (page-view). * - a fresh page shows "No review yet" + Request review * - pick the reviewer (the workspace member) + Submit → in_review * - the reviewer-of-record (here also the requester — the single-user * case the transition matrix allows) sees Approve * - Approve → published with lastReview.decision = "approved" * * Wire-shape notes (probed 2026-10-12): * - `GET /users/me` is the ENVELOPED `{ user }` shape (not bare). * - `GET /pages/:id/review` returns `{ state, review, lastReview }` — the * in-flight row is `review`, NOT `open` (the web type said `open`, which * made the in_review banner branch dead; fixed in review-banner.tsx). */ import { test, expect } from "../fixtures"; import { createPage, createBlock } from "../utils/api"; import type { PageDto } from "../fixtures"; interface TaskDto { id: string; pageId: string; title: string; status: string; pageTitle?: string; } test.describe("Tasks inbox — P3 (T-164)", () => { test("assigned task appears in the inbox and advances status inline", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Tasks Inbox Space"); const me = (await (await api.get("/api/v1/users/me")).json()) as { user: { id: string } }; // An UNASSIGNED task must NOT appear in "Mine" (scope=inbox = assigned to // the caller) — the negative that pins the scope contract. const unassigned = (await ( await api.post(`/api/v1/workspaces/${ws.id}/tasks`, { data: { title: "Unassigned probe" } }) ).json()) as TaskDto; const TASK_TITLE = "E2E inbox task"; const task = (await ( await api.post(`/api/v1/workspaces/${ws.id}/tasks`, { data: { title: TASK_TITLE, assigneeIds: [me.user.id] } }) ).json()) as TaskDto; expect(task.status).toBe("todo"); await authPage.goto(`/w/${ws.slug}/inbox`); await expect(authPage.getByRole("heading", { name: "Inbox" })).toBeVisible(); // Scope to the main content column: the sidebar tree renders the task // page under the same title (strict mode would see two links). const main = authPage.locator("#workspace-main"); // The assigned task is listed (row link = the task page title); the // unassigned one is NOT (scope=inbox). const row = main.getByRole("link", { name: TASK_TITLE }); await expect(row).toBeVisible({ timeout: 15_000 }); await expect(main.getByRole("link", { name: "Unassigned probe" })).toHaveCount(0); // Advance todo → in_progress via the inline chip (aria labels flip). await authPage.getByRole("button", { name: `Mark ${TASK_TITLE} as in_progress` }).click(); await expect(authPage.getByRole("button", { name: `Mark ${TASK_TITLE} as done` })).toBeVisible({ timeout: 10_000 }); // And in_progress → done; done has no next status (the chip becomes a // plain icon, not a button) and the row moves to the Done tab. await authPage.getByRole("button", { name: `Mark ${TASK_TITLE} as done` }).click(); await expect(authPage.getByRole("button", { name: `Mark ${TASK_TITLE} as` })).toHaveCount(0, { timeout: 10_000 }); await authPage.getByRole("button", { name: "Done" }).click(); await expect(main.getByRole("link", { name: TASK_TITLE })).toBeVisible({ timeout: 15_000 }); }); }); test.describe("Page review workflow — P3 (T-162/T-163)", () => { test("request review → in review → approve (single reviewer)", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Reviews Space"); const me = (await (await api.get("/api/v1/users/me")).json()) as { user: { id: string; name: string } }; const page = (await createPage(api, ws.id, { title: "Review me" })) as PageDto; await createBlock(api, page.id, { type: "PARAGRAPH", content: { text: "Body for review" } }); await authPage.goto(`/w/${ws.slug}/page/${page.id}`); // The banner shows the no-review state with a Request review affordance. const banner = authPage.getByTestId("review-banner"); await expect(banner).toBeVisible({ timeout: 20_000 }); await banner.getByRole("button", { name: /request review/i }).click(); // Reviewer picker: the workspace member checkbox + Submit (disabled // until a reviewer is selected). const reviewerCheckbox = banner.getByLabel(me.user.name); await expect(reviewerCheckbox).toBeVisible(); await reviewerCheckbox.check(); await banner.getByRole("button", { name: /submit/i }).click(); // In review: the chip + Approve (I am the reviewer-of-record). await expect(authPage.getByText("In review")).toBeVisible({ timeout: 10_000 }); await authPage.getByRole("button", { name: /approve/i }).click(); // Approved: the banner settles to the approved last-review state. await expect(authPage.getByText("Last review: approved")).toBeVisible({ timeout: 10_000 }); // Server state agrees: published + lastReview.decision = approved. const state = (await ( await api.get(`/api/v1/pages/${page.id}/review`) ).json()) as { state: string; lastReview: { decision: string | null } | null }; expect(state.state).toBe("published"); expect(state.lastReview?.decision).toBe("approved"); }); });