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.
2820 lines
103 KiB
TypeScript
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/);
|
|
});
|
|
});
|