Add complete end-to-end AI-powered quote extraction workflow: - Disable PB SDK auto-cancellation to support parallel batch requests - Add required runtime dependencies for AI processing and document parsing - Implement AI settings backend, operator UI, and REST API routes - Add quote ingestion, insight population, and manual re-run AI endpoints - Build service layers for quote items, AI settings, and structured item insights - Fix attachment upload handling and quote item history blob parsing - Add supporting migrations, smoke tests, and shared utility functions - Export previously private helper functions for cross-service reuse
239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
/**
|
|
* Smoke test for the AI settings round-trip + processor wiring.
|
|
*
|
|
* What this verifies
|
|
* ──────────────────
|
|
* 1. Migration ran successfully (ai_settings row exists or can be
|
|
* created from defaults).
|
|
* 2. `updateAiSettings` persists + `getAiSettings` reflects the
|
|
* change with the 60s cache busted (i.e. no stale read).
|
|
* 3. `getAiSettings()` redacts api_key by default; `getAiSettings(
|
|
* { includeSecret: true })` returns it.
|
|
* 4. `getActiveAiConfig()` honors the `enabled` killswitch
|
|
* (throws AiDisabledError when off).
|
|
* 5. The processor's new code path actually reads from ai_settings:
|
|
* we ingest one quote, call processOneQuote(), and assert that
|
|
* the resulting ai_error is NOT "ai_disabled" when we set
|
|
* enabled=true + a bogus api_key (we expect a 401 from Anthropic
|
|
* or similar, NOT the killswitch path).
|
|
*
|
|
* Cleanup
|
|
* ───────
|
|
* The test mutates ai_settings and creates one quote. Both are
|
|
* reverted at the end (best-effort) so a successful run leaves PB
|
|
* indistinguishable from before. If the script crashes mid-run, the
|
|
* worst case is enabled=false + a placeholder api_key on ai_settings
|
|
* — the killswitch prevents any cost.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/smoke/ai-settings-round-trip.ts
|
|
*
|
|
* Pre-requisites:
|
|
* 1. Migration ran: `npm run pb:migrate:ai-settings`
|
|
* 2. .env.local has NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
|
|
* PB_ADMIN_PASSWORD.
|
|
*/
|
|
import "./_env"; // MUST be first
|
|
import {
|
|
getAiSettings,
|
|
updateAiSettings,
|
|
getActiveAiConfig,
|
|
AiDisabledError,
|
|
clearAiSettingsCache,
|
|
} from "../../src/lib/server/ai-settings-service";
|
|
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-ai-settings@example.invalid";
|
|
const SMOKE_SUBJECT = "[SMOKE] AI settings round trip";
|
|
const SMOKE_MESSAGE_ID = "smoke-ai-settings-2026-08-09-001@wfdash.local";
|
|
|
|
async function main() {
|
|
console.log("=== AI settings round-trip smoke test ===\n");
|
|
|
|
// ── 1. Capture original settings so we can restore at the end ─────
|
|
clearAiSettingsCache();
|
|
const original = await getAiSettings({ includeSecret: true });
|
|
console.log("[1/8] Captured original ai_settings:");
|
|
console.log(` provider=${original.provider || "(empty)"} enabled=${original.enabled}`);
|
|
console.log(` model_id="${original.model_id}" base_url="${original.base_url}"`);
|
|
console.log(` max_tokens=${original.max_tokens} api_key_set=${original.api_key_set}`);
|
|
|
|
let quoteId: string | null = null;
|
|
let customerId: string | null = null;
|
|
let companyId: string | null = null;
|
|
try {
|
|
// ── 2. Update ai_settings via the service ───────────────────────
|
|
console.log("\n[2/8] updateAiSettings({ enabled:true, provider:'anthropic', model_id, api_key, max_tokens:1024 })…");
|
|
await updateAiSettings({
|
|
enabled: true,
|
|
provider: "anthropic",
|
|
model_id: "claude-sonnet-4-5-20250929",
|
|
base_url: "",
|
|
max_tokens: 1024,
|
|
api_key: "smoke-test-key-DO-NOT-USE",
|
|
});
|
|
// Service bumps cache on write; subsequent read must reflect.
|
|
const after = await getAiSettings({ includeSecret: true });
|
|
if (after.enabled !== true) throw new Error("enabled did not flip to true");
|
|
if (after.provider !== "anthropic") throw new Error(`provider stayed "${after.provider}"`);
|
|
if (after.model_id !== "claude-sonnet-4-5-20250929") throw new Error("model_id did not update");
|
|
if (after.api_key !== "smoke-test-key-DO-NOT-USE") throw new Error("api_key did not update");
|
|
if (after.max_tokens !== 1024) throw new Error(`max_tokens stayed ${after.max_tokens}`);
|
|
console.log(" ✓ all fields updated as expected");
|
|
|
|
// ── 3. Default read redacts api_key ─────────────────────────────
|
|
const redacted = await getAiSettings();
|
|
if (redacted.api_key !== "") throw new Error(`expected api_key="" in redacted view, got "${redacted.api_key}"`);
|
|
if (!redacted.api_key_set) throw new Error("expected api_key_set=true after we set a key");
|
|
console.log("\n[3/8] Redacted view returns api_key=\"\" + api_key_set=true ✓");
|
|
|
|
// ── 4. getActiveAiConfig returns the live values ────────────────
|
|
const active = await getActiveAiConfig();
|
|
if (active.provider !== "anthropic") throw new Error(`active.provider=${active.provider}`);
|
|
if (active.apiKey !== "smoke-test-key-DO-NOT-USE") throw new Error("active.apiKey mismatch");
|
|
if (active.maxTokens !== 1024) throw new Error(`active.maxTokens=${active.maxTokens}`);
|
|
console.log(`\n[4/8] getActiveAiConfig() → provider=${active.provider}, maxTokens=${active.maxTokens} ✓`);
|
|
|
|
// ── 5. Killswitch: enabled=false throws AiDisabledError ────────
|
|
await updateAiSettings({ enabled: false });
|
|
let disabledThrown = false;
|
|
try {
|
|
await getActiveAiConfig();
|
|
} catch (e) {
|
|
if (e instanceof AiDisabledError) disabledThrown = true;
|
|
else throw e;
|
|
}
|
|
if (!disabledThrown) throw new Error("AiDisabledError NOT thrown when enabled=false");
|
|
console.log("\n[5/8] Killswitch honored — AiDisabledError thrown when enabled=false ✓");
|
|
|
|
// ── 6. Re-enable so the processor actually tries to call ────────
|
|
await updateAiSettings({ enabled: true });
|
|
|
|
// ── 7. End-to-end: ingest a quote + run processOneQuote ─────────
|
|
console.log("\n[6/8] Ingesting smoke-test quote + running processOneQuote()…");
|
|
const ingest = await ingestQuoteFromEmail({
|
|
from_email: SMOKE_FROM,
|
|
from_name: "Smoke Settings",
|
|
subject: SMOKE_SUBJECT,
|
|
body_plain:
|
|
"Please quote 5 x M10 x 30mm hex bolts grade 8.8 zinc. " +
|
|
"Need delivery to Pretoria.",
|
|
message_id: SMOKE_MESSAGE_ID,
|
|
company_hint: "Smoke Settings Co",
|
|
});
|
|
quoteId = ingest.quote.id;
|
|
customerId = ingest.customer.id;
|
|
companyId = ingest.company?.id ?? null;
|
|
console.log(` ingested quote ${ingest.quote.quote_number} (${quoteId})`);
|
|
|
|
const proc = await processOneQuote({ quoteId });
|
|
console.log(` processOneQuote → outcome=${proc.outcome} error="${proc.error.slice(0, 100)}"`);
|
|
if (proc.outcome !== "failed") {
|
|
throw new Error(`expected outcome=failed (bogus key), got ${proc.outcome}`);
|
|
}
|
|
if (proc.error === "ai_disabled") {
|
|
throw new Error(
|
|
"Processor short-circuited with ai_disabled despite enabled=true — " +
|
|
"cache may not be invalidating on updateAiSettings",
|
|
);
|
|
}
|
|
if (proc.error.length === 0) {
|
|
throw new Error("Processor returned no error message — should have 401 or similar");
|
|
}
|
|
console.log(" ✓ Processor read ai_settings (ai_error ≠ ai_disabled)");
|
|
|
|
// Verify the ai_error was stamped on the quote row.
|
|
const pb = await getAdminPocketBase();
|
|
const afterProc = await pb.collection("quotes").getOne(quoteId);
|
|
if (afterProc.ai_status !== "failed") {
|
|
throw new Error(`Expected ai_status="failed", got "${afterProc.ai_status}"`);
|
|
}
|
|
if (!afterProc.ai_error || afterProc.ai_error.length === 0) {
|
|
throw new Error("ai_error not stamped on the quote row");
|
|
}
|
|
console.log(` ai_status="failed" ai_error="${(afterProc.ai_error || "").slice(0, 80)}…"`);
|
|
|
|
// ── 8. Validation: bogus provider rejected ─────────────────────
|
|
console.log("\n[7/8] Validation: updateAiSettings rejects unknown provider…");
|
|
let rejected = false;
|
|
try {
|
|
// @ts-expect-error -- intentional bad input
|
|
await updateAiSettings({ provider: "openai_compatible" });
|
|
} catch (e) {
|
|
rejected = true;
|
|
console.log(` ✓ rejected: ${(e as Error).message}`);
|
|
}
|
|
if (!rejected) throw new Error("unknown provider was silently accepted");
|
|
|
|
// ── 9. Validation: max_tokens out of range ─────────────────────
|
|
rejected = false;
|
|
try {
|
|
await updateAiSettings({ max_tokens: 99999 });
|
|
} catch (e) {
|
|
rejected = true;
|
|
console.log(`[8/8] Validation: max_tokens out of range rejected ✓ (${(e as Error).message})`);
|
|
}
|
|
if (!rejected) throw new Error("max_tokens=99999 was silently accepted");
|
|
|
|
console.log("\n=== Smoke test PASSED ===");
|
|
} finally {
|
|
// ── Cleanup: restore ai_settings to original + delete smoke rows ─
|
|
console.log("\n[cleanup] Restoring ai_settings + deleting smoke rows…");
|
|
try {
|
|
await updateAiSettings({
|
|
provider: original.provider || "",
|
|
model_id: original.model_id,
|
|
base_url: original.base_url,
|
|
max_tokens: original.max_tokens || 4096,
|
|
enabled: original.enabled,
|
|
// Always clear the placeholder test key, then restore the
|
|
// original (if it was set) — `api_key: ""` then a second
|
|
// pass with the real value would race the cache, so we do
|
|
// them sequentially.
|
|
api_key: "",
|
|
});
|
|
if (original.api_key) {
|
|
await updateAiSettings({ api_key: original.api_key });
|
|
}
|
|
console.log(" ✓ ai_settings restored");
|
|
} catch (e) {
|
|
console.error(` ! could not restore ai_settings: ${(e as Error).message}`);
|
|
}
|
|
|
|
if (quoteId) {
|
|
try {
|
|
const pb = await getAdminPocketBase();
|
|
await pb.collection("quotes").delete(quoteId);
|
|
console.log(` ✓ deleted smoke quote ${quoteId}`);
|
|
} catch (e) {
|
|
console.log(` ! could not delete quote: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
if (customerId) {
|
|
try {
|
|
const pb = await getAdminPocketBase();
|
|
await pb.collection("customers").delete(customerId);
|
|
console.log(` ✓ deleted smoke customer ${customerId}`);
|
|
} catch (e) {
|
|
console.log(` (skipped customer delete: ${(e as Error).message})`);
|
|
}
|
|
}
|
|
if (companyId) {
|
|
try {
|
|
const pb = await getAdminPocketBase();
|
|
await pb.collection("companies").delete(companyId);
|
|
console.log(` ✓ deleted smoke company ${companyId}`);
|
|
} catch (e) {
|
|
console.log(` (skipped company delete: ${(e as Error).message})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("\nSmoke test FAILED:", e.message);
|
|
console.error(e.stack);
|
|
process.exit(1);
|
|
});
|