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