/* eslint-disable */ /** * Smoke test for the new AI email → quote pipeline. * * Exercises the ingestion service directly (skips the HTTP layer to * avoid prod-deploy races during development). Verifies: * * 1. ingestQuoteFromEmail() writes a quote row stamped * ai_status = "pending" * 2. A second call with the same message_id is a dedup (no second * quote, no second customer) * 3. The quote can be picked up by processOneQuote() — which * attempts the AI extraction. If ANTHROPIC_API_KEY is unset, * the call is expected to throw ANTHROPIC_API_KEY missing and * flip ai_status to "failed" with the error captured. * * Run with: * npx tsx scripts/smoke/quotes-ai-pipeline.ts * * Cleanup: deletes the smoke-test customer + company + quote on * success. If the script crashes before cleanup, the data is left * behind; a follow-up run with the same Message-ID will dedup to * the same rows. */ import "./_env"; // MUST be first — side-effect loads .env / .env.local import { ingestQuoteFromEmail } from "../../src/lib/server/quotes-from-email-service"; import { processOneQuote } from "../../src/lib/server/quotes-ai-processor"; import { getAdminPocketBase } from "../../src/lib/server/pocketbase-admin"; const SMOKE_FROM = "smoke-test-pipeline@example.invalid"; const SMOKE_SUBJECT = "[SMOKE] AI pipeline end-to-end check"; const SMOKE_MESSAGE_ID = "smoke-ai-pipeline-2026-08-08-001@wfdash.local"; async function main() { // Defensive env check before we hit the network — saves a confusing // 404 from PB auth if .env.local didn't load (e.g. wrong cwd). console.log("=== AI pipeline smoke test ==="); console.log(` PB URL: ${process.env.NEXT_PUBLIC_POCKETBASE_URL?.slice(0, 60)}…`); console.log(` Admin: ${process.env.PB_ADMIN_EMAIL}`); console.log(` Pwd len: ${(process.env.PB_ADMIN_PASSWORD || "").length}`); console.log(` Anthropic: ${process.env.ANTHROPIC_API_KEY ? "set" : "(unset — failure path expected)"}\n`); console.log(`From: ${SMOKE_FROM}`); console.log(`Subject: ${SMOKE_SUBJECT}`); console.log(`Message-ID: ${SMOKE_MESSAGE_ID}\n`); const pb = await getAdminPocketBase(); pb.autoCancellation(false); // ── 1. Ingest ───────────────────────────────────────────────────── console.log("[1/4] Ingesting email payload…"); const ingest1 = await ingestQuoteFromEmail({ from_email: SMOKE_FROM, from_name: "Smoke Tester", subject: SMOKE_SUBJECT, body_plain: "Please quote 10 x M16 x 50mm hex bolts grade 8.8 zinc, " + "and 20 x M16 wing nuts. Need delivery to Johannesburg.", message_id: SMOKE_MESSAGE_ID, company_hint: "Smoke Test Co", }); console.log(` ✓ Quote ${ingest1.quote.quote_number} (${ingest1.quote.id})`); console.log(` customer=${ingest1.customer.id} company=${ingest1.company?.id ?? "(none)"}`); console.log(` deduped=${ingest1.deduped} attachments=${ingest1.attachments}`); if (!ingest1.deduped && ingest1.quote.ai_status !== "pending") { throw new Error( `Expected ai_status="pending" on new quote, got "${ingest1.quote.ai_status}"`, ); } console.log(` ai_status="${ingest1.quote.ai_status}" ✓`); // ── 2. Dedup ────────────────────────────────────────────────────── console.log("\n[2/4] Re-ingesting with the same Message-ID (expect dedup)…"); const ingest2 = await ingestQuoteFromEmail({ from_email: SMOKE_FROM, subject: SMOKE_SUBJECT, body_plain: "Different body, same Message-ID — should not double-write.", message_id: SMOKE_MESSAGE_ID, company_hint: "Smoke Test Co", }); if (!ingest2.deduped) throw new Error("Expected dedup=true on second ingest"); if (ingest2.quote.id !== ingest1.quote.id) { throw new Error(`Dedup returned a different quote id!`); } console.log(` ✓ Same quote id (${ingest2.quote.id}), no duplicate created`); // ── 3. Process (AI step) ────────────────────────────────────────── console.log("\n[3/4] Running processOneQuote() — invokes Claude API…"); let processedOk = false; try { const result = await processOneQuote({ quoteId: ingest1.quote.id }); console.log(` → outcome=${result.outcome} itemsWritten=${result.itemsWritten}`); if (result.outcome === "processed") { processedOk = true; } else if (result.outcome === "failed" && result.error.includes("ANTHROPIC_API_KEY")) { console.log(` ⚠ ANTHROPIC_API_KEY not configured — that's expected in dev.`); console.log(` ai_error captured correctly: "${result.error.slice(0, 80)}…"`); } else { throw new Error(`Unexpected outcome: ${JSON.stringify(result)}`); } } catch (e) { // processOneQuote catches its own errors and writes ai_error, // so a thrown error here would be a code bug, not an API failure. throw new Error(`processOneQuote threw unexpectedly: ${(e as Error).message}`); } // ── 4. Verify the AI status flipped correctly ──────────────────── console.log("\n[4/4] Verifying ai_status on the quote row…"); const after = await pb.collection("quotes").getOne(ingest1.quote.id); console.log(` ai_status="${after.ai_status}" ai_error="${(after.ai_error || "").slice(0, 60)}…"`); if (processedOk) { if (after.ai_status !== "complete") { throw new Error(`Expected ai_status="complete" after successful AI run, got "${after.ai_status}"`); } console.log(` ✓ ai_status="complete" — pipeline ran end-to-end`); } else { if (after.ai_status !== "failed") { throw new Error(`Expected ai_status="failed" after missing API key, got "${after.ai_status}"`); } console.log(` ✓ ai_status="failed" — failure path captured the missing key correctly`); } // ── 5. Cleanup ──────────────────────────────────────────────────── console.log("\n[cleanup] Deleting smoke-test quote + customer + company…"); await pb.collection("quotes").delete(ingest1.quote.id); // Only delete the smoke customer + company if no other rows still // reference them (defensive — should be true after the quote delete). try { await pb.collection("customers").delete(ingest1.customer.id); console.log(` ✓ Deleted smoke customer ${ingest1.customer.id}`); } catch (e) { console.log(` (skipped customer delete — likely linked to other rows: ${(e as Error).message})`); } if (ingest1.company) { try { await pb.collection("companies").delete(ingest1.company.id); console.log(` ✓ Deleted smoke company ${ingest1.company.id}`); } catch (e) { console.log(` (skipped company delete — likely linked to other rows: ${(e as Error).message})`); } } console.log("\n=== Smoke test PASSED ==="); console.log(processedOk ? "Full pipeline ran (quote created → AI extracted → items written → status=complete)." : "Ingestion ran (quote created, ai_status=pending) and the failure path correctly captured the missing ANTHROPIC_API_KEY."); } main().catch((e) => { console.error("\nSmoke test FAILED:", e.message); console.error(e.stack); process.exit(1); });