WFDASH-WEBAPP/scripts/smoke/quotes-ai-pipeline.ts

153 lines
7.4 KiB
TypeScript
Raw Normal View History

/* 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);
});