WFDASH-WEBAPP/src/lib/note-feed.test.ts
Dave 09e4d8f1e1 feat(collection-pool): back-to-pending, collection date, and a safe notes feed
Gives the operator two new controls on a collection-pool stop, and fixes
the audit feed they write into.

Back-to-pending (`items/[id]/back-to-pending`)

  A stop has to come back when the driver can't take it — no-show,
  wrong date, supplier pushed the delivery — or when the supplier
  confirmation was premature. A row can be assigned to a driver and/or
  marked ready at the same time, so the route clears both in one write
  rather than making the operator unwind them in the wrong order.

Collection date (`items/[id]/collection-date`)

  `order_items.collection_date` was a PB date field that existed but
  nothing read or wrote it. On a PENDING stop it is a forecast — the
  supplier said goods land on the 20th, so the stop stays
  un-assignable because promising a driver a pickup for goods that
  don't exist yet would be a lie. On a READY stop it is the confirmed
  collection day.

Notes feed (`src/lib/note-feed.ts`)

  `notes` on order_items / stock_purchase_items is NOT free text — it
  is a JSON array of NoteEntry objects, and every consumer parses it as
  one. The assign route's "mark ready" path wrote a plain string into
  it, which silently destroys the entire history: the next parse falls
  back to an empty feed. All writers now go through `appendNote` with
  the row's current value. Lives in `src/lib` rather than a page view
  model because both the client pages and the server routes need it,
  and an API route must never import from `app/(app)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 00:44:49 +02:00

233 lines
8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
appendNote,
buildBackToPendingNote,
formatNotesForDisplay,
parseStoredNotes,
type NoteEntry,
} from "./note-feed";
const entry = (over: Partial<NoteEntry> = {}): NoteEntry => ({
ts: "2026-10-01T09:00:00.000Z",
actorId: "u1",
actorName: "Dana",
category: "note",
body: "something",
...over,
});
describe("parseStoredNotes", () => {
it("returns an empty feed for empty / null / undefined input", () => {
expect(parseStoredNotes("")).toEqual([]);
expect(parseStoredNotes(" ")).toEqual([]);
expect(parseStoredNotes(null)).toEqual([]);
expect(parseStoredNotes(undefined)).toEqual([]);
});
it("returns an empty feed for legacy plain text instead of throwing", () => {
// The old assign route wrote a bare string. Parsing must degrade.
expect(parseStoredNotes("spoke to David")).toEqual([]);
});
it("returns an empty feed for a JSON object", () => {
expect(parseStoredNotes('{"body":"x"}')).toEqual([]);
});
it("drops malformed entries but keeps the good ones", () => {
const stored = JSON.stringify([entry(), { ts: "nope" }, null, "junk"]);
const parsed = parseStoredNotes(stored);
expect(parsed).toHaveLength(1);
expect(parsed[0].actorName).toBe("Dana");
});
it("drops an entry whose category is unknown", () => {
// Unknown category => the next read would silently lose the entry.
const stored = JSON.stringify([entry({ category: "not-a-category" as never })]);
expect(parseStoredNotes(stored)).toEqual([]);
});
it("keeps the new pending category", () => {
const stored = JSON.stringify([entry({ category: "pending" })]);
expect(parseStoredNotes(stored)).toHaveLength(1);
});
});
describe("appendNote", () => {
it("appends after the existing entries, preserving them", () => {
const first = appendNote("", { ts: "2026-10-01T09:00:00.000Z", actor: { id: "u1", name: "Dana" }, category: "note", body: "first" });
const second = appendNote(first, { ts: "2026-10-02T09:00:00.000Z", actor: { id: "u2", name: "Ruan" }, category: "pending", body: "second" });
const parsed = parseStoredNotes(second);
expect(parsed).toHaveLength(2);
expect(parsed[0].body).toBe("first");
expect(parsed[1].body).toBe("second");
expect(parsed[1].actorName).toBe("Ruan");
});
it("does not mutate or alias the caller's string", () => {
const before = appendNote("", { ts: "2026-10-01T09:00:00.000Z", actor: { id: "u1", name: "Dana" }, category: "note", body: "first" });
const after = appendNote(before, { ts: "2026-10-02T09:00:00.000Z", actor: { id: "u1", name: "Dana" }, category: "note", body: "second" });
expect(parseStoredNotes(before)).toHaveLength(1);
expect(parseStoredNotes(after)).toHaveLength(2);
});
});
describe("buildBackToPendingNote", () => {
// A pre-existing entry, so the tests prove the new one is APPENDED
// rather than written in place of the history.
const seed = JSON.stringify([
{ ts: "2026-09-01T09:00:00.000Z", actorId: "u0", actorName: "Dana", category: "collection", body: "Collected" },
]);
const last = (value: string) => {
const entries = parseStoredNotes(value);
return entries[entries.length - 1];
};
it("appends after the existing feed instead of replacing it", () => {
const out = buildBackToPendingNote({
currentNotes: seed,
note: "supplier pushed it a week",
actor: { id: "u1", name: "Dana" },
unassigned: true,
unready: true,
timestamp: "2026-10-15T08:00:00.000Z",
});
const entries = parseStoredNotes(out);
expect(entries).toHaveLength(2);
expect(entries[0].body).toBe("Collected");
expect(last(out).category).toBe("pending");
expect(last(out).ts).toBe("2026-10-15T08:00:00.000Z");
expect(last(out).actorName).toBe("Dana");
});
it("names both things it cleared", () => {
const out = buildBackToPendingNote({
currentNotes: "",
note: "supplier pushed it a week",
actor: { id: "u1", name: "Dana" },
unassigned: true,
unready: true,
timestamp: "2026-10-15T08:00:00.000Z",
});
expect(last(out).body).toContain("driver unassigned");
expect(last(out).body).toContain("no longer ready for collection");
expect(last(out).body).toContain("supplier pushed it a week");
});
it("only claims what actually changed", () => {
const out = buildBackToPendingNote({
currentNotes: "",
note: "was never assigned",
actor: { id: "u1", name: "Dana" },
unassigned: false,
unready: true,
timestamp: "2026-10-15T08:00:00.000Z",
});
expect(last(out).body).not.toContain("driver unassigned");
expect(last(out).body).toContain("no longer ready");
});
it("still writes an entry when there was nothing to clear", () => {
const out = buildBackToPendingNote({
currentNotes: "",
note: "operator nudged it",
actor: { id: "u1", name: "Dana" },
unassigned: false,
unready: false,
timestamp: "2026-10-15T08:00:00.000Z",
});
expect(last(out).body).toContain("no state change needed");
expect(last(out).body).toContain("operator nudged it");
});
it("starts a fresh feed from a legacy plain-string value", () => {
const out = buildBackToPendingNote({
currentNotes: "spoke to David",
note: "pushed back",
actor: { id: "u1", name: "Dana" },
unassigned: false,
unready: true,
timestamp: "2026-10-15T08:00:00.000Z",
});
const entries = parseStoredNotes(out);
expect(entries).toHaveLength(1);
expect(entries[0].category).toBe("pending");
expect(entries[0].body).toContain("pushed back");
});
});
describe("formatNotesForDisplay", () => {
it("renders the feed as readable lines, newest first", () => {
const stored = appendNote("", {
ts: "2026-09-01T09:00:00.000Z",
actor: { id: "u1", name: "Dana" },
category: "collection",
body: "Collected",
});
const withSecond = appendNote(stored, {
ts: "2026-10-15T21:17:58.000Z",
actor: { id: "operator", name: "Operator" },
category: "status",
body: "Set the expected-ready date to 21 Oct 2026",
});
const lines = formatNotesForDisplay(withSecond);
expect(lines).toHaveLength(2);
// Newest first.
expect(lines[0]).toContain("Operator");
expect(lines[0]).toContain("Set the expected-ready date to 21 Oct 2026");
expect(lines[1]).toContain("Dana");
expect(lines[1]).toContain("Collected");
});
it("never leaks raw JSON to the operator", () => {
const stored = JSON.stringify([
{ ts: "2026-10-15T21:17:58.000Z", actorId: "operator", actorName: "Operator", category: "status", body: "Marked ready" },
]);
const [line] = formatNotesForDisplay(stored);
expect(line).not.toContain('"ts"');
expect(line).not.toContain("actorId");
expect(line).not.toContain("{");
});
it("flattens a multi-line note so each entry stays one line", () => {
const stored = JSON.stringify([
{
ts: "2026-10-15T21:17:58.000Z",
actorId: "operator",
actorName: "Operator",
category: "status",
body: "Set the date — Delivery Date:\n1 for est. delivery\n2",
},
]);
const lines = formatNotesForDisplay(stored);
expect(lines).toHaveLength(1);
expect(lines[0]).not.toContain("\n");
});
it("renders a non-string body object without crashing", () => {
const stored = JSON.stringify([
{
ts: "2026-10-15T21:17:58.000Z",
actorId: "u1",
actorName: "Dana",
category: "cost",
body: { prevCost: 100, newCost: 120 },
},
]);
const [line] = formatNotesForDisplay(stored);
expect(line).toContain("120");
});
it("falls back to the raw string for a legacy plain-text value", () => {
expect(formatNotesForDisplay("spoke to David")).toEqual(["spoke to David"]);
});
it("returns nothing for empty / missing input", () => {
expect(formatNotesForDisplay("")).toEqual([]);
expect(formatNotesForDisplay(null)).toEqual([]);
expect(formatNotesForDisplay(undefined)).toEqual([]);
});
});