259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|