test(plan): Plan Puzzle v1 integration tests
Covers the 9 scenarios from the §6.3 plan brief, mapped to the harness's sessionless reality: - client propose refuses without a session and does not write fields - rescheduleCardAction refuses a Done card with 'Card is done' - foreign-client propose path: PocketBase read rule denies the cross-tenant lookup (anti-enumeration per access.ts:57-59) - accept/dismiss/returnToScheduled refuse without a session and are no-ops - buildActivityRows collapses same-date patches into zero rows and emits one row when the date actually changes HARNESS-LIMIT: the actions that need an owner session (accept, dismiss, returnToScheduled) cannot be driven successfully in vitest. They are covered by the manual smoke checklist at the workspace path. The sessionless refuse path proves the gate fires for every call; the positive path is exercised by the live smoke run. Test file: tests/integration/plan-puzzle.test.ts (single new file)
This commit is contained in:
parent
29013b5276
commit
3e868f910a
1 changed files with 351 additions and 0 deletions
351
tests/integration/plan-puzzle.test.ts
Normal file
351
tests/integration/plan-puzzle.test.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue