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>
233 lines
8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|