/** * End-to-end smoke test for the email → quote pipeline. * * Calls createQuoteFromEmail twice (different senders), then a third time * with the SAME message_id as the first call (must dedupe), then * appendReplyToQuote to thread a reply onto the first quote. * * Verifies: * - quote, customer, company auto-create * - included_in_quote: false on every item * - cost_price: 0 on every item (no fabrication) * - quote_number is Q-2026-06-NNN * - second call with same message_id returns deduped: true * - reply is stored as a quote_replies row (not as notes text) * - quote has_new_reply flips to true and last_reply_at is set */ import { createQuoteFromEmail, appendReplyToQuote, findQuoteByMessageId } from "../../src/lib/server/quotes-service"; const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const MSG_A = ``; const MSG_B = ``; async function main() { // ── Call 1: new RFQ from a never-seen sender const r1 = await createQuoteFromEmail({ from_email: "newbuyer@example.com", from_name: "New Buyer", subject: "RFQ for Industrial Pump", body_plain: "Please quote on the following 3 items.", message_id: MSG_A, received_at: new Date().toISOString(), extracted_items: [ { description: "Centrifugal Pump 5HP", quantity: 2, unit: "ea" }, { description: "Pump Seal Kit", quantity: 5, unit: "ea", notes: "spares" }, ], }); console.log("Call 1:", { quote_number: r1.quote_number, deduped: r1.deduped, items: r1.items, customer: r1.customer.customer, company: r1.company === null ? "null (no AI hint)" : r1.company.company, }); if (r1.deduped) throw new Error("FAIL — first call should not be deduped"); if (r1.items !== 2) throw new Error(`FAIL — expected 2 items, got ${r1.items}`); if (!r1.quote_number.startsWith("Q-2026-06-")) throw new Error("FAIL — bad quote number prefix"); if (r1.company !== null) throw new Error("FAIL — company should be null when company_hint is unset (no subject fallback)"); // ── Call 2: idempotency — same Message-ID must return the existing quote const r1b = await createQuoteFromEmail({ from_email: "newbuyer@example.com", from_name: "New Buyer", subject: "RFQ for Industrial Pump", body_plain: "Please quote on the following 3 items.", message_id: MSG_A, }); console.log("Call 2 (same Message-ID):", { deduped: r1b.deduped, quote_number: r1b.quote_number }); if (!r1b.deduped) throw new Error("FAIL — second call with same message_id should be deduped"); if (r1b.quote.id !== r1.quote.id) throw new Error("FAIL — dedupe should return the same quote id"); // ── Call 3: new RFQ from a different sender const r2 = await createQuoteFromEmail({ from_email: "second@another.org", from_name: "Second Person", subject: "Quotation for Valves", message_id: MSG_B, extracted_items: [{ description: "Ball Valve 2 inch" }], }); console.log("Call 3:", { quote_number: r2.quote_number, deduped: r2.deduped }); if (r2.deduped) throw new Error("FAIL — different message_id should not dedupe"); // ── Call 4: email with no Message-ID header (iCloud/Mail.app sometimes // strip it). The MCP server should synthesize a stable `synth-…@wfdash.local` // id and still create the quote. const NO_ID_SUBJECT = `Testing order no-msg-id-${RUN_ID}`; const NO_ID_RECEIVED_AT = "2026-06-12T13:00:00.000Z"; const noId1 = await createQuoteFromEmail({ from_email: "noheader@icloud.example.com", from_name: "No Header", subject: NO_ID_SUBJECT, body_plain: "50 x m20 x 69 bolts and nuts zp\n7 x m12 x 33 nuts", message_id: "", received_at: NO_ID_RECEIVED_AT, extracted_items: [ { description: "m20 x 69 bolts and nuts zp", quantity: 50 }, { description: "m12 x 33 nuts", quantity: 7 }, ], }); console.log("Call 4 (no Message-ID):", { quote_number: noId1.quote_number, deduped: noId1.deduped, email_message_id: noId1.quote.email_message_id, }); if (!noId1.quote.email_message_id.startsWith("synth-")) { throw new Error("FAIL — missing message_id should produce a synth-… id"); } // Re-call with the same payload (still no message_id, same received_at). // Must dedup to the same quote via the synthesized id. const noId2 = await createQuoteFromEmail({ from_email: "noheader@icloud.example.com", from_name: "No Header", subject: NO_ID_SUBJECT, body_plain: "50 x m20 x 69 bolts and nuts zp\n7 x m12 x 33 nuts", message_id: "", received_at: NO_ID_RECEIVED_AT, }); if (!noId2.deduped) throw new Error("FAIL — second call with no message_id should dedup via synth id"); if (noId2.quote.id !== noId1.quote.id) throw new Error("FAIL — synth dedup should return the same quote id"); // ── Call 5: email with no From: header. WFDASH must still create the // quote, linked to a sentinel "Unknown Sender" customer, and a second // such email must reuse the same sentinel (no proliferation). const NO_FROM_SUBJECT_A = `Testing order no-from-a-${RUN_ID}`; const NO_FROM_SUBJECT_B = `Testing order no-from-b-${RUN_ID}`; const noFrom1 = await createQuoteFromEmail({ from_email: "", from_name: "", subject: NO_FROM_SUBJECT_A, body_plain: "10 x 2-inch bolts", message_id: ``, received_at: "2026-06-12T14:00:00.000Z", extracted_items: [{ description: "2-inch bolts", quantity: 10 }], }); console.log("Call 5a (no From):", { quote_number: noFrom1.quote_number, deduped: noFrom1.deduped, customer: noFrom1.customer.customer, email_sender: noFrom1.quote.email_sender, }); if (noFrom1.deduped) throw new Error("FAIL — first no-from call should not be deduped"); if (noFrom1.customer.email !== "unknown-sender@no-from.wfdash.local") { throw new Error(`FAIL — expected sentinel customer, got ${noFrom1.customer.email}`); } if (noFrom1.customer.customer !== "Unknown Sender") { throw new Error(`FAIL — expected 'Unknown Sender' name, got ${noFrom1.customer.customer}`); } if (noFrom1.quote.email_sender !== "") { throw new Error(`FAIL — email_sender should be empty string, got '${noFrom1.quote.email_sender}'`); } const noFrom2 = await createQuoteFromEmail({ from_email: "", from_name: "", subject: NO_FROM_SUBJECT_B, body_plain: "20 x 3-inch bolts", message_id: ``, received_at: "2026-06-12T14:01:00.000Z", extracted_items: [{ description: "3-inch bolts", quantity: 20 }], }); console.log("Call 5b (no From, second email):", { quote_number: noFrom2.quote_number, customer: noFrom2.customer.customer, }); if (noFrom2.deduped) throw new Error("FAIL — second no-from call should not be deduped (different message_id)"); if (noFrom2.customer.id !== noFrom1.customer.id) { throw new Error("FAIL — second no-from call should reuse the same sentinel customer"); } // ── Call 6: a fully-blank RFC 5321-violating email — no subject, no // body, no From. The n8n Parse Email node stamps `(no subject)` and // `(no body)` placeholders. WFDASH must accept them as real values and // create a quote, linked to the sentinel "Unknown Sender" customer. const blank = await createQuoteFromEmail({ from_email: "", from_name: "", subject: "(no subject)", body_plain: "(no body)", body_html: "", message_id: ``, received_at: "2026-06-12T18:30:00.000Z", extracted_items: [], }); console.log("Call 6 (fully blank):", { quote_number: blank.quote_number, deduped: blank.deduped, customer: blank.customer.customer, description: blank.quote.description, email_body: blank.quote.email_body, email_sender: blank.quote.email_sender, items: blank.items, }); if (blank.deduped) throw new Error("FAIL — fully-blank call should not be deduped (unique message_id)"); if (blank.customer.email !== "unknown-sender@no-from.wfdash.local") { throw new Error(`FAIL — expected sentinel customer, got ${blank.customer.email}`); } if (blank.quote.description !== "(no subject)") { throw new Error(`FAIL — description should be '(no subject)', got '${blank.quote.description}'`); } if (blank.quote.email_subject !== "(no subject)") { throw new Error(`FAIL — email_subject should be '(no subject)', got '${blank.quote.email_subject}'`); } if (blank.quote.email_body !== "(no body)") { throw new Error(`FAIL — email_body should be '(no body)', got '${blank.quote.email_body}'`); } if (blank.quote.email_sender !== "") { throw new Error(`FAIL — email_sender should be empty string, got '${blank.quote.email_sender}'`); } if (blank.items !== 0) throw new Error(`FAIL — expected 0 items, got ${blank.items}`); // ── Append a reply to the first quote (resolve by In-Reply-To) const reply = await appendReplyToQuote({ from_email: "newbuyer@example.com", from_name: "New Buyer", subject: `Re: Quotation ${r1.quote_number}`, body_plain: "Thanks. Can you also quote on 10HP?", message_id: "", in_reply_to: MSG_A, received_at: new Date().toISOString(), }); console.log("Reply:", { matched_by: reply.matched_by, reply_id: (reply.reply as { id: string } | null)?.id }); if (reply.matched_by !== "in_reply_to") throw new Error(`FAIL — matched_by should be in_reply_to, got ${reply.matched_by}`); // ── Resolve by subject regex on a different (synthetic) Message-ID const replySubjectOnly = await appendReplyToQuote({ from_email: "newbuyer@example.com", from_name: "New Buyer", subject: `RE: Quotation ${r1.quote_number}`, body_plain: "Subject-only match test.", message_id: "", }); console.log("Reply (subject only):", { matched_by: replySubjectOnly.matched_by }); if (replySubjectOnly.matched_by !== "subject") throw new Error(`FAIL — matched_by should be subject, got ${replySubjectOnly.matched_by}`); // ── Unmatched reply: must report matched_by: 'none' const replyNone = await appendReplyToQuote({ from_email: "newbuyer@example.com", subject: "totally unrelated subject", body_plain: "Unmatched test.", message_id: "", }); console.log("Reply (unmatched):", { matched_by: replyNone.matched_by }); if (replyNone.matched_by !== "none") throw new Error("FAIL — unmatched should report 'none'"); // ── Lookup by message id const looked = await findQuoteByMessageId(MSG_A); console.log("Lookup by message id:", looked?.quote_number, "has_new_reply:", looked?.has_new_reply); if (!looked) throw new Error("FAIL — lookup by message id returned null"); if (looked.id !== r1.quote.id) throw new Error("FAIL — lookup returned wrong quote id"); if (!looked.has_new_reply) throw new Error("FAIL — has_new_reply should be true after the In-Reply-To reply"); if (!looked.last_reply_at) throw new Error("FAIL — last_reply_at should be set"); // ── Cleanup: delete the smoke quotes (so we don't pollute the live data) // Skip deletion — leave for the user to verify in the UI and clean up manually. console.log("\nALL SMOKE CHECKS PASSED."); console.log("Quotes created:"); console.log(` ${r1.quote_number} id=${r1.quote.id} customer=${r1.customer.customer}`); console.log(` ${r2.quote_number} id=${r2.quote.id} customer=${r2.customer.customer}`); console.log(` ${noId1.quote_number} id=${noId1.quote.id} customer=${noId1.customer.customer} (no Message-ID, synth-…@wfdash.local)`); console.log(` ${noFrom1.quote_number} id=${noFrom1.quote.id} customer=${noFrom1.customer.customer} (no From, Unknown Sender sentinel)`); console.log(` ${noFrom2.quote_number} id=${noFrom2.quote.id} customer=${noFrom2.customer.customer} (no From, same sentinel)`); console.log(` ${blank.quote_number} id=${blank.quote.id} customer=${blank.customer.customer} (fully-blank, placeholder subject/body)`); } main().catch((err) => { console.error(err); process.exit(1); });