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>> = {}; 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, 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) { // 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) { 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()), 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: "", 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(""); 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: "", 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: "", }); const r2 = await createQuoteFromEmail({ ...basePayload, from_email: "", subject: "Unknown sender email B", message_id: "", }); 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: "", }); expect(result.quote.email_message_id).toBe(""); }); }); 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: "", }); 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: "", in_reply_to: "", 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).in_reply_to).toBe(""); }); 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: "", }); const result = await appendReplyToQuote({ from_email: "buyer@b.com", subject: `Re: Quotation ${created.quote_number}`, body_plain: "OK, please proceed.", message_id: "", }); 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: "", }); 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: "", 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: "", }); 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: "", }); 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: "", }); const found = await findQuoteByMessageId(""); expect(found?.id).toBe(created.quote.id); }); it("returns null when no match", async () => { const found = await findQuoteByMessageId(""); 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 // (`_`), 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; generateQuotePdfMock: ReturnType; }; 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).mockResolvedValue( opts.isStale, ); const regenKind = opts.regenOutcome ?? "ok"; if (regenKind === "ok") { (generateQuotePdfMock as unknown as ReturnType).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).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).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).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).mockReset(); (generateQuotePdfMock as unknown as ReturnType).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: "", }); expect(updated.status).toBe("emailed"); expect(updated.email_delivered).toBe(true); expect(updated.email_delivered_at).toBeTruthy(); expect(updated.email_outbound_message_id).toBe(""); 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: "", }); 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(""); 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("expands customer.company so the list renders the customer's real company", async () => { // Regression pin. `QUOTE_LIST_EXPAND` used to be // "customer,customer.rep,company" — the 3-level expand was dropped // to save one IN query per page load, and the list page read the // quote's own `company` relation instead. That relation is a // denormalised copy PocketBase never re-cascades, so it drifts: // Q202610315 listed "Mre" while its customer sat at Harmony, and // the rep reads that column to decide whether to approve. // // The mock doesn't apply expand, so pin the expand string itself. await pbMock.collection("quotes").create({ id: "q-company", quote_number: "Q1", status: "new", }); const seen: string[] = []; const realCollection = pbMock.collection.bind(pbMock); const spy = vi .spyOn(pbMock, "collection") .mockImplementation((name: string) => { const coll = realCollection(name); if (name !== "quotes") return coll; return { ...coll, async getList( page = 1, perPage = 25, opts: { filter?: string; expand?: string } = {}, ) { if (opts.expand) seen.push(opts.expand); return coll.getList(page, perPage, opts); }, }; }); try { await getQuotesDashboardData({ page: 1, perPage: 20, tab: "new" }); } finally { spy.mockRestore(); } expect(seen.length).toBeGreaterThan(0); expect(seen[0]).toContain("customer.company"); }); 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, items: Array> = [], ) => { 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: "", }, [{ 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: "", }, [{ 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).submitted_by).toBe( "Hermes", ); expect((result.quote as unknown as Record).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/); }); });