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 { store, 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; let lastCloseAt = -1; for (let i = 0; i < trimmed.length; i++) { if (trimmed[i] === "(") depth++; else if (trimmed[i] === ")") { depth--; lastCloseAt = i; } } // Wrap the whole string iff depth is 0 at the end AND the // opening `(` is at position 0 AND the matching close is the // last char. if (depth !== 0) return s; if (lastCloseAt !== trimmed.length - 1) return s; return trimmed.slice(1, -1); }; const parseFilter = (raw: string): FilterAST => { const s = stripOuterParens(raw.trim()); // 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 `+?`. const m = s.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]*?)"?$/); if (!m) { throw new Error(`Mock filter parser: cannot parse "${s}"`); } const [, field, op, value] = m; 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; case "=~": return actualStr.includes(ast.value); case "!~": return !actualStr.includes(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("@/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, } 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/); }); }); 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).toBe(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("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).toBe(0); }); 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).toBe(0); }); 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("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; } = {}) { 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(), }); } 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 IDs", async () => { const { quote } = await seedReadyQuote(); const a1 = await pbMock.collection("quote_attachments").create({ quote: quote.id, filename: "spec.pdf", file: ["t1"], content_type: "application/pdf", size_bytes: 1, email_message_id: "", kind: "doc", direction: "outbound", }); const a2 = await pbMock.collection("quote_attachments").create({ quote: quote.id, filename: "drawing.dwg", file: ["t2"], content_type: "application/octet-stream", size_bytes: 2, email_message_id: "", kind: "other", direction: "outbound", }); // Belongs to a DIFFERENT quote — must be filtered out. const a3 = await pbMock.collection("quote_attachments").create({ quote: "DIFFERENT_QUOTE_ID", filename: "smuggled.pdf", file: ["t3"], content_type: "application/pdf", size_bytes: 3, email_message_id: "", kind: "doc", }); const result = await sendQuoteEmail(quote.id, { cc: "", bcc: "", subject: "", message: "", attachAuthLetters: false, selectedAttachmentIds: [a1.id, a2.id, a3.id], }); expect(result.failure).toBeNull(); const parsed = JSON.parse(result.quote.email_outbound_attachment_ids); expect(parsed.sort()).toEqual([a1.id, a2.id].sort()); expect(parsed).not.toContain(a3.id); }); 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("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"); }); }); 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, and templates are special-cased (no // status filter). Every other tab must have at least one status. for (const tab of QUOTE_TAB_ORDER) { if (tab === "delivery_note" || tab === "all" || tab === "templates") { 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); }); }); 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", ]); }); }); // ---------------------------------------------------------------------------- // 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); }); });