import { api } from "./api"; import { createExportDeps } from "./exportDeps"; import { exportInvoice, issueAndArchive } from "./exportFlow"; import { parsePrefs } from "../pdf/model/prefs"; import { bytesEqual, dirOf, type StepLog } from "./selfTestSteps"; import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types"; /** * The end-to-end invoice flow through the real IPC, run against the fresh database the self-test * launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a * fake vendor and bank the way the setup wizard would. */ const today = () => { const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }; const hex = (bytes: Uint8Array) => Array.from(bytes.slice(0, 5), (b) => b.toString(16).padStart(2, "0")).join(""); const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`; export async function runE2eSteps(log: StepLog, reportPath: string): Promise { const outDir = dirOf(reportPath); const searchablePath = `${outDir}/e2e-searchable.pdf`; const flattenedPath = `${outDir}/e2e-flattened.pdf`; let s!: Settings; let bank!: BankAccount; let invoice: Invoice | null = null; const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => { s = await api.getSettings(); let banks = await api.listBanks(); if (banks.length === 0) { const saved = await api.saveBank({ id: null, label: "Primary", bankName: "Self-test Bank", accountName: "Self-test Vendor", accountNo: "000000000000", branch: "000001", ifsc: "TEST0000001", isDefault: true, }); s = await api.saveSettings({ ...s, vendorName: s.vendorName || "Self-test Vendor", vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001", vendorStateCode: s.vendorStateCode || "29", defaultBankId: saved.id, onboarded: true, }); banks = await api.listBanks(); } const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0]; if (!found) throw new Error("no bank account after provisioning"); bank = found; return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`; }); if (!loaded.ok) return; const prefs = parsePrefs(s.renderPrefs); const input: InvoiceInput = { invoiceDate: today(), dueDate: "", clientId: null, clientName: "Self-test Client Pvt Ltd", clientAddress: "42 Example Road, Indiranagar, Bengaluru - 560038", clientGstin: "", poNumber: "PO-SELFTEST", placeOfSupplyStateCode: "", discount: 0, taxType: s.gstRegistration === "unregistered" ? "none" : s.defaultTaxType, taxRate: s.gstRegistration === "unregistered" ? 0 : s.defaultTaxRate, reverseCharge: false, bankAccountId: bank.id, signaturePath: null, notes: "Self-test invoice", saveClient: false, draftId: null, items: [ { description: "Voice-over, 60 second spot", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 4500, sortOrder: 0 }, { description: "Studio session", mode: "rate", rate: 1200, unit: "hour", quantity: 2, amount: 0, sortOrder: 1 }, { description: "Revision round", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 850.5, sortOrder: 2 }, ], }; const deps = createExportDeps({ settings: s, bank }); // What the export wrote, per path, so the checks need no way to read arbitrary files back. const written = new Map(); const exportWith = (mode: "searchable" | "flattened", source: "original" | "rerender", path: string) => exportInvoice( { invoice: inv, mode, source }, { ...deps, save: async () => path, writeExportFile: async (p, bytes) => { written.set(p, bytes); return deps.writeExportFile(p, bytes); }, }, ); let inv!: Invoice; const issued = await log.run("e2e: issue and archive", async () => { const outcome = await issueAndArchive(input, prefs, deps); invoice = outcome.invoice; return { ok: outcome.archived, detail: `${outcome.invoice.number} (id ${outcome.invoice.id}), total ${outcome.invoice.total}, archived=${outcome.archived}${outcome.error ? `, error: ${outcome.error}` : ""}`, }; }); if (!issued.ok || !invoice) return; inv = invoice as Invoice; await log.run("e2e: archive status", async () => { const st = await api.archiveStatus(inv.id); const matches = st.archived && st.sha256 === inv.archivedPdfSha256 && st.fingerprint === inv.archivedFingerprint; return { ok: matches, detail: `archived=${st.archived}, sha ${st.sha256?.slice(0, 12)}, fingerprint ${st.fingerprint?.slice(0, 12)}, matches issue result: ${matches}` }; }); await log.run("e2e: export searchable original", async () => { const r = await exportWith("searchable", "original", searchablePath); return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` }; }); await log.run("e2e: export flattened original", async () => { const r = await exportWith("flattened", "original", flattenedPath); return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` }; }); await log.run("e2e: export searchable re-render", async () => { const r = await exportWith("searchable", "rerender", `${outDir}/e2e-rerender.pdf`); return { ok: !!r && r.identicalToIssued !== undefined, detail: `identicalToIssued=${r?.identicalToIssued}` }; }); await log.run("e2e: archive bytes equal the searchable export", async () => { const archived = await api.readArchive(inv.id); const exported = written.get(searchablePath); const eq = !!exported && bytesEqual(archived, exported); return { ok: eq, detail: `${kb(archived.byteLength)} header ${hex(archived)}, equal to export file: ${eq}` }; }); await log.run("e2e: last export directory", async () => { const dir = await api.getLastExportDir(); return { ok: dir === outDir, detail: `${dir} (expected ${outDir})` }; }); await log.run("e2e: cancel keeps archive readable", async () => { const before = await api.readArchive(inv.id); const cancelled = await api.cancelInvoice(inv.id, "self-test"); const after = await api.readArchive(inv.id); const st = await api.archiveStatus(inv.id); return { ok: cancelled.status === "cancelled" && st.archived && bytesEqual(before, after), detail: `status ${cancelled.status}, archive still ${kb(after.byteLength)}, unchanged: ${bytesEqual(before, after)}`, }; }); await log.run("e2e: archiving different bytes is refused", async () => { const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n"); const before = await api.readArchive(inv.id); try { await api.archivePdf(inv.id, other, "tampered"); } catch (e) { const msg = String(e); const after = await api.readArchive(inv.id); const intact = bytesEqual(after, before); return { ok: /cannot be replaced/.test(msg) && intact, detail: `refused: ${msg}; archive unchanged: ${intact}` }; } return { ok: false, detail: "the replacement was accepted" }; }); }