Files
template-app/modules/invoicing/__tests__/core.test.ts
T
2026-11-14 09:56:45 +00:00

162 lines
6.8 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Simulated ports cover contract/app rules; real service fiscal transactions are separate acceptance.
import { describe, expect, it, vi } from "vitest"
import { decimal, draft, invoiceTerms, issue, revision } from "../contract/index.js"
import { transitionInvoice, updateDraft, validateTerms } from "../core/invoices.js"
import { type InvoicingService, readsOf } from "../core/ports.js"
import { en } from "../i18n/en.js"
import { lt } from "../i18n/lt.js"
import { draftData } from "../ui/form-data.js"
import { id, invoiceId, row, terms } from "./fixture.js"
function service(): InvoicingService {
return {
list: vi.fn(async () => ({ rows: [row] })),
get: vi.fn(async () => row),
series: vi.fn(async () => ({ rows: [] })),
ubl: vi.fn(async () => ({ filename: "fixture.xml", xml: "<Invoice/>" })),
draft: vi.fn(async () => row),
update: vi.fn(async () => row),
transition: vi.fn(async () => row),
createSeries: vi.fn(async () => {
throw new Error("not in this fixture")
}),
}
}
describe("Invoicing contracts and safe app operations", () => {
it("keeps amounts exact and rejects number/exponent and malformed integer inputs without throwing", () => {
expect(invoiceTerms.parse(terms).lines[0]?.net_price.amount).toBe("9007199254740993.123456789012345678")
for (const bad of [1, NaN, Infinity, "1e3", "1.2.3", ""]) expect(decimal.safeParse(bad).success).toBe(false)
for (const bad of ["oops", "1.5", "9223372036854775808", ""]) expect(revision.safeParse(bad).success).toBe(false)
expect(
draft.input.safeParse({
org: "fixture",
data: { ...terms, lines: [{ ...terms.lines[0], quantity: "oops" }] },
requestId: id,
}).success,
).toBe(false)
})
it("refuses cross-currency terms before an external mutation", () => {
expect(() =>
validateTerms({
...terms,
lines: [
{
...terms.lines[0],
quantity: "2",
item_name: "Synthetic",
unit: "C62",
vat_category: "S",
net_price: { currency: "USD", amount: "1" },
},
],
}),
).toThrow()
})
it("requires an explicit confirmation and grants no write methods to a read view", async () => {
expect(
issue.input.safeParse({ org: "fixture", invoiceId, expectedVersion: "1", requestId: id, confirmed: false })
.success,
).toBe(false)
const port = service()
expect("draft" in readsOf(port)).toBe(false)
await expect(
transitionInvoice(port, "issue", { invoiceId, expectedVersion: "1", requestId: id, confirmed: false as never }),
).rejects.toMatchObject({ code: "bad_request" })
expect(port.transition).not.toHaveBeenCalled()
})
it("forwards the same exact revision and idempotency key on response-loss retries", async () => {
const port = service()
const input = { invoiceId, expectedVersion: "1", requestId: id, confirmed: true as const }
await transitionInvoice(port, "issue", input)
await transitionInvoice(port, "issue", input)
expect(vi.mocked(port.transition).mock.calls[0]).toEqual(vi.mocked(port.transition).mock.calls[1])
expect(port.transition).toHaveBeenCalledWith(
"issue",
expect.objectContaining({ expectedVersion: "1", key: `invoicing:issue:${id}` }),
)
})
it("does not predict a future revision, and delegates stale receipt resolution to the service", async () => {
const port = service()
await expect(
updateDraft(port, { invoiceId, data: terms, expectedVersion: "9007199254740994", requestId: id }),
).rejects.toMatchObject({ code: "conflict" })
expect(port.update).not.toHaveBeenCalled()
await updateDraft(port, { invoiceId, data: terms, expectedVersion: "1", requestId: id })
expect(port.update).toHaveBeenCalledWith(invoiceId, terms, "1", `invoicing:update:${id}`)
})
it("retains unedited invoice terms and exact digits while editing an existing draft", () => {
const prior = {
...terms,
notes: ["Synthetic note"],
ext: { fixture: "retain" },
payment_means: { code: "30", account_id: "SYNTHETIC" },
seller: { ...terms.seller, trading_name: "Synthetic trading" },
}
const values: Record<string, string> = {
series: terms.series_id,
currency: terms.currency,
issueDate: terms.issue_date,
buyerReference: terms.buyer_reference,
}
for (const side of ["seller", "buyer"] as const) {
const party = terms[side]
Object.assign(values, {
[`${side}.reference`]: party.reference,
[`${side}.name`]: party.name,
[`${side}.country`]: party.address.country,
[`${side}.scheme`]: party.endpoint.scheme,
[`${side}.endpoint`]: party.endpoint.value,
[`${side}.vatScheme`]: party.vat_identifier?.scheme ?? "vat",
[`${side}.vatValue`]: party.vat_identifier?.value ?? "",
})
}
const line = terms.lines[0]
if (!line) throw new Error("fixture line absent")
Object.assign(values, {
"line.0.name": line.item_name,
"line.0.quantity": line.quantity,
"line.0.unit": line.unit,
"line.0.price": line.net_price.amount,
"line.0.category": line.vat_category,
"line.0.rate": line.vat_rate ?? "",
})
const next = draftData(values, 1, "en", prior)
expect(next.ext).toEqual(prior.ext)
expect(next.payment_means).toEqual(prior.payment_means)
expect(next.seller.trading_name).toBe(prior.seller.trading_name)
expect(next.notes).toEqual(prior.notes)
expect(next.lines[0]?.net_price.amount).toBe(line.net_price.amount)
})
it("reads a price and a VAT rate as a Lithuanian reader types them, exactly", () => {
const values: Record<string, string> = {
series: terms.series_id,
currency: "EUR",
issueDate: terms.issue_date,
buyerReference: terms.buyer_reference,
"line.0.name": "Varžtas",
"line.0.quantity": "3",
"line.0.unit": "H87",
"line.0.price": "1\u00a0234,50",
"line.0.category": "S",
"line.0.rate": "21",
}
for (const side of ["seller", "buyer"] as const) {
const party = terms[side]
Object.assign(values, {
[`${side}.reference`]: party.reference,
[`${side}.name`]: party.name,
[`${side}.scheme`]: party.endpoint.scheme,
[`${side}.endpoint`]: party.endpoint.value,
[`${side}.country`]: party.address.country,
})
}
const next = draftData(values, 1, "lt", terms)
expect(next.lines[0]?.net_price).toEqual({ currency: "EUR", amount: "1234.50" })
expect(next.lines[0]?.vat_rate).toBe("21")
expect(() => draftData({ ...values, "line.0.price": "12,5,0" }, 1, "lt", terms)).toThrow()
})
it("ships the same translated labels in both languages", () =>
expect(Object.keys(en).sort()).toEqual(Object.keys(lt).sort()))
})