diff --git a/tests/integration/roadmap.test.ts b/tests/integration/roadmap.test.ts new file mode 100644 index 0000000..bb374ee --- /dev/null +++ b/tests/integration/roadmap.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let projectId: string; +let cardId: string; +let originalDate = ""; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + const card = await admin + .collection("cards") + .getFirstListItem(`project = "${projectId}" && archived = false`); + cardId = card.id; + originalDate = card.target_date ?? ""; +}); + +afterAll(async () => { + await admin + .collection("cards") + .update(cardId, { target_date: originalDate }) + .catch(() => {}); +}); + +describe("rescheduling is always audited", () => { + it("writes an activity row naming the owner", async () => { + // target_date is the client's field. An owner moving it without a record + // is exactly what the activity log exists to prevent. + const { rescheduleCardAction } = await import("@/app/actions/reschedule"); + + const before = ( + await admin.collection("activity").getList(1, 1, { + filter: `card = "${cardId}" && field = "target_date"`, + fields: "id", + }) + ).totalItems; + + // Called without an owner session in this harness, it must refuse rather + // than write. Either outcome is acceptable; a silent write is not. + const result = await rescheduleCardAction({ + cardId, + date: "2026-11-15", + }).catch(() => ({ ok: false as const })); + + if (result.ok) { + const after = ( + await admin.collection("activity").getList(1, 1, { + filter: `card = "${cardId}" && field = "target_date"`, + fields: "id", + }) + ).totalItems; + expect(after).toBe(before + 1); + } else { + const card = await admin.collection("cards").getOne(cardId); + expect((card.target_date ?? "").slice(0, 10)).not.toBe("2026-11-15"); + } + }); + + it("refuses a malformed date", async () => { + const { rescheduleCardAction } = await import("@/app/actions/reschedule"); + const result = await rescheduleCardAction({ + cardId, + date: "next tuesday", + }).catch(() => ({ ok: false as const })); + expect(result.ok).toBe(false); + }); +}); + +describe("the planning page is scoped by role", () => { + it("shows a client manager only their own client's work", async () => { + const stamp = Date.now(); + const other = await admin + .collection("clients") + .create({ name: `Roadmap probe ${stamp}`, slug: `rmp-${stamp}`, active: true }); + const otherProject = await admin.collection("projects").create({ + client: other.id, + name: `Roadmap probe project ${stamp}`, + slug: `rmp-${stamp}`, + status: "active", + }); + + const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + const response = await fetch(`${APP_URL}/plan`, { headers: { cookie } }); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).not.toContain(`Roadmap probe ${stamp}`); + + await admin.collection("projects").delete(otherProject.id); + await admin.collection("clients").delete(other.id); + }); + + it("renders for the owner", async () => { + const cookie = await signIn(DEMO.ownerEmail, DEMO.ownerPassword); + const response = await fetch(`${APP_URL}/plan`, { headers: { cookie } }); + expect(response.status).toBe(200); + }); +});