WFDASH-WEBAPP/scripts/smoke/create-from-email.ts

259 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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 = `<smoke-a-${RUN_ID}@example.com>`;
const MSG_B = `<smoke-b-${RUN_ID}@another.org>`;
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: `<no-from-a-${RUN_ID}@example.com>`,
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: `<no-from-b-${RUN_ID}@example.com>`,
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: `<blank-${RUN_ID}@example.com>`,
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: "<reply-a@example.com>",
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: "<reply-a-subject-only@example.com>",
});
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: "<unmatched@example.com>",
});
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);
});