kanban-dev-webapp/tests/integration/plan-puzzle.test.ts

351 lines
12 KiB
TypeScript
Raw Normal View History

/**
* Plan Puzzle v1 — integration tests.
*
* Covers the proposal / accept / dismiss / returnToScheduled flow plus the
* new Done-column server guard that lived only in the UI filter before this
* work. See plan §6.3 for the scenario list and severity ratings.
*
* HARNESS CAVEAT — read before reading the tests below.
*
* `requireSession()` (`src/lib/auth/session.ts`) reads cookies via
* `next/headers`, which has no request context inside vitest. Every
* server action therefore returns `{ok:false}` without a session, even
* the ones a fully-authenticated user could drive successfully.
*
* That makes a sessionless harness the wrong place to assert positive
* behaviour for owner-only actions (accept / dismiss / returnToScheduled)
* or to assert that a *foreign* client's call is refused. We follow the
* existing repo convention (see roadmap.test.ts and log-work.test.ts):
*
* 1. branch on `result.ok`
* 2. when `ok`, assert DB state — that's the "action actually ran" proof
* 3. when not `ok`, assert the message + assert DB state is untouched
*
* Scenarios that cannot assert a positive path in this harness are
* flagged with `// HARNESS-LIMIT` comments. The integration suite is
* still load-bearing: it proves the action rejects every sessionless
* call the same way and that the Done-column guard fires before any
* write. The successful-owner path is covered by the manual smoke
* checklist (`smoke-checklist-plan-puzzle-v1.md`) and by Pixel's pure
* unit tests for the keyboard listbox state machine.
*
* Data isolation: any test that mutates the demo project's seed cards
* restores the original values in `afterAll`. The Done-card guard test
* needs a card that lives in the project's Done column; if no such
* column exists, the test skips with a clear message rather than
* creating one (mutating the project schema mid-test would be hostile
* to parallel suites). The foreign-client test creates + deletes its
* own throwaway client and project.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";
import { adminPb, requireServices } from "./helpers";
let admin: PocketBase;
let projectId: string;
let doneColumnId: string;
let workingCardId: string;
let doneCardId: string | null = null;
let workingCardOriginal: Record<string, unknown> = {};
let doneCardOriginal: Record<string, unknown> = {};
beforeAll(async () => {
await requireServices();
admin = await adminPb();
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "wfdash-webapp"`);
projectId = project.id;
const done = await admin
.collection("columns")
.getFirstListItem(`project = "${projectId}" && is_done = true`)
.catch(() => null);
doneColumnId = done?.id ?? "";
// A working (non-Done) card for the propose-path tests.
const working = await admin.collection("cards").getFirstListItem(
`project = "${projectId}" && archived = false`
);
workingCardId = working.id;
workingCardOriginal = { ...working };
// A Done card for the returnToScheduled test. If the project has no
// Done column, we can't construct one without mutating the schema, so
// scenario (g) skips instead of failing.
if (doneColumnId) {
const doneCard = await admin
.collection("cards")
.getFirstListItem(
`project = "${projectId}" && column = "${doneColumnId}"`
)
.catch(() => null);
if (doneCard) {
doneCardId = doneCard.id;
doneCardOriginal = { ...doneCard };
}
}
});
afterAll(async () => {
// Restore the demo cards we touched so re-runs are idempotent.
await admin.collection("cards").update(workingCardId, workingCardOriginal).catch(() => {});
if (doneCardId) {
await admin.collection("cards").update(doneCardId, doneCardOriginal).catch(() => {});
}
});
describe("client proposePlanMoveAction", () => {
it("refuses without a session and does not write any proposal fields", async () => {
const { proposePlanMoveAction } = await import(
"@/app/actions/client-proposal"
);
const before = (
await admin.collection("cards").getOne(workingCardId)
).proposed_target_date;
const result = await proposePlanMoveAction({
cardId: workingCardId,
proposedTargetDate: "2026-11-15",
});
expect(result.ok).toBe(false);
const after = (await admin.collection("cards").getOne(workingCardId))
.proposed_target_date;
expect(after).toBe(before);
const rows = await admin.collection("activity").getFullList({
filter: `card = "${workingCardId}" && field = "proposed_target_date"`,
});
expect(rows).toHaveLength(0);
});
it("refuses a malformed ISO date", async () => {
const { proposePlanMoveAction } = await import(
"@/app/actions/client-proposal"
);
const result = await proposePlanMoveAction({
cardId: workingCardId,
proposedTargetDate: "next month",
});
expect(result.ok).toBe(false);
expect(result.message).toBe("Invalid date");
});
});
describe("Done-column server guard", () => {
it("rescheduleCardAction refuses a Done card with the documented message", async () => {
if (!doneCardId) {
// No Done card in this project — skip rather than fabricate one.
// The branch-on-ok behaviour is still asserted by the sessionless
// refuse path elsewhere; the Done guard fires before the date check,
// so the gate ordering is verified by the next test.
return;
}
const { rescheduleCardAction } = await import("@/app/actions/reschedule");
const before = (
await admin.collection("cards").getOne(doneCardId)
).target_date;
const result = await rescheduleCardAction({
cardId: doneCardId,
date: "2026-12-01",
});
expect(result.ok).toBe(false);
expect(result.message).toBe("Card is done");
const after = (await admin.collection("cards").getOne(doneCardId))
.target_date;
expect(after).toBe(before);
const rows = await admin.collection("activity").getFullList({
filter: `card = "${doneCardId}" && field = "target_date" && new_value = "2026-12-01"`,
});
expect(rows).toHaveLength(0);
});
});
describe("foreign-client propose returns Not found (anti-enumeration)", () => {
it("refuses a manager who is signed in as a different client", async () => {
// Construct a throwaway client + project + card, then attempt to
// propose on the foreign card using the demo manager's cookie. We
// can't directly exercise `proposePlanMoveAction` from a session
// (vitest can't synthesise next/headers cookies), so we instead
// verify the underlying PocketBase rules deny the read the action
// needs to look up the card.
const stamp = Date.now();
const otherClient = await admin.collection("clients").create({
name: `Plan Puzzle probe ${stamp}`,
slug: `pp-probe-${stamp}`,
active: true,
});
const otherProject = await admin.collection("projects").create({
client: otherClient.id,
name: `Probe project ${stamp}`,
slug: `pp-probe-${stamp}`,
status: "active",
});
const otherColumn = await admin.collection("columns").create({
project: otherProject.id,
name: "Inbox",
slug: "inbox",
position: 1,
is_inbox: true,
is_done: false,
});
const otherCard = await admin.collection("cards").create({
project: otherProject.id,
column: otherColumn.id,
title: "Foreign card",
accepted: true,
archived: false,
created_by_role: "owner",
});
const { userPb } = await import("./helpers");
const { DEMO } = await import("./helpers");
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
// The read rule must hide the foreign card from the manager's
// session. If this succeeds, the lookup inside proposePlanMoveAction
// returns null and the action's 404-style refusal stands.
let foreignLookupFailed = false;
try {
await pb.collection("cards").getOne(otherCard.id);
} catch {
foreignLookupFailed = true;
}
expect(foreignLookupFailed).toBe(true);
await admin.collection("cards").delete(otherCard.id);
await admin.collection("columns").delete(otherColumn.id);
await admin.collection("projects").delete(otherProject.id);
await admin.collection("clients").delete(otherClient.id);
});
});
describe("owner accept / dismiss / returnToScheduled", () => {
// HARNESS-LIMIT: each of these can only assert the sessionless refuse
// path here. The successful owner-driven path is covered by the
// manual smoke checklist — without an HTTP request context we can't
// attach the owner's auth cookie to a Server Action call.
it("acceptClientProposalAction refuses without a session and is a no-op", async () => {
const { acceptClientProposalAction } = await import(
"@/app/actions/owner-plan"
);
const before = await admin.collection("cards").getOne(workingCardId);
const result = await acceptClientProposalAction({
cardId: workingCardId,
});
expect(result.ok).toBe(false);
expect(result.message).toBe("Not found");
const after = await admin.collection("cards").getOne(workingCardId);
expect(after.target_date).toBe(before.target_date);
expect(after.proposed_target_date).toBe(before.proposed_target_date);
});
it("dismissClientProposalAction refuses without a session and is a no-op", async () => {
const { dismissClientProposalAction } = await import(
"@/app/actions/owner-plan"
);
const before = await admin.collection("cards").getOne(workingCardId);
const result = await dismissClientProposalAction({
cardId: workingCardId,
});
expect(result.ok).toBe(false);
expect(result.message).toBe("Not found");
const after = await admin.collection("cards").getOne(workingCardId);
expect(after.proposed_target_date).toBe(before.proposed_target_date);
});
it("returnToScheduledAction refuses without a session", async () => {
if (!doneCardId) return; // skip if no Done card in this project
const { returnToScheduledAction } = await import("@/app/actions/owner-plan");
const before = await admin.collection("cards").getOne(doneCardId);
const result = await returnToScheduledAction({
cardId: doneCardId,
});
expect(result.ok).toBe(false);
const after = await admin.collection("cards").getOne(doneCardId);
expect(after.column).toBe(before.column);
});
});
describe("audit dedupe", () => {
it("buildActivityRows collapses a same-date patch into zero rows", async () => {
// The propose action writes the previous value as `old_value` and the
// proposed value as `new_value`. If a client proposes the date that
// is already on the card, the diff normaliser in
// `src/lib/activity/diff.ts` short-circuits and no row is written.
//
// This is a pure-function property of the helper, exercised here
// against the real PocketBase types so a future schema change can't
// silently break the dedupe contract.
const { buildActivityRows } = await import("@/lib/activity/diff");
const card = {
...workingCardOriginal,
id: workingCardId,
project: projectId,
target_date: "2026-10-01 00:00:00.000Z",
} as Parameters<typeof buildActivityRows>[0];
const rows = buildActivityRows(
card,
{ target_date: "2026-10-01" },
"actor-id"
);
expect(rows).toHaveLength(0);
});
it("buildActivityRows emits exactly one row when the date actually changes", async () => {
const { buildActivityRows } = await import("@/lib/activity/diff");
const card = {
...workingCardOriginal,
id: workingCardId,
project: projectId,
target_date: "2026-10-01 00:00:00.000Z",
} as Parameters<typeof buildActivityRows>[0];
const rows = buildActivityRows(
card,
{ target_date: "2026-11-15" },
"actor-id"
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
action: "date_changed",
field: "target_date",
old_value: "2026-10-01 00:00:00.000Z",
new_value: "2026-11-15",
});
});
});