351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 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",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|