import { describe, expect, it, vi } from "vitest"; import type { ErpnextConfig, ErpnextConnectionTest, ErpnextPushResult, ErpnextSyncStatus } from "./erpnext"; import { autoPushIssued, baseUrlProblem, connectionProblems, describePaymentPush, describePush, failedPush, hasDetectResult, isConfigured, paymentPushDisabledReason, pushDisabledReason, summarizeBatch, syncTag, toInput, validateConfigForm, warningSeverity, withDefaultUoms, withStoredValue, sortWarnings, sendableSelection, formatSyncedAt, } from "./erpnextUi"; const test: ErpnextConnectionTest = { user: "api@example.com", versions: { frappe: "15.80.0", erpnext: "15.70.0", indiaCompliance: "15.30.0" }, features: { v2Naming: true, indiaCompliance: true }, indiaCompliance: true, warnings: [], }; const config = (patch: Partial = {}): ErpnextConfig => ({ baseUrl: "https://erp.example.com", apiKey: "key", apiSecretSet: true, extraCaPem: "", company: "Test Co", companyAddress: "", namingMode: "mirror", namingSeries: "", incomeAccount: "", costCenter: "", cgstAccount: "", sgstAccount: "", utgstAccount: "", igstAccount: "", taxTemplateIntra: "", taxTemplateInter: "", paymentBankAccount: "", tdsAccount: "", defaultItemCode: "", uomMap: {}, customerGroup: "", territory: "", sellingPriceList: "", submitOnPush: false, attachPdf: true, autoPushOnIssue: false, createMissingCustomers: true, lastDetectResult: test, ...patch, }); const sync = (patch: Partial): ErpnextSyncStatus => ({ invoiceId: 1, status: "synced", remoteName: "INV/2026-001", remoteDocstatus: 0, lastError: "", syncedAt: null, attached: false, ...patch, }); const push = (patch: Partial = {}): ErpnextPushResult => ({ invoiceId: 1, number: "INV/2026-001", ok: true, status: "synced", remoteName: "INV/2026-001", remoteDocstatus: 0, created: true, noOp: false, attached: true, error: null, errorKind: null, warnings: [], ...patch, }); describe("status tags", () => { it("maps every sync state", () => { expect(syncTag(undefined)).toMatchObject({ label: "Not sent", type: "gray" }); expect(syncTag(sync({ status: "none" })).label).toBe("Not sent"); expect(syncTag(sync({ remoteDocstatus: 0 }))).toMatchObject({ label: "Draft in ERPNext", type: "blue" }); expect(syncTag(sync({ remoteDocstatus: 1 }))).toMatchObject({ label: "Submitted", type: "green" }); expect(syncTag(sync({ status: "error", lastError: "Item missing" }))).toMatchObject({ label: "Error", type: "red", tooltip: "Item missing" }); expect(syncTag(sync({ status: "conflict", lastError: "" }))).toMatchObject({ label: "Conflict", type: "purple" }); expect(syncTag(sync({ status: "error", lastError: "" })).tooltip).toBeTruthy(); }); }); describe("configured and disabled reasons", () => { it("needs an address, a key and a stored secret", () => { expect(isConfigured(config())).toBe(true); expect(isConfigured(config({ baseUrl: " " }))).toBe(false); expect(isConfigured(config({ apiKey: "" }))).toBe(false); expect(isConfigured(config({ apiSecretSet: false }))).toBe(false); expect(isConfigured(null)).toBe(false); }); it("asks for a connection test only when the vendor is registered", () => { const untested = config({ lastDetectResult: null }); expect(pushDisabledReason({ cfg: untested, vendorRegistered: true })).toMatch(/Run Test connection first/); expect(pushDisabledReason({ cfg: untested, vendorRegistered: false })).toBeNull(); expect(pushDisabledReason({ cfg: config(), vendorRegistered: true })).toBeNull(); expect(hasDetectResult(null)).toBe(false); expect(hasDetectResult({} as ErpnextConnectionTest)).toBe(false); expect(pushDisabledReason({ cfg: null, vendorRegistered: false })).toMatch(/not set up/); expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "cancelled" })).toMatch(/Cancelled/); expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "issued" })).toBeNull(); }); it("allows a payment only against a submitted invoice, once", () => { const cfg = config(); expect(paymentPushDisabledReason({ cfg, sync: undefined, entry: null })).toMatch(/Send the invoice/); expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 0 }), entry: null })).toMatch(/Submit the invoice in ERPNext first/); expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: null })).toBeNull(); expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: "ACC-PAY-1" })).toMatch(/ACC-PAY-1/); }); }); describe("push results", () => { it("describes outcomes", () => { expect(describePush(push())).toMatchObject({ kind: "success", subtitle: "INV/2026-001 (draft)" }); expect(describePush(push({ remoteDocstatus: 1 })).subtitle).toContain("submitted"); expect(describePush(push({ noOp: true })).kind).toBe("info"); const warned = describePush(push({ warnings: ["The PDF was not attached: no permission"] })); expect(warned.kind).toBe("warning"); expect(warned.subtitle).toContain("no permission"); expect(describePush(push({ ok: false, status: "refused", error: "Run Test connection first", errorKind: "precondition" })).kind).toBe("warning"); expect(describePush(push({ ok: false, status: "conflict", error: "different total" })).title).toMatch(/conflicts/); expect(describePush(push({ ok: false, status: "error", error: "417", errorKind: "validation" })).kind).toBe("error"); expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: true, entryName: "PE-1", alreadySynced: false, error: null, errorKind: null }).kind).toBe("success"); expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: false, entryName: null, alreadySynced: false, error: "nope", errorKind: "other" }).kind).toBe("error"); }); it("turns a rejected call into a failed row", () => { const r = failedPush(3, "INV/3", "ERPNext is not set up yet"); expect(r).toMatchObject({ ok: false, invoiceId: 3, number: "INV/3", error: "ERPNext is not set up yet" }); expect(failedPush(3, "INV/3", new Error("boom")).error).toBe("boom"); }); it("summarises a batch", () => { const s = summarizeBatch([push(), push({ noOp: true }), push({ ok: false, status: "error", error: "x" })]); expect(s).toMatchObject({ total: 3, sent: 1, upToDate: 1, failed: 1, kind: "warning" }); expect(s.title).toBe("1 sent, 1 already up to date, 1 failed"); expect(summarizeBatch([push(), push()]).kind).toBe("success"); expect(summarizeBatch([push({ ok: false })]).kind).toBe("error"); expect(summarizeBatch([push({ warnings: ["w"] })]).kind).toBe("warning"); }); }); describe("auto push on issue", () => { const invoice = { id: 7, number: "INV/2026-007" }; it("does nothing when it is off or ERPNext is not set up", async () => { const pushFn = vi.fn(); expect(await autoPushIssued({ getConfig: async () => config(), push: pushFn }, invoice, true)).toBeNull(); expect(await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, apiSecretSet: false }), push: pushFn }, invoice, true)).toBeNull(); expect(await autoPushIssued({ getConfig: async () => Promise.reject(new Error("db")), push: pushFn }, invoice, true)).toBeNull(); expect(pushFn).not.toHaveBeenCalled(); }); it("pushes with the configured submit setting and reports the result", async () => { const pushFn = vi.fn(async () => push({ number: invoice.number })); const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true }), push: pushFn }, invoice, true); expect(pushFn).toHaveBeenCalledWith(7); expect(notice?.kind).toBe("success"); }); it("never throws: a rejected push becomes an error notice", async () => { const notice = await autoPushIssued( { getConfig: async () => config({ autoPushOnIssue: true }), push: async () => Promise.reject("ERPNext is not set up yet") }, invoice, false, ); expect(notice).toMatchObject({ kind: "error" }); expect(notice?.subtitle).toContain("not set up"); }); it("explains a missing connection test instead of pushing", async () => { const pushFn = vi.fn(); const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, lastDetectResult: null }), push: pushFn }, invoice, true); expect(pushFn).not.toHaveBeenCalled(); expect(notice?.subtitle).toMatch(/Run Test connection first/); }); }); describe("warnings", () => { it("ranks errors before warnings before notes", () => { expect(warningSeverity("gst_accounts_not_configured")).toBe("error"); expect(warningSeverity("ic_not_detected")).toBe("warning"); expect(warningSeverity("versions_unknown")).toBe("info"); const sorted = sortWarnings([ { code: "versions_unknown", message: "a" }, { code: "company_missing", message: "b" }, { code: "ic_not_detected", message: "c" }, ]); expect(sorted.map((w) => w.code)).toEqual(["company_missing", "ic_not_detected", "versions_unknown"]); }); }); describe("settings form", () => { it("validates the address like the backend", () => { expect(baseUrlProblem("")).toMatch(/Enter/); expect(baseUrlProblem("https://erp.example.com")).toBeNull(); expect(baseUrlProblem("erp.example.com")).toBeNull(); expect(baseUrlProblem("http://erp.example.com")).toMatch(/https/); expect(baseUrlProblem("http://localhost:8000")).toBeNull(); expect(baseUrlProblem("localhost:8000")).toBeNull(); expect(baseUrlProblem("http://site.test")).toBeNull(); expect(baseUrlProblem("http://127.0.0.1:8000")).toBeNull(); expect(baseUrlProblem("https://user:pw@erp.example.com")).toMatch(/credentials/); expect(baseUrlProblem("ftp://erp.example.com")).toMatch(/https/); }); it("builds the input without write-only fields and fills the UOM defaults", () => { const input = toInput(config({ uomMap: { hour: "Hr" } })); expect(input).not.toHaveProperty("apiSecretSet"); expect(input).not.toHaveProperty("lastDetectResult"); expect(input.apiSecret).toBe(""); expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" }); expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour"); }); it("needs a secret only when none is stored or it is being cleared", () => { const form = toInput(config()); expect(connectionProblems(form, true)).toEqual({}); expect(connectionProblems(form, false).apiSecret).toBeTruthy(); expect(connectionProblems({ ...form, clearSecret: true }, true).apiSecret).toBeTruthy(); expect(connectionProblems({ ...form, clearSecret: true, apiSecret: "new" }, true)).toEqual({}); expect(connectionProblems({ ...form, apiKey: " " }, true).apiKey).toBeTruthy(); }); it("validates a save: untouched is fine, series mode needs a series, mirror needs v2 naming", () => { const empty = toInput(config({ baseUrl: "", apiKey: "", apiSecretSet: false })); expect(validateConfigForm(empty, false, null)).toEqual({}); const form = toInput(config()); expect(validateConfigForm(form, true, test)).toEqual({}); expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "" }, true, test).namingSeries).toBeTruthy(); expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "SINV-.YY.-" }, true, test)).toEqual({}); const old = { ...test, features: { v2Naming: false, indiaCompliance: true } }; expect(validateConfigForm(form, true, old).namingMode).toBeTruthy(); expect(validateConfigForm(form, true, null).namingMode).toBeUndefined(); }); it("keeps a saved value visible when the loaded list lacks it", () => { const items = [{ name: "A", label: "A", detail: "" }]; expect(withStoredValue(items, "A")).toBe(items); expect(withStoredValue(items, "")).toBe(items); const out = withStoredValue(items, "Sales - AC"); expect(out[0]).toMatchObject({ name: "Sales - AC" }); expect(out).toHaveLength(2); }); }); describe("selection and dates", () => { it("leaves cancelled invoices out of a send", () => { const rows = [ { id: 1, status: "issued" }, { id: 2, status: "cancelled" }, { id: 3, status: "issued" }, ]; expect(sendableSelection(rows)).toEqual({ send: [rows[0], rows[2]], skipped: 1 }); expect(sendableSelection([])).toEqual({ send: [], skipped: 0 }); }); it("formats the sync time defensively", () => { expect(formatSyncedAt(null)).toBe(""); expect(formatSyncedAt("not a date")).toBe("not a date"); expect(formatSyncedAt("2026-04-01T10:30:00+00:00")).toMatch(/2026/); }); });