WFDASH-WEBAPP/src/lib/server/quotes-service.test.ts
SJCoetz f9a0d08dab fix(tests): mock scaffold destructure + collection-pool + pocketbase startup
- orders-service.test.ts: destructure capture-group bug on regex match
  (was assigning m[0] to 'field', so filter clauses never matched).
  Two pre-seed tests were masked by the bug.
- quotes-service.test.ts: createQuoteFromEmail now returns 0
  attachments; the n8n flow uploads them in a follow-up step. Update
  the assertion that still expected the old embedded-base64 count.
- collection-pool/page.test.tsx: tests assume ONE supplier group with
  multiple rows; supply that shape and scope the selectors. Also wait
  for data load in the chevron test and gather list items across all
  <ul> elements in the sort control helper.
- pocketbase.ts: wrap authStore.onChange registration defensively so
  the test mock (no authStore) doesn't throw at startup.
- stock-orders/page.tsx: drop the re-export of
  STOCK_PURCHASE_STATUS_VALUES on the page module — Next.js page
  route files only allow the default export.
- collection-confirm/view-model.test.ts: buildConfirmNote input is
  Pick<InvoiceDetails, 'date' | 'number'>; drop the extra setBy fields.

Co-authored-by: Orca <help@stably.ai>
2026-07-31 00:01:24 +02:00

1013 lines
37 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// The mock state has to be hoisted to the top of the file because Vitest
// hoists `vi.mock` factories above all imports.
const { store, pbMock, clearStore } = vi.hoisted(() => {
const store: Record<string, Map<string, Record<string, unknown>>> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreate = (collection: string) => {
if (!store[collection]) store[collection] = new Map();
return store[collection];
};
const where = (collection: string, filter: string) => {
const map = getOrCreate(collection);
const items = Array.from(map.values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
clauses.every((clause) => {
const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/);
if (!m) return true;
const [, field, raw] = m;
const value = raw.replace(/^"|"$/g, "");
if (field === "year" || field === "month") {
return Number(item[field]) === Number(value);
}
return String(item[field] ?? "") === value;
})
);
};
const pbMock = {
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,
page,
perPage,
};
},
async getFullList(opts: { filter?: string; fields?: string } = {}) {
return where(name, opts.filter || "");
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string) {
const item = getOrCreate(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
// Enforce the unique (year, month) index for quote_counters so the
// service's race-handling path can be exercised.
if (name === "quote_counters") {
const map = getOrCreate(name);
for (const item of map.values()) {
if (item.year === data.year && item.month === data.month) {
const err = new Error("unique constraint") as Error & { status: number };
err.status = 409;
throw err;
}
}
}
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreate(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreate(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = new Date().toISOString();
// Return a clone so concurrent callers each see the value at the
// moment their update ran, not the final mutated value.
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
generateQuoteNumberServer,
findOrCreateCustomer,
findOrCreateCompany,
createQuoteFromEmail,
appendReplyToQuote,
findQuoteByMessageId,
extractQuoteNumberFromSubject,
normalizeCompanyName,
sendQuoteEmail,
retryQuoteEmail,
recordSendOutcome,
} 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: "<abc123@example.com>",
in_reply_to: undefined as string | undefined,
received_at: new Date().toISOString(),
extracted_items: [
{ description: "20mm cable ties (pack of 100)", quantity: 5, unit: "pack" },
{ description: "Cable gland M20", quantity: 10, unit: "each" },
],
attachments: [
{
filename: "rfq.pdf",
content_type: "application/pdf",
size: 1024,
// 1 byte of base64 = "AA=="
base64: "AA==",
},
],
};
it("creates a new quote, customer, company, items, and attachments", async () => {
const result = await createQuoteFromEmail({
...basePayload,
company_hint: "NewCo Mining",
});
expect(result.deduped).toBe(false);
expect(result.quote_number).toMatch(/^Q\d{4}\d{2}001$/);
expect(result.items).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("<abc123@example.com>");
expect(result.quote.has_attachments).toBe(true);
expect(result.quote.status).toBe("new");
expect(result.quote.notes).toBe(""); // not a notes blob
});
it("creates AI-extracted items as drafts (is_processed=false, zero prices)", async () => {
const result = await createQuoteFromEmail(basePayload);
const items = await pbMock.collection("quote_items").getFullList({
filter: `quote = "${result.quote.id}"`,
});
expect(items.length).toBe(2);
for (const item of items) {
expect(item.is_processed).toBe(false);
// No fabricated cost or selling price.
expect(Number(item.cost_price)).toBe(0);
expect(Number(item.selling_price)).toBe(0);
}
});
it("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: "<blank-a@example.com>",
received_at: "2026-06-12T18:30:00.000Z",
extracted_items: [],
});
expect(result.customer.customer).toBe("Unknown Sender");
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.quote.description).toBe("(no subject)");
expect(result.quote.email_subject).toBe("(no subject)");
expect(result.quote.email_sender).toBe("");
expect(result.items).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: "<no-from-a@example.com>",
});
const r2 = await createQuoteFromEmail({
...basePayload,
from_email: "",
subject: "Unknown sender email B",
message_id: "<no-from-b@example.com>",
});
expect(r1.deduped).toBe(false);
expect(r2.deduped).toBe(false);
expect(r1.quote.id).not.toBe(r2.quote.id);
expect(r1.customer.id).toBe(r2.customer.id);
});
it("a real from_email still wins over the sentinel", async () => {
const result = await createQuoteFromEmail({
...basePayload,
from_email: "real-buyer@somewhere.co.za",
});
expect(result.customer.email).toBe("real-buyer@somewhere.co.za");
expect(result.customer.customer).not.toBe("Unknown Sender");
});
it("existing sentinel survives a fresh run (no duplicate Unknown Sender row)", async () => {
// Pre-create the sentinel so the findOrCreate path hits the existing
// branch. Verify no second customer is created.
const customersBefore = await pbMock.collection("customers").getFullList();
await pbMock.collection("customers").create({
customer: "Unknown Sender",
email: "unknown-sender@no-from.wfdash.local",
company: "Unknown",
description: "Pre-existing sentinel.",
});
const customersAfter = await pbMock.collection("customers").getFullList();
expect(customersAfter.length).toBe(customersBefore.length + 1);
const result = await createQuoteFromEmail({ ...basePayload, from_email: "" });
expect(result.customer.email).toBe("unknown-sender@no-from.wfdash.local");
expect(result.customer.description).toBe("Pre-existing sentinel.");
const customersFinal = await pbMock.collection("customers").getFullList();
expect(customersFinal.length).toBe(customersAfter.length);
});
it("synthesizes a stable message_id when the inbound email has no Message-ID header", async () => {
// iCloud / Mail.app sometimes strip the Message-ID header. The pipeline
// must still create the quote and dedup correctly on retry.
const noId1 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
const noId2 = await createQuoteFromEmail({
...basePayload,
message_id: "",
});
expect(noId1.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
expect(noId2.deduped).toBe(true);
expect(noId2.quote.id).toBe(noId1.quote.id);
});
it("treats whitespace-only message_id as missing and synthesizes", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: " ",
});
expect(result.quote.email_message_id).toMatch(/^synth-[0-9a-f]{8}@wfdash\.local$/);
});
it("keeps a real (non-empty) message_id verbatim — never synthesizes over a real one", async () => {
const result = await createQuoteFromEmail({
...basePayload,
message_id: "<real-msg-id@mail.example.com>",
});
expect(result.quote.email_message_id).toBe("<real-msg-id@mail.example.com>");
});
});
describe("appendReplyToQuote", () => {
it("matches by In-Reply-To header (strongest signal)", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Quotation needed",
body_plain: "",
message_id: "<orig@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@a.com",
from_name: "Buyer",
subject: "Re: Some subject without a number",
body_plain: "Thanks, looks good.",
message_id: "<reply1@example.com>",
in_reply_to: "<orig@example.com>",
received_at: new Date().toISOString(),
});
expect(result.matched_by).toBe("in_reply_to");
expect(result.quote?.id).toBe(created.quote.id);
expect((result.reply as Record<string, unknown>).in_reply_to).toBe("<orig@example.com>");
});
it("falls back to subject regex when In-Reply-To is missing", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@b.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig2@example.com>",
});
const result = await appendReplyToQuote({
from_email: "buyer@b.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "OK, please proceed.",
message_id: "<reply2@example.com>",
});
expect(result.matched_by).toBe("subject");
expect(result.quote?.id).toBe(created.quote.id);
});
it("updates has_new_reply and last_reply_at on the matched quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@c.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig3@example.com>",
});
const replyDate = "2026-06-11T10:00:00.000Z";
await appendReplyToQuote({
from_email: "buyer@c.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Approved.",
message_id: "<reply3@example.com>",
received_at: replyDate,
});
const updated = await pbMock.collection("quotes").getOne(created.quote.id);
expect(updated.has_new_reply).toBe(true);
expect(updated.last_reply_at).toBe(replyDate);
});
it("returns matched_by='none' when no match — caller moves to Unmatched", async () => {
const result = await appendReplyToQuote({
from_email: "unknown@x.com",
subject: "Some random reply",
body_plain: "Hello",
});
expect(result.matched_by).toBe("none");
expect(result.quote).toBeNull();
expect(result.reply).toBeNull();
});
it("stores the reply as a structured quote_replies row, not as notes text", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@d.com",
subject: "Need a quote",
body_plain: "",
message_id: "<orig4@example.com>",
});
await appendReplyToQuote({
from_email: "buyer@d.com",
subject: `Re: Quotation ${created.quote_number}`,
body_plain: "Reply body content",
quoted_text: "Original quoted text",
message_id: "<reply4@example.com>",
});
const replies = await pbMock.collection("quote_replies").getFullList({
filter: `quote = "${created.quote.id}"`,
});
expect(replies.length).toBe(1);
expect(replies[0].body_plain).toBe("Reply body content");
expect(replies[0].quoted_text).toBe("Original quoted text");
// The quote's notes field should NOT contain the reply body.
expect(created.quote.notes).toBe("");
});
});
describe("findQuoteByMessageId", () => {
it("returns the matching quote", async () => {
const created = await createQuoteFromEmail({
from_email: "buyer@e.com",
subject: "Need a quote",
body_plain: "",
message_id: "<findme@example.com>",
});
const found = await findQuoteByMessageId("<findme@example.com>");
expect(found?.id).toBe(created.quote.id);
});
it("returns null when no match", async () => {
const found = await findQuoteByMessageId("<nope@example.com>");
expect(found).toBeNull();
});
it("returns null for empty Message-ID", async () => {
expect(await findQuoteByMessageId("")).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Outbound send-email pipeline
// ─────────────────────────────────────────────────────────────────────────────
/**
* Helper: create a quote in the requested status with a real-looking
* customer + PDF already attached. The pipeline tests below mutate
* this base case (delete the PDF, blank the email, etc.) to exercise
* specific failure paths.
*/
async function seedReadyQuote(overrides: {
status?: string;
customerEmail?: string | null;
customerName?: string;
withPdf?: boolean;
} = {}) {
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: "<out-123@wfdash.local>",
});
expect(updated.status).toBe("emailed");
expect(updated.email_delivered).toBe(true);
expect(updated.email_delivered_at).toBeTruthy();
expect(updated.email_outbound_message_id).toBe("<out-123@wfdash.local>");
expect(updated.email_error).toBe("");
expect(updated.sending_started_at).toBe("");
});
it("flips to mail_failed on SMTP failure + records reason + clears sending_started_at", async () => {
const { quote } = await seedReadyQuote({ status: "sending" });
await pbMock.collection("quotes").update(quote.id, {
sending_started_at: new Date().toISOString(),
});
const updated = await recordSendOutcome({
quoteId: quote.id,
outcome: "failure",
errorMessage: "550 5.1.1 User unknown",
messageId: "<out-failed@wfdash.local>",
});
expect(updated.status).toBe("mail_failed");
expect(updated.email_failed_at).toBeTruthy();
expect(updated.email_error).toMatch(/550 5\.1\.1 User unknown/);
expect(updated.email_outbound_message_id).toBe("<out-failed@wfdash.local>");
expect(updated.sending_started_at).toBe("");
});
it("truncates long error messages so we don't exceed PB's 5000-byte cap", async () => {
const { quote } = await seedReadyQuote({ status: "sending" });
const huge = "x".repeat(10_000);
const updated = await recordSendOutcome({
quoteId: quote.id,
outcome: "failure",
errorMessage: huge,
});
expect(updated.email_error.length).toBeLessThan(5000);
});
});