WFDASH-WEBAPP/src/lib/server/quotes-service.test.ts
Dave d983eccd6c fix(delivery-notes): return to the list after quoting; kill the phantom supplier
Two things the operator hit straight away.

1. Creating a quote parked her on the quote detail page.
   She is working through notes one after another, so that pulled her
   out of the list for a document she had just reviewed in the modal.
   `afterCreate` now defaults to "list": the modal closes, the toast
   confirms, and she is back where she was with the row's first slot
   now showing the quote number.

2. "Couldn't load this quote's PDF" — blaming a supplier.
   The message was right about the failure and wrong about the cause.
   The readiness rule existed in TWO places: `quotes-service.ts` for
   the send path, `quote-pdf-service.ts` for the PDF route. When the
   supplier requirement was dropped I changed one of them and left the
   other enforcing it. So a counter note whose lines never had a
   supplier could be quoted and sent but never rendered.

   Both copies now call one function, `quoteItemReadinessGaps`, so a
   third copy cannot appear. Verified in the browser: Q202609631 now
   fails with "Every item still needs: cost price, not processed,
   selling price" — no supplier — and that quote's line genuinely has
   cost 0, selling 0 and is_processed false, so refusing it is correct.
   The phantom requirement is gone; the real blockers are named.

   `is_processed` is deliberately KEPT. It is the flag that says a
   person reviewed the line, and nothing else can set it on a
   delivery-note quote — the quote_items update whitelists exclude it.
   Dropping it would let a quote go out with lines nobody looked at.

The quotes-service test mock now spreads the real module, so these
tests run against the real rule rather than a stub that could agree
with the implementation while production disagreed.

Full suite 3676 passing, tsc clean, no new lint errors.
2026-10-05 19:49:20 +02:00

2820 lines
103 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// The mock state has to be hoisted to the top of the file because Vitest
// hoists `vi.mock` factories above all imports.
const { pbMock, clearStore } = vi.hoisted(() => {
const store: Record<string, Map<string, Record<string, unknown>>> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreate = (collection: string) => {
if (!store[collection]) store[collection] = new Map();
return store[collection];
};
// The mock `where` is intentionally minimal — supports the patterns the
// production service actually issues: `field = "value"` (with or
// Recursive filter parser. The production service builds filters
// like `is_template != true && (status = "a" || status = "b")` —
// parens bind the OR to the surrounding AND. A naive
// top-level `||` split would break the parens; this parser
// walks the string and evaluates each piece as a boolean
// expression, recursing into parens.
type FilterAST =
| { kind: "and"; left: FilterAST; right: FilterAST }
| { kind: "or"; left: FilterAST; right: FilterAST }
| { kind: "not"; expr: FilterAST }
| {
kind: "cmp";
field: string;
op: "=" | "!=" | "=~" | "!~" | ">=" | "<=";
value: string;
};
// Strip outer parens if they wrap the whole string. e.g.
// `(a = "x")` → `a = "x"`. Lets callers wrap groups in parens
// for readability without us thinking they need recursive eval.
// Only strips if the matching `(` is at index 0 AND the matching
// `)` is at the END — anything else means the parens don't wrap
// the whole expression.
const stripOuterParens = (s: string): string => {
const trimmed = s.trim();
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s;
let depth = 0;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed[i] === "(") depth++;
else if (trimmed[i] === ")") {
depth--;
// The paren opened at index 0 closed here. If that's not the
// final character, the string is something like `(a) || (b)`
// — the leading `(` and trailing `)` are DIFFERENT pairs, so
// stripping them would corrupt the expression into
// `a) || (b`. Only a close at the very end means the parens
// genuinely wrap the whole thing.
if (depth === 0 && i < trimmed.length - 1) return s;
}
}
return depth === 0 ? trimmed.slice(1, -1) : s;
};
const parseFilter = (raw: string): FilterAST => {
// Strip outer parens repeatedly — a filter can arrive wrapped in
// redundant nested parens (e.g. `((status = "a" || status = "b"))`,
// which the production `withTab` helper produces when a multi-
// status tab filter that's already parenthesized gets wrapped
// again). Real PB tolerates redundant parens fine; this mock needs
// to fully unwrap them before scanning for the top-level operator,
// or a single-strip leaves one layer behind and the leaf regex
// below chokes on the stray `(`.
let s = raw.trim();
for (;;) {
const next = stripOuterParens(s);
if (next === s) break;
s = next;
}
// Find the LOWEST-precedence top-level operator. `||` is lower
// than `&&` in PB, so we look for `||` first and recurse.
// Loop bound: `i < s.length - 1` would skip the last char; we
// need to scan every char (including the final `)`) to keep
// `depth` accurate for nested-paren tracking.
let depth = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === "(") depth++;
else if (c === ")") depth--;
else if (depth === 0) {
// Two-char operator `||`
if (c === "|" && s[i + 1] === "|") {
return {
kind: "or",
left: parseFilter(s.slice(0, i)),
right: parseFilter(s.slice(i + 2)),
};
}
}
}
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === "(") depth++;
else if (c === ")") depth--;
else if (depth === 0) {
if (c === "&" && s[i + 1] === "&") {
return {
kind: "and",
left: parseFilter(s.slice(0, i)),
right: parseFilter(s.slice(i + 2)),
};
}
}
}
// Leaf — a comparison. `!=`, `=~`, `~`, `!~`, `>=`, or `=`.
// Value can be empty (e.g. `field != ""`) so we use `*?` not `+?`.
// Note: the regex matches `!~` BEFORE `~` so the leading `!`
// doesn't get stripped on its own.
const m = s.match(/^(\w+)\s*(=~|!~|>=|<=|=|!=|~)\s*"?([^"]*?)"?$/);
if (!m) {
throw new Error(`Mock filter parser: cannot parse "${s}"`);
}
let [, field, op, value] = m;
// PB accepts both `~` and `=~` for the contains operator; normalise
// them so service code can use whichever form PB's docs recommend
// for the version it targets.
if (op === "~") op = "=~";
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~" | ">=" | "<=", value };
};
const evalFilter = (item: Record<string, unknown>, ast: FilterAST): boolean => {
switch (ast.kind) {
case "and": return evalFilter(item, ast.left) && evalFilter(item, ast.right);
case "or": return evalFilter(item, ast.left) || evalFilter(item, ast.right);
case "not": return !evalFilter(item, ast.expr);
case "cmp": {
const actualStr = String(item[ast.field] ?? "");
switch (ast.op) {
case "=": return actualStr === ast.value;
case "!=": return actualStr !== ast.value;
// PB's `~` substring operator is case-insensitive — lower-case
// both sides so the mock matches production behaviour (e.g.
// `quote_number~"acme"` matches a stored "ACME-2026-001").
case "=~": return actualStr.toLowerCase().includes(ast.value.toLowerCase());
case "!~": return !actualStr.toLowerCase().includes(ast.value.toLowerCase());
// Lexicographic comparison — good enough for ISO timestamps
// and for the path-A recency cutoff the operator actually
// hits in production. The real PB does the same thing.
case ">=": return actualStr >= ast.value;
case "<=": return actualStr <= ast.value;
}
}
}
};
const where = (collection: string, filter: string) => {
const map = getOrCreate(collection);
const items = Array.from(map.values());
if (!filter || !filter.trim()) return items;
const ast = parseFilter(filter);
return items.filter((item) => evalFilter(item, ast));
};
const pbMock = {
autoCancellation: () => undefined,
collection: (name: string) => ({
async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
const items = where(name, opts.filter || "");
const start = (page - 1) * perPage;
return {
items: items.slice(start, start + perPage),
totalItems: items.length,
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
page,
perPage,
};
},
async getFullList(opts: { filter?: string; fields?: string } = {}) {
return where(name, opts.filter || "");
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string) {
const item = getOrCreate(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
// Enforce the unique (year, month) index for quote_counters so the
// service's race-handling path can be exercised.
if (name === "quote_counters") {
const map = getOrCreate(name);
for (const item of map.values()) {
if (item.year === data.year && item.month === data.month) {
const err = new Error("unique constraint") as Error & { status: number };
err.status = 409;
throw err;
}
}
}
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreate(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreate(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = new Date().toISOString();
// Return a clone so concurrent callers each see the value at the
// moment their update ran, not the final mutated value.
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("./quote-pdf-service", async (importOriginal) => {
// The `*Mock` aliases point at the SAME fns the service imports, so
// tests can configure them via `await import("./quote-pdf-service")`.
const isQuotePdfStale = vi.fn();
const generateQuotePdf = vi.fn();
return {
// Spread the real module first. `quoteItemReadinessGaps` is the
// ONE readiness rule both the PDF route and the send path use —
// it must be the real implementation, not a stub, or these tests
// would pass against a fake rule while production used another.
...(await importOriginal<typeof import("./quote-pdf-service")>()),
isQuotePdfStale,
generateQuotePdf,
isQuotePdfStaleMock: isQuotePdfStale,
generateQuotePdfMock: generateQuotePdf,
};
});
// Route every `getAdminPocketBase()` call inside `quotes-service`
// through the in-memory pbMock above. Without this mock the tests
// fall through to the real PocketBase instance, which requires
// PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD env vars and a live server
// — neither of which the unit suite depends on for correctness.
// All other service test files in this repo follow the same
// pattern (see delivery-notes-service.test.ts, customers-coordinates.test.ts,
// etc.) — this file was missing the mock since the
// "submitQuote" describe block landed, so every submitQuote test
// silently hit real PB and got a "Quote not found" 404.
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
generateQuoteNumberServer,
findOrCreateCustomer,
findOrCreateCompany,
createQuoteFromEmail,
appendReplyToQuote,
findQuoteByMessageId,
extractQuoteNumberFromSubject,
normalizeCompanyName,
sendQuoteEmail,
retryQuoteEmail,
recordSendOutcome,
getQuotesDashboardData,
getQuotesArchiveData,
getQuoteStatusTabCounts,
clearQuoteStatusTabCountsCache,
QUOTE_TAB_STATUSES,
QUOTE_TAB_ORDER,
submitQuote,
SubmitQuoteError,
setQuoteNotes,
findDuplicateQuotes,
} from "./quotes-service";
beforeEach(() => {
clearStore();
});
describe("normalizeCompanyName", () => {
it("lowercases, trims, collapses whitespace, strips trailing punctuation", () => {
expect(normalizeCompanyName(" Acme Holdings. ")).toBe("acme");
});
it("strips common legal suffixes", () => {
expect(normalizeCompanyName("Acme Pty Ltd")).toBe("acme");
expect(normalizeCompanyName("Acme Proprietary Limited")).toBe("acme");
expect(normalizeCompanyName("Acme Corp.")).toBe("acme");
expect(normalizeCompanyName("Acme Incorporated")).toBe("acme");
});
it("returns empty string for blank input", () => {
expect(normalizeCompanyName("")).toBe("");
expect(normalizeCompanyName(" ")).toBe("");
});
});
describe("extractQuoteNumberFromSubject", () => {
it("extracts Q-YYYY-MM-NNN from a fresh quote subject", () => {
expect(extractQuoteNumberFromSubject("Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
});
it("extracts from a reply subject (Re: prefix)", () => {
expect(extractQuoteNumberFromSubject("Re: Quotation Q-2026-06-001")).toBe("Q-2026-06-001");
});
it("returns null when no quote number is present", () => {
expect(extractQuoteNumberFromSubject("Just a normal email")).toBeNull();
expect(extractQuoteNumberFromSubject("Quote Q-26-6-1 (wrong format)")).toBeNull();
});
});
describe("generateQuoteNumberServer", () => {
it("starts the month at 001", async () => {
const n = await generateQuoteNumberServer();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
expect(n).toBe(`Q${year}${month}001`);
});
it("increments on each call", async () => {
const a = await generateQuoteNumberServer();
const b = await generateQuoteNumberServer();
const c = await generateQuoteNumberServer();
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q${now.getFullYear()}${month}`;
expect(a).toBe(`${prefix}001`);
expect(b).toBe(`${prefix}002`);
expect(c).toBe(`${prefix}003`);
});
it("produces unique numbers under concurrent calls (no race)", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, () => generateQuoteNumberServer())
);
const unique = new Set(results);
expect(unique.size).toBe(10);
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const prefix = `Q${now.getFullYear()}${month}`;
for (const n of results) {
expect(n.startsWith(prefix)).toBe(true);
}
});
});
describe("findOrCreateCustomer", () => {
it("finds an existing customer by exact email match", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "buyer@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({
email: "buyer@acme.co.za",
name: "Should Not Be Used",
});
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
expect(customer.customer).toBe("Acme Mining");
});
it("finds by alternative_email when primary email misses", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "primary@acme.co.za",
alternative_email: "alt@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({ email: "alt@acme.co.za" });
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("finds by buyer_email when primary and alt miss", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "primary@acme.co.za",
buyer_email: "buyer@acme.co.za",
company: "Acme",
});
const { customer, created } = await findOrCreateCustomer({ email: "buyer@acme.co.za" });
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("creates a new customer when no match exists", async () => {
// Pre-create the company record the hint refers to — `customer.company`
// is now a relation, so the hint must resolve to a real company id
// (or be left empty).
const company = await pbMock.collection("companies").create({
company: "Unknown Co",
address: "",
vendor: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "new@unknown.co.za",
name: "New Person",
companyHint: "Unknown Co",
});
expect(created).toBe(true);
expect(customer.email).toBe("new@unknown.co.za");
expect(customer.customer).toBe("New Person");
expect(customer.company).toBe(company.id);
expect(customer.description).toMatch(/Auto-created/);
});
it("never fuzzy-merges on company name", async () => {
// Two customers with similar emails from different domains should stay
// separate.
const a = await findOrCreateCustomer({ email: "john@acme.com" });
const b = await findOrCreateCustomer({ email: "jane@beta.com" });
expect(a.customer.id).not.toBe(b.customer.id);
});
it("throws when email is missing", async () => {
await expect(findOrCreateCustomer({ email: "" })).rejects.toThrow(/email is required/);
});
it("resolves an existing customer by person_hint when the sender has no customer row", async () => {
// Gary is a rep — his email has no customer row. He forwards a
// customer's RFQ; the from_email is Gary (no match in the customer
// table) and the actual customer is John Smith. person_hint should
// resolve to John's existing row, NOT create a new customer tied to
// Gary's email.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
name: "Gary",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
expect(customer.customer).toBe("John Smith");
});
it("falls back to sender (Gary) when person_hint has no match", async () => {
// person_hint doesn't resolve to any existing customer. The function
// must NOT silently invent a customer — it falls through to the
// sender-based create-new path. This means a brand-new customer row
// is created tied to Gary's email, which the quoter reassigns in
// the WFDASH UI.
const { customer, created } = await findOrCreateCustomer({
email: "gary-new@wfsales.co.za",
name: "Gary New",
personHint: "Nobody By This Name",
});
expect(created).toBe(true);
expect(customer.email).toBe("gary-new@wfsales.co.za");
expect(customer.customer).toBe("Gary New");
});
it("falls back to existing exact-email match when person_hint is empty", async () => {
// Empty / missing person_hint must not change behaviour — exact email
// match wins, otherwise we create a new customer.
const existing = await pbMock.collection("customers").create({
customer: "Jane Buyer",
email: "jane@buyer.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "jane@buyer.co.za",
personHint: "",
});
expect(created).toBe(false);
expect(customer.id).toBe(existing.id);
});
it("falls back to existing exact-email match when person_hint matches a different customer but sender is already the right one", async () => {
// If the from_email already resolves to the right customer (rare
// but possible — Gary forwards his own email to himself), we
// shouldn't churn. Exact-email still wins because it's the first
// resolution pass.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@abcmining.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "john@abcmining.co.za",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
});
it("tie-breaks person_hint candidates by companyHint", async () => {
// Two customers named "John Smith" exist. companyHint should pick
// the one whose company matches.
const acmeCo = await pbMock.collection("companies").create({
company: "Acme Mining",
address: "",
vendor: "",
});
await pbMock.collection("companies").create({
company: "Other Co",
address: "",
vendor: "",
});
const johnAcme = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@acme-personal.co.za",
company: acmeCo.id,
});
await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@other.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "John Smith",
companyHint: "Acme Mining",
});
expect(created).toBe(false);
expect(customer.id).toBe(johnAcme.id);
});
it("person_hint wins over the sender's existing email match", async () => {
// The real-world bug this fixes: Gary (gary@wfsales.co.za) has his
// own customer row from years of forwarding. Before the fix, the
// email-exact-match check ran BEFORE person_hint, so every
// Gary-forwarded RFQ landed on Gary's row even when the subject
// named the actual customer. After the fix, person_hint resolves
// first when it's set (the n8n workflow only sets it for forwarded
// emails where the named person is the real customer), so the
// quoted-for person wins.
const gary = await pbMock.collection("customers").create({
customer: "Gary",
email: "gary@wfsales.co.za",
company: "",
});
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
expect(customer.id).not.toBe(gary.id);
});
it("token-drop: 3-token subject 'Jose Elia Redpath' finds 2-token customer 'Elia Jose'", async () => {
// Gary's bare-subject format is "FirstName LastName Company" — the
// pure-substring match on the whole string misses because the
// customer table stores "LastName FirstName" (SA convention). The
// token-drop fallback walks the prefix ladder (3 → 2 → 1 token)
// and finds "Elia Jose" via the single-token rung ("Jose").
const elia = await pbMock.collection("customers").create({
customer: "Elia Jose",
email: "Elia.Jose@redpathmining.com",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "Jose Elia Redpath",
});
expect(created).toBe(false);
expect(customer.id).toBe(elia.id);
});
it("token-drop: single-token rung only fires for 2- or 3-token hints", async () => {
// A 5-token sentence hint ("Please quote John Smith urgently")
// must NOT degrade to matching every "John" in the database. The
// single-token rung is gated to 2- and 3-token hints only, so
// longer hints stay on their full prefix ladder and either match
// on a multi-token rung or fall through.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
await pbMock.collection("customers").create({
customer: "Bob Johnson",
email: "bob@bobcorp.co.za",
company: "",
});
// "John Smith" should match via the 2-token rung (whole string is
// already a substring of the customer name). The single-token
// "John" rung would also match Bob Johnson? No — "John" in "Bob
// Johnson" IS a substring. So we need a hint where neither rung
// matches anything: "Xavier Quigley urgently please" → no match.
// Then we expect fall-through to email → new Gary customer.
const result = await findOrCreateCustomer({
email: "gary-fresh@wfsales.co.za",
personHint: "Xavier Quigley urgently please",
});
expect(result.created).toBe(true);
expect(result.customer.email).toBe("gary-fresh@wfsales.co.za");
// sanity: the 2-token rung DOES still work for the simple case
expect(john.id).toBeDefined();
});
});
describe("findOrCreateCompany", () => {
it("finds an existing company by case-insensitive exact match", async () => {
const existing = await pbMock.collection("companies").create({
company: "Acme Holdings",
address: "1 Main Rd",
vendor: "",
});
const { company, created } = await findOrCreateCompany({ name: "acme holdings" });
expect(created).toBe(false);
expect(company?.id).toBe(existing.id);
});
it("finds by normalized match (stripped suffix)", async () => {
const existing = await pbMock.collection("companies").create({
company: "Acme",
address: "1 Main Rd",
vendor: "",
});
const { company, created } = await findOrCreateCompany({ name: "Acme Pty Ltd" });
expect(created).toBe(false);
expect(company?.id).toBe(existing.id);
});
it("creates a new company when no match", async () => {
const { company, created } = await findOrCreateCompany({
name: "Brand New Co",
address: "5 New St",
});
expect(created).toBe(true);
expect(company?.company).toBe("Brand New Co");
expect(company?.address).toBe("5 New St");
});
it("returns null company when name is blank", async () => {
const { company, created } = await findOrCreateCompany({ name: "" });
expect(company).toBeNull();
expect(created).toBe(false);
});
it("never fuzzy-merges on partial overlap", async () => {
await pbMock.collection("companies").create({
company: "Acme Mining Supplies",
address: "",
vendor: "",
});
const { created } = await findOrCreateCompany({ name: "Acme Tools" });
// "Acme" is shared but the full strings are different — must create.
expect(created).toBe(true);
});
});
describe("createQuoteFromEmail", () => {
const basePayload = {
from_email: "buyer@newco.co.za",
from_name: "Buyer Person",
subject: "RFQ: Mining equipment",
body_plain: "Please quote on the following items.",
message_id: "<abc123@example.com>",
in_reply_to: undefined as string | undefined,
received_at: new Date().toISOString(),
extracted_items: [
{ description: "20mm cable ties (pack of 100)", quantity: 5, unit: "pack" },
{ description: "Cable gland M20", quantity: 10, unit: "each" },
],
attachments: [
{
filename: "rfq.pdf",
content_type: "application/pdf",
size: 1024,
// 1 byte of base64 = "AA=="
base64: "AA==",
},
],
};
it("creates a new quote, customer, company, items, and attachments", async () => {
const result = await createQuoteFromEmail({
...basePayload,
company_hint: "NewCo Mining",
});
expect(result.deduped).toBe(false);
expect(result.quote_number).toMatch(/^Q\d{4}\d{2}001$/);
expect(result.items_count).toBe(2);
expect(result.items).toHaveLength(2);
// Attachments are no longer created in this path — the n8n flow calls
// `attachments.upload_for_quote` once per file in a follow-up step, so
// the create call must return 0 to avoid a 5 MB base64 POST that the
// App Router rejects. The caller is responsible for the upload.
expect(result.attachments).toBe(0);
expect(result.customer.email).toBe("buyer@newco.co.za");
expect(result.company?.company).toBe("NewCo Mining");
expect(result.quote.email_sender).toBe("buyer@newco.co.za");
expect(result.quote.email_subject).toBe("RFQ: Mining equipment");
expect(result.quote.email_message_id).toBe("<abc123@example.com>");
expect(result.quote.has_attachments).toBe(true);
expect(result.quote.status).toBe("new");
expect(result.quote.notes).toBe(""); // not a notes blob
});
it("creates AI-extracted items as drafts (is_processed=false, zero prices)", async () => {
const result = await createQuoteFromEmail(basePayload);
const items = await pbMock.collection("quote_items").getFullList({
filter: `quote = "${result.quote.id}"`,
});
expect(items.length).toBe(2);
for (const item of items) {
expect(item.is_processed).toBe(false);
// No fabricated cost or selling price.
expect(Number(item.cost_price)).toBe(0);
expect(Number(item.selling_price)).toBe(0);
}
});
it("preserves the `unit` field from extracted_items (gap fix 2026-08-05)", async () => {
// Before the fix, the `unit` field was dropped during the create loop
// even though `extracted_items` carries it. The agent's gap analysis
// flagged this — confirm the field is now written to the row.
const result = await createQuoteFromEmail({
...basePayload,
extracted_items: [
{ description: "Cable gland M20", quantity: 10, unit: "ea" },
{ description: "Cable ties 200mm", quantity: 2, unit: "pack" },
],
});
expect(result.items).toHaveLength(2);
const units = result.items.map((i) => i.unit).sort();
expect(units).toEqual(["ea", "pack"]);
});
it("returns the actual items array in the result (gap fix 2026-08-05)", async () => {
// Before the fix, result.items was a count. Returning the array lets
// the agent confirm what was created and follow up with quote_items.update.
const result = await createQuoteFromEmail(basePayload);
expect(result.items_count).toBe(2);
expect(result.items).toHaveLength(2);
expect(result.items[0]).toHaveProperty("id");
expect(result.items[0]).toHaveProperty("description");
expect(result.items[0]).toHaveProperty("is_processed", false);
});
it("dedupes by email_message_id — second call returns the same quote", async () => {
const first = await createQuoteFromEmail(basePayload);
const second = await createQuoteFromEmail(basePayload);
expect(second.deduped).toBe(true);
expect(second.quote.id).toBe(first.quote.id);
expect(second.quote_number).toBe(first.quote_number);
});
it("matches an existing customer by email (does not create duplicate)", async () => {
const existing = await pbMock.collection("customers").create({
customer: "Existing Buyer",
email: "buyer@newco.co.za",
company: "Old Co",
});
const result = await createQuoteFromEmail(basePayload);
expect(result.customer.id).toBe(existing.id);
});
it("uses company_hint from payload when provided", async () => {
const result = await createQuoteFromEmail({
...basePayload,
company_hint: " Acme Pty Ltd ",
});
expect(result.company?.company).toBe("Acme Pty Ltd");
});
it("leaves company null when no company_hint is provided (no subject fallback)", async () => {
// The user explicitly does not want us to invent a company name from the
// email subject — that's the kind of fabrication we're trying to avoid.
// When the AI can't find a company in the body, the quoter picks it
// in the form.
const result = await createQuoteFromEmail({
...basePayload,
subject: "RFQ from Acme Mining",
});
expect(result.company).toBeNull();
});
it("tolerates missing from_email, message_id, and subject (treats empty subject as a real value)", async () => {
// The n8n Parse Email node stamps `(no subject)` when the inbound email
// has no Subject header, so WFDASH receives a real string. The schema
// invariant is "subject is a string", not "subject is non-empty" —
// and even an empty string is now tolerated at runtime, because the
// n8n side is contractually obligated to send a placeholder.
await expect(createQuoteFromEmail({ ...basePayload, message_id: "" })).resolves.toBeTruthy();
await expect(createQuoteFromEmail({ ...basePayload, from_email: "" })).resolves.toBeTruthy();
await expect(
createQuoteFromEmail({ ...basePayload, from_email: "", message_id: "" })
).resolves.toBeTruthy();
// Empty subject is now tolerated (was hard-rejected before). The
// office sees a placeholder quote in the queue.
await expect(createQuoteFromEmail({ ...basePayload, subject: "" })).resolves.toBeTruthy();
// Whitespace-only subject is also tolerated — the description is stored
// verbatim so a future regression that drops the Parse Email trim
// doesn't take the whole pipeline down.
await expect(createQuoteFromEmail({ ...basePayload, subject: " " })).resolves.toBeTruthy();
});
it("accepts a placeholder subject like '(no subject)' and stores it on the quote", async () => {
// The n8n side stamps `(no subject)` for emails with no Subject header.
// WFDASH must accept that literal string as a real subject, store it
// verbatim on the quote, and create the row so the office sees it.
const result = await createQuoteFromEmail({
...basePayload,
subject: "(no subject)",
body_plain: "",
extracted_items: [],
});
expect(result.quote.description).toBe("(no subject)");
expect(result.quote.email_subject).toBe("(no subject)");
expect(result.items_count).toBe(0);
expect(result.items).toEqual([]);
});
it("placeholder subject + empty body + empty from_email all combine cleanly", async () => {
// A fully-blank RFC 5321-violating send: no subject, no body, no From.
// All three layers of tolerance combine: placeholder subject, no body,
// sentinel "Unknown Sender" customer. The quote is created.
const result = await createQuoteFromEmail({
from_email: "",
from_name: "",
subject: "(no subject)",
body_plain: "",
body_html: "",
message_id: "<blank-a@example.com>",
received_at: "2026-06-12T18:30:00.000Z",
extracted_items: [],
});
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.quote.description).toBe("(no subject)");
expect(result.quote.email_subject).toBe("(no subject)");
expect(result.quote.email_sender).toBe("");
expect(result.items_count).toBe(0);
expect(result.items).toEqual([]);
});
it("creates a quote with a sentinel 'Unknown Sender' customer when from_email is empty", async () => {
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
expect(result.deduped).toBe(false);
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
// The literal empty string is stored, not a placeholder — the sentinel
// is only used for the `customer` relation.
expect(result.quote.email_sender).toBe("");
});
it("treats whitespace-only from_email as missing", async () => {
const result = await createQuoteFromEmail({ ...basePayload, from_email: " " });
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
});
it("links the quote to the named customer when a rep forwards with person_hint", async () => {
// Gary (the rep) forwards an inbound RFQ. The customer is John Smith
// at ABC Mining — both already exist. person_hint + company_hint make
// the quote land on the right customer, not on Gary.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const result = await createQuoteFromEmail({
...basePayload,
from_email: "gary@wfsales.co.za",
from_name: "Gary",
subject: "Quote for John Smith at ABC Mining",
person_hint: "John Smith",
company_hint: "ABC Mining",
});
expect(result.customer.id).toBe(john.id);
expect(result.customer.customer).toBe("John Smith");
// Gary's email is still recorded on the quote (audit), but he is NOT
// the linked customer.
expect(result.quote.email_sender).toBe("gary@wfsales.co.za");
});
it("falls back to the sender (rep) when person_hint does not match any customer", async () => {
// The rep names someone we don't have in the database. Don't invent —
// land on the rep so the quote stays attributable; the quoter
// reassigns in the UI.
const result = await createQuoteFromEmail({
...basePayload,
from_email: "gary@wfsales.co.za",
from_name: "Gary",
subject: "Quote for Someone New",
person_hint: "Someone New",
});
expect(result.customer.email).toBe("gary@wfsales.co.za");
expect(result.customer.customer).toBe("Gary");
});
it("two emails with no from_email both link to the same sentinel customer", async () => {
// Different subjects bypass the message_id dedup, so two distinct
// quotes are created — but both should reuse the same sentinel.
const r1 = await createQuoteFromEmail({
...basePayload,
from_email: "",
subject: "Unknown sender email A",
message_id: "<no-from-a@example.com>",
});
const r2 = await createQuoteFromEmail({
...basePayload,
from_email: "",
subject: "Unknown sender email B",
message_id: "<no-from-b@example.com>",
});
expect(r1.deduped).toBe(false);
expect(r2.deduped).toBe(false);
expect(r1.quote.id).not.toBe(r2.quote.id);
expect(r1.customer.id).toBe(r2.customer.id);
});
it("a real from_email still wins over the sentinel", async () => {
const result = await createQuoteFromEmail({
...basePayload,
from_email: "real-buyer@somewhere.co.za",
});
expect(result.customer.email).toBe("real-buyer@somewhere.co.za");
expect(result.customer.customer).not.toBe("Unknown Sender");
});
it("existing sentinel survives a fresh run (no duplicate Unknown Sender row)", async () => {
// Pre-create the sentinel so the findOrCreate path hits the existing
// branch. Verify no second customer is created.
const customersBefore = await pbMock.collection("customers").getFullList();
await pbMock.collection("customers").create({
customer: "Unknown Sender",
email: "unknown-sender@no-from.wfdash.local",
company: "Unknown",
description: "Pre-existing sentinel.",
});
const customersAfter = await pbMock.collection("customers").getFullList();
expect(customersAfter.length).toBe(customersBefore.length + 1);
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.customer.description).toBe("Pre-existing sentinel.");
const customersFinal = await pbMock.collection("customers").getFullList();
expect(customersFinal.length).toBe(customersAfter.length);
});
it("synthesizes a stable message_id when the inbound email has no Message-ID header", async () => {
// iCloud / Mail.app sometimes strip the Message-ID header. The pipeline
// must still create the quote and dedup correctly on retry.
const noId1 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
const noId2 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
expect(noId1.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
expect(noId2.deduped).toBe(true);
expect(noId2.quote.id).toBe(noId1.quote.id);
});
it("treats whitespace-only message_id as missing and synthesizes", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: " ",
});
expect(result.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
});
it("keeps a real (non-empty) message_id verbatim — never synthesizes over a real one", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: "<real-msg-id@mail.example.com>",
});
expect(result.quote.email_message_id).toBe("<real-msg-id@mail.example.com>");
});
});
describe("appendReplyToQuote", () => {
it("matches by In-Reply-To header (strongest signal)", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Quotation needed",
body_plain: "",
message_id: "<orig@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Re: Some subject without a number",
body_plain: "Thanks, looks good.",
message_id: "<reply1@example.com>",
in_reply_to: "<orig@example.com>",
received_at: new Date().toISOString(),
});
expect(result.matched_by).toBe("in_reply_to");
expect(result.quote?.id).toBe(created.quote.id);
expect((result.reply as Record<string, unknown>).in_reply_to).toBe("<orig@example.com>");
});
it("falls back to subject regex when In-Reply-To is missing", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@b.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig2@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@b.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "OK, please proceed.",
message_id: "<reply2@example.com>",
});
expect(result.matched_by).toBe("subject");
expect(result.quote?.id).toBe(created.quote.id);
});
it("updates has_new_reply and last_reply_at on the matched quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@c.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig3@example.com>",
});
const replyDate = "2026-06-11T10:00:00.000Z";
await appendReplyToQuote({
from_email: "buyer@c.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Approved.",
message_id: "<reply3@example.com>",
received_at: replyDate,
});
const updated = await pbMock.collection("quotes").getOne(created.quote.id);
expect(updated.has_new_reply).toBe(true);
expect(updated.last_reply_at).toBe(replyDate);
});
it("returns matched_by='none' when no match — caller moves to Unmatched", async () => {
const result = await appendReplyToQuote({
from_email: "unknown@x.com",
subject: "Some random reply",
body_plain: "Hello",
});
expect(result.matched_by).toBe("none");
expect(result.quote).toBeNull();
expect(result.reply).toBeNull();
});
it("stores the reply as a structured quote_replies row, not as notes text", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@d.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig4@example.com>",
});
await appendReplyToQuote({
from_email: "buyer@d.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Reply body content",
quoted_text: "Original quoted text",
message_id: "<reply4@example.com>",
});
const replies = await pbMock.collection("quote_replies").getFullList({
filter: `quote = "${created.quote.id}"`,
});
expect(replies.length).toBe(1);
expect(replies[0].body_plain).toBe("Reply body content");
expect(replies[0].quoted_text).toBe("Original quoted text");
// The quote's notes field should NOT contain the reply body.
expect(created.quote.notes).toBe("");
});
});
describe("findQuoteByMessageId", () => {
it("returns the matching quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@e.com",
subject: "Need a quote",
body_plain: "",
message_id: "<findme@example.com>",
});
const found = await findQuoteByMessageId("<findme@example.com>");
expect(found?.id).toBe(created.quote.id);
});
it("returns null when no match", async () => {
const found = await findQuoteByMessageId("<nope@example.com>");
expect(found).toBeNull();
});
it("returns null for empty Message-ID", async () => {
expect(await findQuoteByMessageId("")).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Outbound send-email pipeline
// ─────────────────────────────────────────────────────────────────────────────
/**
* Helper: create a quote in the requested status with a real-looking
* customer + PDF already attached. The pipeline tests below mutate
* this base case (delete the PDF, blank the email, etc.) to exercise
* specific failure paths.
*/
async function seedReadyQuote(overrides: {
status?: string;
customerEmail?: string | null;
customerName?: string;
withPdf?: boolean;
/**
* Whether to seed at least one ready `quote_items` row. Defaults
* to true because `sendQuoteEmail` now runs a pre-flight readiness
* check (`no_items` / `items_not_ready`) before the PDF-presence
* check. Tests that need to exercise the "no PDF" path should
* set `withItem: true` so the only failure is the missing PDF.
* Tests that NEED a no-items quote can pass `withItem: false`.
*/
withItem?: boolean;
} = {}) {
const customer = await pbMock.collection("customers").create({
customer: overrides.customerName ?? "Acme Mining",
email:
overrides.customerEmail === null
? ""
: overrides.customerEmail ?? "buyer@acme.co.za",
alternative_email: "",
buyer_email: "",
number: "",
company: "Acme",
dateadded: "",
lastvisiteddate: "",
rep: "",
coordinates: "",
description: "",
});
const q = await pbMock.collection("quotes").create({
quote_number: "Q-2026-06-099",
customer: customer.id,
status: overrides.status ?? "approval_pending",
notes: "",
email_sender: "",
email_subject: "",
email_body: "",
has_attachments: false,
company_letterhead: "CNL",
});
if (overrides.withPdf !== false) {
await pbMock.collection("quote_attachments").create({
quote: q.id,
filename: "quote.pdf",
file: ["pdf-token"],
content_type: "application/pdf",
size_bytes: 12345,
email_message_id: "",
kind: "pdf",
uploaded_at: new Date().toISOString(),
});
}
if (overrides.withItem !== false) {
// Seed one ready line so the pre-flight readiness check passes
// and the send path can actually reach the PDF-presence / status
// checks below. Matches the pre-flight gates in
// `diagnosePdfReadiness` (is_processed, supplier, cost>0,
// selling>0).
await pbMock.collection("quote_items").create({
quote: q.id,
quote_number: "Q-2026-06-099",
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
sku: "M16HEX",
unit: "EA",
notes: "",
pricing_source: "manual",
});
}
return { quote: q, customer };
}
describe("sendQuoteEmail", () => {
it("flips status to sending on a happy path and persists composition", async () => {
const { quote } = await seedReadyQuote();
const result = await sendQuoteEmail(quote.id, {
cc: "buyer2@acme.co.za, ceo@acme.co.za",
bcc: "rep@wfdash.local",
subject: "Re: Quote Q-2026-06-099",
message: "Please find attached.",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure).toBeNull();
expect(result.quote.status).toBe("sending");
expect(result.quote.email_cc).toBe("buyer2@acme.co.za, ceo@acme.co.za");
expect(result.quote.email_subject_outbound).toBe("Re: Quote Q-2026-06-099");
expect(result.quote.attach_auth_letters).toBe(true);
expect(result.quote.sending_started_at).toBeTruthy();
expect(result.quote.email_error).toBe("");
});
it("captures selectedAttachmentIds into email_outbound_attachment_ids JSON, filters foreign tokens", async () => {
const { quote } = await seedReadyQuote();
// Operator-uploaded files now live on the quote row's
// `outbound_attachments` file field as PB tokens
// (`<random>_<originalName>`), NOT as `quote_attachments` rows.
// `sendQuoteEmail` validates the modal's `selectedAttachmentIds`
// against that field — foreign tokens (different quote or just
// made up) are silently dropped.
await pbMock.collection("quotes").update(quote.id, {
outbound_attachments: ["spec_t1.pdf", "drawing_t2.dwg"],
});
// A token that doesn't belong to this quote — must be filtered.
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: ["spec_t1.pdf", "drawing_t2.dwg", "smuggled_t3.pdf"],
});
expect(result.failure).toBeNull();
const parsed = JSON.parse(result.quote.email_outbound_attachment_ids);
expect(parsed.sort()).toEqual(["spec_t1.pdf", "drawing_t2.dwg"].sort());
expect(parsed).not.toContain("smuggled_t3.pdf");
});
it("rejects with no_recipient when customer has no email and no alternative", async () => {
const { quote } = await seedReadyQuote({ customerEmail: "" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("no_recipient");
expect(result.quote.status).toBe("mail_failed");
expect(result.quote.email_error).toMatch(/No valid recipient/);
expect(result.quote.email_failed_at).toBeTruthy();
});
it("falls back to quote.email_sender when customer has no email and no alternative", async () => {
// The walk-in scenario: no customer.email set, but the original
// inbound from: address is stashed on quote.email_sender. The
// service should walk to source #4 and not fail with no_recipient.
const { quote } = await seedReadyQuote({
customerEmail: "",
customerName: "Walk-in Customer",
});
// Stamp the quote's email_sender directly (the seed doesn't expose it).
await pbMock.collection("quotes").update(quote.id, {
email_sender: "walkin@buyer.co.za",
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
expect(result.failure).toBeNull();
expect(result.quote.status).toBe("sending");
});
it("rejects with placeholder_customer when customer is the Unknown Sender sentinel", async () => {
const sentinel = await pbMock.collection("customers").create({
id: "dcpb38w4mk6vrm5",
customer: "Unknown Sender",
email: "buyer@x.com",
alternative_email: "",
buyer_email: "",
number: "",
company: "",
dateadded: "",
lastvisiteddate: "",
rep: "",
coordinates: "",
description: "",
});
const q = await pbMock.collection("quotes").create({
quote_number: "Q-2026-06-100",
customer: sentinel.id,
status: "approval_pending",
});
const result = await sendQuoteEmail(q.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("placeholder_customer");
expect(result.quote.status).toBe("mail_failed");
});
it("rejects with invalid_recipient when CC contains garbage", async () => {
const { quote } = await seedReadyQuote();
const result = await sendQuoteEmail(quote.id, {
cc: "good@x.com, not-an-email, also-bad",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("invalid_recipient");
expect(result.failure?.message).toContain("not-an-email");
expect(result.quote.status).toBe("mail_failed");
});
it("rejects with no_pdf when no PDF attachment exists", async () => {
const { quote } = await seedReadyQuote({ withPdf: false });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("no_pdf");
expect(result.quote.status).toBe("mail_failed");
});
it("rejects with invalid_status when quote is already emailed", async () => {
const { quote } = await seedReadyQuote({ status: "emailed" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("invalid_status");
expect(result.quote.status).toBe("mail_failed");
});
it("returns quote_not_found for a bogus id", async () => {
const result = await sendQuoteEmail("does-not-exist", {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("quote_not_found");
});
});
/**
* Stale-PDF auto-regen (added 2026-09-07).
*
* Bug: the operator edits markup on the approval screen, then
* clicks Approve & Send. The previous `sendQuoteEmail` path
* looked up the most recent `kind="pdf"` attachment and shipped
* it as-is — the price changes never made it into the email.
*
* Fix: before flipping to `sending`, the service runs a
* staleness probe and re-renders via `generateQuotePdf` when any
* priced item or the quote row itself has been updated after the
* PDF. If the regen fails the send is refused (we don't fall
* back to the stale PDF). The probe is mocked out via
* `isQuotePdfStale` so each test below controls exactly which
* branch is exercised.
*/
type PdfServiceMocks = {
isQuotePdfStaleMock: ReturnType<typeof vi.fn>;
generateQuotePdfMock: ReturnType<typeof vi.fn>;
};
describe("sendQuoteEmail stale-PDF auto-regen", () => {
/**
* Reach into the vi.mock factory for the pdf service and force
* the probe / render functions to return what THIS test wants.
* Resets to defaults at the end so neighbouring tests are
* unaffected.
*/
async function withMocks(opts: {
isStale: boolean;
regenOutcome?: "ok" | "items_not_ready" | "placeholder_customer" | "render_error";
}) {
const { isQuotePdfStaleMock, generateQuotePdfMock } = (await import(
"./quote-pdf-service"
)) as unknown as PdfServiceMocks;
(isQuotePdfStaleMock as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
opts.isStale,
);
const regenKind = opts.regenOutcome ?? "ok";
if (regenKind === "ok") {
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: "ok",
attachmentId: "fresh-pdf-id",
filename: "Quote-CNL-regen.pdf",
status: "approval_pending",
pdfGeneratedAt: new Date().toISOString(),
skippedItemIds: [],
readyItemCount: 1,
totalItemCount: 1,
isPartial: false,
});
} else if (regenKind === "items_not_ready") {
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: "readiness_failure",
code: "items_not_ready",
message: "1 item missing supplier",
details: {
itemIds: ["item-1"],
issues: [
{
itemId: "item-1",
description: "M16 hex bolt",
missing: ["no supplier"],
},
],
},
});
} else if (regenKind === "placeholder_customer") {
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: "readiness_failure",
code: "placeholder_customer",
message: "Quote pointed at placeholder customer",
details: { customerId: "cust-1", customerName: "Unknown Sender" },
});
} else {
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: "render_error",
message: "Render crashed",
detail: "JSX out of memory",
});
}
}
function clearMocks() {
return Promise.all([
import("./quote-pdf-service").then((mod) => {
const { isQuotePdfStaleMock, generateQuotePdfMock } =
mod as unknown as PdfServiceMocks;
(isQuotePdfStaleMock as unknown as ReturnType<typeof vi.fn>).mockReset();
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).mockReset();
}),
]);
}
it("regenerates the PDF when items have changed since the last render", async () => {
const { quote } = await seedReadyQuote();
await withMocks({ isStale: true, regenOutcome: "ok" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure).toBeNull();
expect(result.quote.status).toBe("sending");
const { generateQuotePdfMock } = (await import("./quote-pdf-service")) as unknown as PdfServiceMocks;
expect(generateQuotePdfMock).toHaveBeenCalledTimes(1);
expect(generateQuotePdfMock).toHaveBeenCalledWith(quote.id);
await clearMocks();
});
it("does NOT regenerate when the PDF is fresh", async () => {
const { quote } = await seedReadyQuote();
await withMocks({ isStale: false, regenOutcome: "ok" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure).toBeNull();
expect(result.quote.status).toBe("sending");
const { generateQuotePdfMock } = (await import("./quote-pdf-service")) as unknown as PdfServiceMocks;
expect(generateQuotePdfMock).not.toHaveBeenCalled();
await clearMocks();
});
it("refuses to send when the regen hits items_not_ready", async () => {
// Operator blanked a price between the last render and the
// send click. We refuse to ship the stale PDF and surface
// the per-line issue so the operator sees exactly what to
// fix.
const { quote } = await seedReadyQuote();
await withMocks({ isStale: true, regenOutcome: "items_not_ready" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("items_not_ready");
expect(result.failure?.message).toMatch(/Could not regenerate the PDF/);
expect(result.failure?.details?.issues?.[0].missing).toContain("no supplier");
expect(result.quote.status).toBe("mail_failed");
await clearMocks();
});
it("refuses to send when the regen hits placeholder_customer", async () => {
// The customer got swapped to the Unknown Sender sentinel
// between the last render and the send click. We refuse to
// ship the stale PDF — the operator must fix the customer
// pointer first.
const { quote } = await seedReadyQuote();
await withMocks({ isStale: true, regenOutcome: "placeholder_customer" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("placeholder_customer");
expect(result.quote.status).toBe("mail_failed");
await clearMocks();
});
it("refuses to send when the render itself crashes", async () => {
const { quote } = await seedReadyQuote();
await withMocks({ isStale: true, regenOutcome: "render_error" });
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: true,
selectedAttachmentIds: [],
});
expect(result.failure).not.toBeNull();
expect(result.failure?.message).toMatch(/PDF regeneration failed/);
expect(result.quote.status).toBe("mail_failed");
await clearMocks();
});
});
/**
* diagnosePdfReadiness is private to quotes-service.ts, but it's
* reachable through sendQuoteEmail when the quote has no PDF. We use
* that path to test the per-line readiness logic — the goal of these
* cases is to verify that an `is_no_quote` quote can be sent even
* though its items have zero prices / aren't processed, while the
* "no supplier" gate stays active (a real data-quality issue even
* on a decline-quote).
*/
describe("diagnosePdfReadiness with is_no_quote", () => {
async function seedForReadinessCheck(opts: {
isNoQuote: boolean;
items: Array<{
description: string;
cost_price?: number;
selling_price?: number;
is_processed?: boolean;
supplier?: string;
}>;
}) {
const customer = await pbMock.collection("customers").create({
customer: "Acme Mining",
email: "buyer@acme.co.za",
alternative_email: "",
buyer_email: "",
number: "",
company: "Acme",
dateadded: "",
lastvisiteddate: "",
rep: "",
coordinates: "",
description: "",
});
const q = await pbMock.collection("quotes").create({
quote_number: "Q-2026-06-NOQ",
customer: customer.id,
status: "approval_pending",
is_no_quote: opts.isNoQuote,
notes: "",
email_sender: "",
email_subject: "",
email_body: "",
has_attachments: false,
company_letterhead: "CNL",
});
// No PDF — force the diagnosePdfReadiness path inside sendQuoteEmail.
for (const it of opts.items) {
await pbMock.collection("quote_items").create({
quote: q.id,
quote_number: "Q-2026-06-NOQ",
description: it.description,
quantity: 1,
cost_price: it.cost_price ?? 0,
selling_price: it.selling_price ?? 0,
markup: 0,
supplier: it.supplier ?? "",
is_processed: it.is_processed ?? false,
sku: "",
unit: "EA",
notes: "",
pricing_source: "manual",
});
}
return { quote: q, customer };
}
it("returns no items_not_ready issue when is_no_quote is true and zero prices", async () => {
const { quote } = await seedForReadinessCheck({
isNoQuote: true,
items: [
{
description: "M16 bolt",
cost_price: 0,
selling_price: 0,
is_processed: false,
supplier: "sup-acme",
},
],
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
// With the relaxation, the per-line pricing/processed checks are
// skipped — and the supplier field is set so no other issue fires.
// The diagnosePdfReadiness function falls through to "no_pdf".
expect(result.failure?.code).not.toBe("items_not_ready");
expect(result.failure?.code).toBe("no_pdf");
});
it("no longer flags a missing supplier, even on an is_no_quote decline", async () => {
// The supplier block is GONE. It used to be the hardest gate in
// the PDF path, and it was wrong for the commonest workflow: a
// delivery note raised at the counter has no parent order, so its
// lines frequently carry no supplier at all. The operator could
// quote the note, watch the quote appear, and then be refused a
// PDF for a field the counter form never asked them to fill in.
// The supplier is still PRINTED when one is present; we just no
// longer refuse to render without it.
const { quote } = await seedForReadinessCheck({
isNoQuote: true,
items: [
{
description: "M16 bolt",
cost_price: 0,
selling_price: 0,
is_processed: false,
// supplier intentionally blank — must NOT flag any more.
},
],
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
// With the pricing/processed checks skipped for a decline AND the
// supplier no longer gating, nothing blocks: the check falls
// through to "no_pdf".
expect(result.failure?.code).not.toBe("items_not_ready");
expect(result.failure?.code).toBe("no_pdf");
});
it("does not flag a missing supplier on a normally-priced quote either", async () => {
// A priced, processed line with no supplier used to be blocked
// outright. That is the exact case a counter note produces.
const { quote } = await seedForReadinessCheck({
isNoQuote: false,
items: [
{
description: "M16 bolt",
cost_price: 100,
selling_price: 125,
is_processed: true,
},
],
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
expect(result.failure?.code).not.toBe("items_not_ready");
});
it("still refuses an unpriced line when is_no_quote is false (regression)", async () => {
// The supplier relaxation must not have blunted the pricing gate.
// This is the check that keeps its teeth: a line nobody priced
// still blocks the PDF.
const { quote } = await seedForReadinessCheck({
isNoQuote: false,
items: [
{
description: "M16 bolt",
cost_price: 0,
selling_price: 0,
is_processed: false,
},
],
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("items_not_ready");
const missing = result.failure?.details?.issues?.[0]?.missing ?? [];
expect(missing).toContain("cost price");
expect(missing).toContain("selling price");
// And never the supplier, whatever else is wrong.
expect(missing).not.toContain("no supplier");
});
it("still flags items_not_ready when is_no_quote is false and zero prices (regression)", async () => {
const { quote } = await seedForReadinessCheck({
isNoQuote: false,
items: [
{
description: "M16 bolt",
cost_price: 0,
selling_price: 0,
is_processed: false,
supplier: "sup-acme",
},
],
});
const result = await sendQuoteEmail(quote.id, {
cc: "",
bcc: "",
subject: "",
message: "",
attachAuthLetters: false,
selectedAttachmentIds: [],
});
expect(result.failure?.code).toBe("items_not_ready");
const missing = result.failure?.details?.issues?.[0]?.missing ?? [];
expect(missing).toContain("not processed");
expect(missing).toContain("cost price");
expect(missing).toContain("selling price");
});
});
describe("retryQuoteEmail", () => {
it("flips a mail_failed quote back to sending and clears error", async () => {
const { quote } = await seedReadyQuote({ status: "mail_failed" });
await pbMock.collection("quotes").update(quote.id, {
email_error: "[smtp] Connection refused",
email_failed_at: "2026-06-23T10:00:00.000Z",
});
const result = await retryQuoteEmail(quote.id);
expect(result.failure).toBeNull();
expect(result.quote.status).toBe("sending");
expect(result.quote.email_error).toBe("");
expect(result.quote.email_failed_at).toBe("");
expect(result.quote.sending_started_at).toBeTruthy();
});
it("rejects with invalid_status when the quote isn't mail_failed", async () => {
const { quote } = await seedReadyQuote({ status: "approval_pending" });
const result = await retryQuoteEmail(quote.id);
expect(result.failure?.code).toBe("invalid_status");
});
it("rejects with quote_not_found for a bogus id", async () => {
const result = await retryQuoteEmail("missing");
expect(result.failure?.code).toBe("quote_not_found");
});
});
describe("recordSendOutcome", () => {
it("flips to emailed on success + stamps message_id + clears error", async () => {
const { quote } = await seedReadyQuote({ status: "sending" });
await pbMock.collection("quotes").update(quote.id, {
sending_started_at: new Date().toISOString(),
});
const updated = await recordSendOutcome({
quoteId: quote.id,
outcome: "success",
messageId: "<out-123@wfdash.local>",
});
expect(updated.status).toBe("emailed");
expect(updated.email_delivered).toBe(true);
expect(updated.email_delivered_at).toBeTruthy();
expect(updated.email_outbound_message_id).toBe("<out-123@wfdash.local>");
expect(updated.email_error).toBe("");
expect(updated.sending_started_at).toBe("");
});
it("flips to mail_failed on SMTP failure + records reason + clears sending_started_at", async () => {
const { quote } = await seedReadyQuote({ status: "sending" });
await pbMock.collection("quotes").update(quote.id, {
sending_started_at: new Date().toISOString(),
});
const updated = await recordSendOutcome({
quoteId: quote.id,
outcome: "failure",
errorMessage: "550 5.1.1 User unknown",
messageId: "<out-failed@wfdash.local>",
});
expect(updated.status).toBe("mail_failed");
expect(updated.email_failed_at).toBeTruthy();
expect(updated.email_error).toMatch(/550 5\.1\.1 User unknown/);
expect(updated.email_outbound_message_id).toBe("<out-failed@wfdash.local>");
expect(updated.sending_started_at).toBe("");
});
it("truncates long error messages so we don't exceed PB's 5000-byte cap", async () => {
const { quote } = await seedReadyQuote({ status: "sending" });
const huge = "x".repeat(10_000);
const updated = await recordSendOutcome({
quoteId: quote.id,
outcome: "failure",
errorMessage: huge,
});
expect(updated.email_error.length).toBeLessThan(5000);
});
});
// ----------------------------------------------------------------------------
// Quotes dashboard read model — tab filter, counts cache.
//
// Same surface as the orders dashboard tests: prove the server
// narrows by tab (so the footer pagination matches the badge count)
// and that the counts cache works.
// ----------------------------------------------------------------------------
describe("QUOTE_TAB_STATUSES (canonical tab → statuses mapping)", () => {
it("absorbs cancelled + rejected under the cancelled tab (was bucketed under completed before)", async () => {
// Regression guard for the long-standing bug where the
// Cancelled tab badge was always empty: the old
// getQuoteStatusCounts mapped `cancelled` and `rejected` to
// "completed", so the page's "cancelled" tab saw zero count
// even when 60+ rejected quotes existed.
expect(QUOTE_TAB_STATUSES.cancelled).toEqual(
expect.arrayContaining(["cancelled", "rejected"]),
);
expect(QUOTE_TAB_STATUSES.completed).not.toContain("cancelled");
expect(QUOTE_TAB_STATUSES.completed).not.toContain("rejected");
});
it("includes every status the page tab renders, with no tab having an empty status list unless it's a special tab", async () => {
// delivery_note, all, templates, and completed_recent are
// special-cased (no status filter, or filter is built from
// `completed` + a date cutoff). Every other tab must have at
// least one status.
for (const tab of QUOTE_TAB_ORDER) {
if (
tab === "delivery_note" ||
tab === "all" ||
tab === "templates" ||
tab === "completed_recent"
) {
expect(QUOTE_TAB_STATUSES[tab]).toEqual([]);
} else {
expect(QUOTE_TAB_STATUSES[tab].length).toBeGreaterThan(0);
}
}
});
});
describe("getQuoteStatusTabCounts", () => {
beforeEach(() => clearQuoteStatusTabCountsCache());
it("buckets quotes into the right tab based on status", async () => {
await pbMock.collection("quotes").create({
id: "q-emailed", quote_number: "Q1", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-emailed-2", quote_number: "Q2", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-approved", quote_number: "Q3", status: "approved",
});
await pbMock.collection("quotes").create({
id: "q-rejected", quote_number: "Q4", status: "rejected",
});
await pbMock.collection("quotes").create({
id: "q-cancelled", quote_number: "Q5", status: "cancelled",
});
await pbMock.collection("quotes").create({
id: "q-new", quote_number: "Q6", status: "new",
});
const counts = await getQuoteStatusTabCounts(pbMock as never);
expect(counts.completed).toBe(3); // 2 emailed + 1 approved
expect(counts.cancelled).toBe(2); // 1 rejected + 1 cancelled
expect(counts.new).toBe(1);
});
it("returns the same counts on a second call (cache hit)", async () => {
await pbMock.collection("quotes").create({
id: "q1", quote_number: "Q1", status: "emailed",
});
const first = await getQuoteStatusTabCounts(pbMock as never);
// Mutate the underlying store — the cache should still serve
// the stale value.
await pbMock.collection("quotes").create({
id: "q2", quote_number: "Q2", status: "emailed",
});
const cached = await getQuoteStatusTabCounts(pbMock as never);
expect(cached.completed).toBe(first.completed);
// Bypass the cache to see the new state.
const fresh = await getQuoteStatusTabCounts(pbMock as never, { bypassCache: true });
expect(fresh.completed).toBe(first.completed + 1);
});
it("can skip archive-only tabs for the main quotes page", async () => {
await pbMock.collection("quotes").create({
id: "q-completed", quote_number: "Q1", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-new", quote_number: "Q2", status: "new",
});
const counts = await getQuoteStatusTabCounts(pbMock as never, {
bypassCache: true,
includeArchivedTabs: false,
});
expect(counts.new).toBe(1);
expect(counts.completed).toBe(0);
expect(counts.all).toBe(0);
});
});
describe("getQuotesDashboardData — end-to-end", () => {
beforeEach(() => clearQuoteStatusTabCountsCache());
it("returns tab-scoped items + global counts in a single response", async () => {
// Set up: 1 emailed, 1 approved (both → completed tab), 1 new
// (new tab), 1 rejected (cancelled tab).
await pbMock.collection("quotes").create({
id: "q1", quote_number: "Q1", status: "emailed",
created: "2026-01-01T00:00:00Z",
});
await pbMock.collection("quotes").create({
id: "q2", quote_number: "Q2", status: "approved",
created: "2026-01-02T00:00:00Z",
});
await pbMock.collection("quotes").create({
id: "q3", quote_number: "Q3", status: "new",
created: "2026-01-03T00:00:00Z",
});
await pbMock.collection("quotes").create({
id: "q4", quote_number: "Q4", status: "rejected",
created: "2026-01-04T00:00:00Z",
});
const result = await getQuotesDashboardData({
page: 1,
perPage: 20,
tab: "completed",
});
// Counts are global (always were) — the badge still tells the truth.
expect(result.counts.completed).toBe(2);
expect(result.counts.new).toBe(1);
expect(result.counts.cancelled).toBe(1);
// Items are scoped to the active tab.
expect(result.items.map((q) => q.id).sort()).toEqual(["q1", "q2"]);
expect(result.totalItems).toBe(2);
expect(result.totalPages).toBe(1);
});
it("scopes the Cancelled tab to rejected + cancelled statuses (the long-standing bug)", async () => {
await pbMock.collection("quotes").create({
id: "q-rejected", quote_number: "Q1", status: "rejected",
});
await pbMock.collection("quotes").create({
id: "q-cancelled", quote_number: "Q2", status: "cancelled",
});
await pbMock.collection("quotes").create({
id: "q-emailed", quote_number: "Q3", status: "emailed",
});
const result = await getQuotesDashboardData({
page: 1,
perPage: 20,
tab: "cancelled",
});
expect(result.counts.cancelled).toBe(2); // rejected + cancelled both counted here
expect(result.counts.completed).toBe(1); // emailed stays under completed
expect(result.items.map((q) => q.id).sort()).toEqual([
"q-cancelled",
"q-rejected",
]);
});
it("can omit archive counts for the main quotes page", async () => {
await pbMock.collection("quotes").create({
id: "q-completed", quote_number: "Q1", status: "emailed",
created: "2026-01-01T00:00:00Z",
});
await pbMock.collection("quotes").create({
id: "q-new", quote_number: "Q2", status: "new",
created: "2026-01-02T00:00:00Z",
});
const result = await getQuotesDashboardData({
page: 1,
perPage: 20,
tab: "new",
includeArchivedCounts: false,
});
expect(result.counts.new).toBe(1);
expect(result.counts.completed).toBe(0);
expect(result.counts.all).toBe(0);
});
});
// ----------------------------------------------------------------------------
// Quotes archive — read model for /quotes/archive.
//
// The archive holds 7,849 of 8,466 quotes (the "completed" and "all"
// tabs on the main view). These tests pin the matching set and the
// view toggle.
// ----------------------------------------------------------------------------
describe("getQuotesArchiveData", () => {
it("returns completed quotes by default with the 'completed' view header counts", async () => {
// 2 emailed (completed), 1 new (active), 1 cancelled (bucketed
// under cancelled in the original tab mapping — counts toward
// the 'all' view but not 'completed').
await pbMock.collection("quotes").create({
id: "q-1", quote_number: "Q-2025-001", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-2", quote_number: "Q-2025-002", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-3", quote_number: "Q-2025-003", status: "new",
});
await pbMock.collection("quotes").create({
id: "q-4", quote_number: "Q-2025-004", status: "cancelled",
});
const result = await getQuotesArchiveData({ page: 1, perPage: 20 });
// Default view is "completed" — only the 2 emailed quotes.
expect(result.view).toBe("completed");
expect(result.items.map((q) => q.id).sort()).toEqual(["q-1", "q-2"]);
expect(result.totalItems).toBe(2);
expect(result.counts.completed).toBe(2);
expect(result.counts.all).toBe(4); // 2 emailed + 1 new + 1 cancelled
});
it("returns every counted-status quote for the 'all' view", async () => {
await pbMock.collection("quotes").create({
id: "q-new", quote_number: "Q-2025-NEW", status: "new",
});
await pbMock.collection("quotes").create({
id: "q-emailed", quote_number: "Q-2025-EM", status: "emailed",
});
await pbMock.collection("quotes").create({
id: "q-problem", quote_number: "Q-2025-PR", status: "problem",
});
const result = await getQuotesArchiveData({
page: 1,
perPage: 20,
view: "all",
});
expect(result.view).toBe("all");
expect(result.items.length).toBe(3);
expect(result.counts.all).toBe(3);
});
it("filters by search (quote_number, reference, customer, company)", async () => {
await pbMock.collection("quotes").create({
id: "q-1", quote_number: "Q-FINDME-001", status: "emailed",
quote_reference: "ref-A",
});
await pbMock.collection("quotes").create({
id: "q-2", quote_number: "Q-OTHER-002", status: "emailed",
});
const byNumber = await getQuotesArchiveData({
page: 1, perPage: 20, search: "FINDME",
});
expect(byNumber.items.map((q) => q.id)).toEqual(["q-1"]);
const byRef = await getQuotesArchiveData({
page: 1, perPage: 20, search: "ref-A",
});
expect(byRef.items.map((q) => q.id)).toEqual(["q-1"]);
});
it("returns an empty result when no quotes match the view", async () => {
// All new — no completed quotes to show.
await pbMock.collection("quotes").create({
id: "q-1", quote_number: "Q-2025-001", status: "new",
});
const result = await getQuotesArchiveData({ page: 1, perPage: 20 });
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
expect(result.counts.completed).toBe(0);
});
it("loads the default archive page without a full quote collection scan", async () => {
await pbMock.collection("quotes").create({
id: "q-1", quote_number: "Q-2025-001", status: "emailed",
});
const pbNoFullScan = {
autoCancellation: () => undefined,
collection: (name: string) => {
if (name === "quotes") {
const base = pbMock.collection(name);
return {
...base,
async getFullList(opts: { filter?: string } = {}) {
if ((opts.filter || "").includes('status = "')) {
throw new Error("unexpected full quote scan");
}
return base.getFullList(opts);
},
};
}
return pbMock.collection(name);
},
};
const result = await getQuotesArchiveData(
{ page: 1, perPage: 20 },
pbNoFullScan as never,
);
expect(result.items.map((q) => q.id)).toEqual(["q-1"]);
expect(result.counts.completed).toBe(1);
});
});
describe("findDuplicateQuotes", () => {
/** Helper: create a quote plus its line items in the mock store. */
const seedQuote = async (
quote: Record<string, unknown>,
items: Array<Record<string, unknown>> = [],
) => {
await pbMock.collection("quotes").create(quote);
for (const item of items) {
await pbMock.collection("quote_items").create({
quote: quote.id,
...item,
});
}
};
it("flags the same item list quoted for two DIFFERENT companies", async () => {
// The core mining-tender case: two bidders on the same mine spot
// each send us the identical list. Cross-company matching is the
// whole point, so this MUST group.
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z", company: "co-blyvoor" },
[
{ description: "M20 x 50mm Hex Bolt", quantity: 100 },
{ description: "M20 Flat Washer", quantity: 200 },
],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-05T00:00:00Z", company: "co-cementation" },
[
{ description: "M20 x 50mm Hex Bolt", quantity: 100 },
{ description: "M20 Flat Washer", quantity: 200 },
],
);
const groups = await findDuplicateQuotes();
const content = groups.filter((g) => g.kind === "item_content");
expect(content).toHaveLength(1);
expect(content[0].records.map((r) => r.id)).toEqual(["q-a", "q-b"]);
// Oldest first — that's the one to open and compare prices against.
expect(content[0].records[0].quoteNumber).toBe("Q-1");
expect(content[0].label).toBe("2 lines · 300 units");
});
it("matches regardless of line order and trivial text differences", async () => {
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z" },
[
{ description: "M20 x 50mm Hex Bolt", quantity: 10 },
{ description: "Grease Nipple", quantity: 5 },
],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "claimed", created: "2026-08-02T00:00:00Z" },
[
// Reversed order + different case/punctuation/spacing.
{ description: "grease nipple.", quantity: 5 },
{ description: "M20 X 50MM HEX BOLT", quantity: 10 },
],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "item_content")).toHaveLength(1);
});
it("does not flag quotes whose quantities differ", async () => {
// Same parts but a different amount is a different request —
// the spec numbers matter in mining supply.
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z" },
[{ description: "M20 x 50mm Hex Bolt", quantity: 100 }],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-02T00:00:00Z" },
[{ description: "M20 x 50mm Hex Bolt", quantity: 250 }],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "item_content")).toHaveLength(0);
});
it("does not flag quotes that have no line items", async () => {
// Otherwise every empty draft would match every other empty draft.
await seedQuote({ id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z" });
await seedQuote({ id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-02T00:00:00Z" });
const groups = await findDuplicateQuotes();
expect(groups).toHaveLength(0);
});
it("flags the same email body sent from DIFFERENT senders", async () => {
// Two bidders forwarding the same tender document from the mine.
const body =
"Please quote on the attached schedule for the Blyvoor shaft refurbishment, closing Friday 14h00.";
await seedQuote(
{
id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z",
email_sender: "buyer@companyone.co.za", email_body: body,
},
[{ description: "Widget A", quantity: 1 }],
);
await seedQuote(
{
id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-02T00:00:00Z",
email_sender: "procurement@companytwo.co.za", email_body: body,
},
[{ description: "Widget B", quantity: 2 }],
);
const groups = await findDuplicateQuotes();
const byBody = groups.filter((g) => g.kind === "email_body");
expect(byBody).toHaveLength(1);
expect(byBody[0].records.map((r) => r.id)).toEqual(["q-a", "q-b"]);
});
it("ignores very short email bodies", async () => {
// "please quote" would otherwise match half the collection.
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z", email_body: "please quote" },
[{ description: "Widget A", quantity: 1 }],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-02T00:00:00Z", email_body: "please quote" },
[{ description: "Widget B", quantity: 2 }],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "email_body")).toHaveLength(0);
});
it("flags a true same-Message-ID double ingest", async () => {
await seedQuote(
{
id: "q-a", quote_number: "Q-1", status: "new", created: "2026-08-01T00:00:00Z",
email_message_id: "<abc@mail.example>",
},
[{ description: "Widget A", quantity: 1 }],
);
await seedQuote(
{
id: "q-b", quote_number: "Q-2", status: "new", created: "2026-08-02T00:00:00Z",
email_message_id: "<abc@mail.example>",
},
[{ description: "Widget B", quantity: 2 }],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "email_message_id")).toHaveLength(1);
});
it("excludes completed quotes older than the scan window", async () => {
const old = new Date(Date.now() - 80 * 24 * 60 * 60 * 1000).toISOString();
const recent = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
await seedQuote(
{ id: "q-old", quote_number: "Q-OLD", status: "emailed", created: old, updated: old },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
await seedQuote(
{ id: "q-recent", quote_number: "Q-NEW", status: "emailed", created: recent, updated: recent },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
// Only the recent one is in scope, so there's no pair to group.
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "item_content")).toHaveLength(0);
});
it("keeps active quotes in scope regardless of age", async () => {
// A `new` quote that's been sitting for months is still live work.
const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString();
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "new", created: old, updated: old },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "new", created: old, updated: old },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "item_content")).toHaveLength(1);
});
it("excludes templates", async () => {
// Every quote spawned from a template would otherwise flag against it.
await seedQuote(
{ id: "q-tpl", quote_number: "Q-TPL", status: "new", created: "2026-08-01T00:00:00Z", is_template: true },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
await seedQuote(
{ id: "q-real", quote_number: "Q-1", status: "new", created: "2026-08-02T00:00:00Z" },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
const groups = await findDuplicateQuotes();
expect(groups.filter((g) => g.kind === "item_content")).toHaveLength(0);
});
it("reports the earlier quote's total value so the operator can compare pricing", async () => {
// Relative dates: completed quotes only count inside the recent
// window, so fixed calendar dates rot as time passes.
const day = 24 * 60 * 60 * 1000;
const twoDaysAgo = new Date(Date.now() - 2 * day).toISOString();
const yesterday = new Date(Date.now() - day).toISOString();
await seedQuote(
{ id: "q-a", quote_number: "Q-1", status: "emailed", created: twoDaysAgo },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10, selling_price: 15.5 }],
);
await seedQuote(
{ id: "q-b", quote_number: "Q-2", status: "new", created: yesterday },
[{ description: "M20 x 50mm Hex Bolt Galvanised", quantity: 10 }],
);
const groups = await findDuplicateQuotes();
const content = groups.filter((g) => g.kind === "item_content");
expect(content).toHaveLength(1);
expect(content[0].records[0].totalValue).toBe(155);
// The new one is unpriced — 0 is correct, not a bug.
expect(content[0].records[1].totalValue).toBe(0);
});
});
describe("submitQuote", () => {
it("flips a new quote to approval_pending when every line is priced", async () => {
const quote = await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
description: "M16 bolt",
selling_price: 25,
});
await pbMock.collection("quote_items").create({
id: "qi-2",
quote: "q-1",
description: "Hex nut",
selling_price: 8,
});
const result = await submitQuote("q-1", { id: "u1", name: "Hermes" });
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("approval_pending");
expect(result.quote.submitted_by).toBe("Hermes");
expect(typeof result.quote.submitted_at).toBe("string");
// PB row updated in place.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.status).toBe("approval_pending");
});
it("is idempotent — re-submitting an approval_pending quote is a no-op", async () => {
const original = await pbMock.collection("quotes").create({
id: "q-1",
status: "approval_pending",
submitted_by: "Hermes",
submitted_at: "2026-08-14T10:00:00.000Z",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1");
expect(result.statusChanged).toBe(false);
expect(result.quote.status).toBe("approval_pending");
// Stamps unchanged — the no-op path MUST NOT re-stamp.
expect((result.quote as unknown as Record<string, unknown>).submitted_by).toBe(
"Hermes",
);
expect((result.quote as unknown as Record<string, unknown>).submitted_at).toBe(
"2026-08-14T10:00:00.000Z",
);
// PB row not touched.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored).toEqual(original);
});
it("still rejects cancelled and claimed quotes with code: invalid_status", async () => {
// Terminal / pre-pricing states stay locked out of the
// approval queue:
// - cancelled → operator took it out of the queue intentionally
// - claimed → operator hasn't priced yet; they should
// "Process Items" first
// The `sending` and `template` branches are covered by their
// own tests above; this case is just the two statuses that
// are user-driven terminal-or-prerequisite gates.
for (const status of ["cancelled", "claimed"]) {
await pbMock.collection("quotes").create({
id: `q-${status}`,
status,
});
await pbMock.collection("quote_items").create({
id: `qi-${status}`,
quote: `q-${status}`,
selling_price: 10,
});
await expect(submitQuote(`q-${status}`)).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 422,
code: "invalid_status",
});
}
});
it("accepts every resubmittable status and flips it to approval_pending", async () => {
// The resubmittable set (see RESUBMITTABLE_STATUSES in
// @/lib/quote-status) covers post-send resubmits: customer
// came back with changes on an emailed / completed / approved
// quote, operator fixed a problem or follow-up, mail_failed
// retry, or the legacy `rejected` terminal state. Every one
// of them lands on `approval_pending` — `partial` is
// ignored on re-submissions, only fresh `new` submissions
// can opt into `partial_approval_pending`.
const resubmittable = [
"emailed",
"complete",
"approved",
"follow_up",
"problem",
"partial_approved",
"mail_failed",
"email_failed",
"rejected",
];
for (const status of resubmittable) {
await pbMock.collection("quotes").create({
id: `q-${status}`,
status,
});
await pbMock.collection("quote_items").create({
id: `qi-${status}`,
quote: `q-${status}`,
selling_price: 10,
});
// Partial flag is silently swallowed on resubmissions
// (only fresh `new` quotes honour it). Passing it here
// exercises that the resubmission still completes.
const result = await submitQuote(`q-${status}`, undefined, {
partial: true,
});
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("approval_pending");
expect(result.targetStatus).toBe("approval_pending");
}
});
it("ignores the partial flag on resubmissions even when caller asks for it", async () => {
// Specifically pin the "partial is swallowed on resubmit" rule
// so a future refactor can't quietly land a partial_approval_pending
// on a quote that's already been through the pipeline once.
await pbMock.collection("quotes").create({
id: "q-emailed",
status: "emailed",
});
await pbMock.collection("quote_items").create({
id: "qi-emailed",
quote: "q-emailed",
selling_price: 10,
});
const result = await submitQuote("q-emailed", undefined, {
partial: true,
});
expect(result.quote.status).toBe("approval_pending");
});
it("rejects a quote with unpriced lines and returns the offending item ids", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-priced",
quote: "q-1",
description: "M16 bolt",
selling_price: 25,
});
await pbMock.collection("quote_items").create({
id: "qi-zero",
quote: "q-1",
description: "Hex nut",
selling_price: 0,
});
await pbMock.collection("quote_items").create({
id: "qi-null",
quote: "q-1",
description: "Washer",
selling_price: null,
});
// A line moved to a sibling quote (partial-approve split) should
// be excluded — it's not on this quote anymore.
await pbMock.collection("quote_items").create({
id: "qi-moved",
quote: "q-1",
description: "Moved line",
selling_price: 0,
moved_to_quote: "q-other",
});
try {
await submitQuote("q-1");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(SubmitQuoteError);
const e = err as SubmitQuoteError;
expect(e.status).toBe(422);
expect(e.code).toBe("unpriced_items");
expect(e.offendingItemIds.sort()).toEqual(["qi-null", "qi-zero"]);
}
// Status NOT flipped on a rejected submit.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.status).toBe("new");
});
it("404s with code: quote_not_found when the quote id doesn't exist", async () => {
await expect(submitQuote("missing")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 404,
code: "quote_not_found",
});
});
it("400s when the quote id is empty / whitespace", async () => {
await expect(submitQuote("")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 400,
code: "quote_not_found",
});
await expect(submitQuote(" ")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 400,
code: "quote_not_found",
});
});
it("omits submitted_by when no actor is supplied", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1");
expect(result.statusChanged).toBe(true);
// The type doesn't expose submitted_by because it was never set,
// but the underlying field should be absent from the PB update.
expect(result.quote.submitted_by).toBeUndefined();
});
it("omits submitted_by when actor is supplied but has no name", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1", { id: "u1", name: "" });
expect(result.statusChanged).toBe(true);
expect(result.quote.submitted_by).toBeUndefined();
});
});
describe("submitQuote with is_no_quote", () => {
it("flips a new no-quote quote to approval_pending even when items are unpriced", async () => {
await pbMock.collection("quotes").create({
id: "q-test",
quote_number: "Q-2026-08-002",
status: "new",
is_no_quote: true,
});
await pbMock.collection("quote_items").create({
id: "qi-zero",
quote: "q-test",
description: "M16 bolt",
selling_price: 0,
});
await pbMock.collection("quote_items").create({
id: "qi-priced",
quote: "q-test",
description: "Hex nut",
selling_price: 100,
});
const result = await submitQuote("q-test", undefined, { partial: false });
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("approval_pending");
// PB row updated in place.
const stored = await pbMock.collection("quotes").getOne("q-test");
expect(stored.status).toBe("approval_pending");
});
it("flips a new no-quote quote to partial_approval_pending when partial:true even with zero-priced items", async () => {
// Spec test case #3: `submitQuote` accepts zero-priced items when
// `is_no_quote` true AND `partial: true`. Mirrors the previous test
// (spec #1) but flips on the partial flag and asserts the
// `partial_approval_pending` target status — not the full-approval
// path.
await pbMock.collection("quotes").create({
id: "q-test",
quote_number: "Q-2026-08-004",
status: "new",
is_no_quote: true,
});
await pbMock.collection("quote_items").create({
id: "qi-zero",
quote: "q-test",
description: "M16 bolt",
selling_price: 0,
});
await pbMock.collection("quote_items").create({
id: "qi-priced",
quote: "q-test",
description: "Hex nut",
selling_price: 100,
});
const result = await submitQuote("q-test", undefined, { partial: true });
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("partial_approval_pending");
expect(result.targetStatus).toBe("partial_approval_pending");
// PB row updated in place.
const stored = await pbMock.collection("quotes").getOne("q-test");
expect(stored.status).toBe("partial_approval_pending");
});
it("still throws unpriced_items for a regular (non-no-quote) quote with zero-priced items", async () => {
await pbMock.collection("quotes").create({
id: "q-test",
quote_number: "Q-2026-08-003",
status: "new",
is_no_quote: false,
});
await pbMock.collection("quote_items").create({
id: "qi-zero",
quote: "q-test",
description: "M16 bolt",
selling_price: 0,
});
await pbMock.collection("quote_items").create({
id: "qi-priced",
quote: "q-test",
description: "Hex nut",
selling_price: 100,
});
try {
await submitQuote("q-test", undefined, { partial: false });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(SubmitQuoteError);
const e = err as SubmitQuoteError;
expect(e.status).toBe(422);
expect(e.code).toBe("unpriced_items");
expect(e.offendingItemIds).toEqual(["qi-zero"]);
}
// Status NOT flipped on a rejected submit.
const stored = await pbMock.collection("quotes").getOne("q-test");
expect(stored.status).toBe("new");
});
});
describe("setQuoteNotes", () => {
it("writes the trimmed value to the quote and returns the updated row", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "",
});
const updated = await setQuoteNotes("q-1", " Valid for 30 days ");
expect(updated.quote_notes).toBe("Valid for 30 days");
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.quote_notes).toBe("Valid for 30 days");
});
it("treats whitespace-only input as empty string", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "previous note",
});
const updated = await setQuoteNotes("q-1", " \n\t ");
expect(updated.quote_notes).toBe("");
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.quote_notes).toBe("");
});
it("coerces non-string input to empty string (defensive)", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "previous note",
});
// The route is supposed to reject non-string before we get here,
// but the service must be defensive — passing through anything
// non-string should land as "" rather than crashing.
const updated = await setQuoteNotes("q-1", undefined as unknown as string);
expect(updated.quote_notes).toBe("");
});
it("propagates PB errors when the quote does not exist", async () => {
await expect(setQuoteNotes("missing", "hi")).rejects.toThrow(/not found/);
});
});