187 lines
7.9 KiB
TypeScript
187 lines
7.9 KiB
TypeScript
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<void> {
|
|
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<string, Uint8Array>();
|
|
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);
|
|
},
|
|
},
|
|
);
|
|
|
|
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;
|
|
const 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 notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
|
|
|
|
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: a new invoice can be issued and exported after the first", async () => {
|
|
const first = inv.number;
|
|
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
|
|
const next = `${outDir}/e2e-second.pdf`;
|
|
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
|
|
return {
|
|
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
|
|
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
|
|
};
|
|
});
|
|
|
|
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" };
|
|
});
|
|
}
|