Add a Vitest unit test suite (cart totals, invoice tax grouping, bundle contents)
No test infrastructure existed in this repo yet. Covers the pure logic most likely to silently produce wrong numbers on a live order: discount/ shipping math, per-rate invoice grouping, and bundle-contents string building. Extracted describeBundleContents() out of the checkout route into its own module so it's importable from a test (route.ts files only allow HTTP-method exports).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeBundleContents } from "../bundleContents";
|
||||
import type { RawProduct } from "../productsServer";
|
||||
|
||||
const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
id: 1,
|
||||
slug: "starter-set",
|
||||
name: "Starter-Set",
|
||||
price: 29.9,
|
||||
active: true,
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
bundleItems: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("describeBundleContents", () => {
|
||||
it("returns null for a regular (non-bundle) product", () => {
|
||||
expect(describeBundleContents(product())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty bundleItems array", () => {
|
||||
expect(describeBundleContents(product({ bundleItems: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a single bundle item as 'qty× name'", () => {
|
||||
const result = describeBundleContents(
|
||||
product({ bundleItems: [{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 }] }),
|
||||
);
|
||||
expect(result).toBe("2× ToDo-Karten");
|
||||
});
|
||||
|
||||
it("joins multiple bundle items with a comma", () => {
|
||||
const result = describeBundleContents(
|
||||
product({
|
||||
bundleItems: [
|
||||
{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 },
|
||||
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toBe("2× ToDo-Karten, 1× Wochenplaner");
|
||||
});
|
||||
|
||||
it("skips a line whose product didn't resolve to an object (depth miss)", () => {
|
||||
const result = describeBundleContents(
|
||||
product({
|
||||
bundleItems: [
|
||||
{ product: 5, quantity: 1 },
|
||||
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toBe("1× Wochenplaner");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSubtotal, computeCartTotals, type CartLine } from "../cartTotals";
|
||||
import type { Product } from "../payload";
|
||||
|
||||
const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
price: 12.9,
|
||||
compareAtPrice: null,
|
||||
image: "",
|
||||
href: null,
|
||||
active: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
spotlight: false,
|
||||
spotlightEyebrow: null,
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const line = (qty: number, productOverrides: Partial<Product> = {}): CartLine => ({ entry: { qty }, product: product(productOverrides) });
|
||||
|
||||
describe("computeSubtotal", () => {
|
||||
it("sums quantity × price across lines", () => {
|
||||
expect(computeSubtotal([line(2, { price: 10 }), line(1, { price: 5 })])).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty cart", () => {
|
||||
expect(computeSubtotal([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCartTotals", () => {
|
||||
it("adds shipping on top of the subtotal with no discount", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 20 })], 2.9, null);
|
||||
expect(totals.subtotal).toBe(20);
|
||||
expect(totals.total).toBeCloseTo(22.9, 6);
|
||||
expect(totals.discountAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("applies a percent discount before adding shipping", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 100 })], 5, { type: "percent", value: 10 });
|
||||
expect(totals.discountAmount).toBe(10);
|
||||
expect(totals.total).toBe(95); // 100 - 10 + 5
|
||||
});
|
||||
|
||||
it("applies a fixed discount, clamped so the total never goes negative", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 5 })], 0, { type: "fixed", value: 50 });
|
||||
expect(totals.discountAmount).toBe(5); // clamped to subtotal
|
||||
expect(totals.total).toBe(0);
|
||||
});
|
||||
|
||||
it("computes totalSavings from compareAtPrice, separately from the discount code", () => {
|
||||
const totals = computeCartTotals([line(2, { price: 10, compareAtPrice: 15 })], 0, null);
|
||||
expect(totals.totalSavings).toBe(10); // 2 × (15 - 10)
|
||||
expect(totals.subtotal).toBe(20); // uses price, not compareAtPrice
|
||||
});
|
||||
|
||||
it("ignores compareAtPrice when it isn't actually higher than price", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 10, compareAtPrice: 10 })], 0, null);
|
||||
expect(totals.totalSavings).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { __testables, type InvoiceItem, type InvoiceOrder } from "../invoicePdf";
|
||||
|
||||
const { isPaidImmediately, groupByTaxRate } = __testables;
|
||||
|
||||
const item = (overrides: Partial<InvoiceItem> = {}): InvoiceItem => ({
|
||||
productName: "ToDo-Karten",
|
||||
quantity: 2,
|
||||
unitPrice: 12.9,
|
||||
taxRatePercent: 19,
|
||||
bundleContents: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const order = (overrides: Partial<InvoiceOrder> = {}): InvoiceOrder => ({
|
||||
orderNumber: "#EP-0001",
|
||||
invoiceNumber: "RE-0001",
|
||||
invoiceIssuedAt: new Date().toISOString(),
|
||||
customerFirstName: "Max",
|
||||
customerLastName: "Mustermann",
|
||||
deliveryMethod: "address",
|
||||
street: "Musterweg 1",
|
||||
zip: "10115",
|
||||
city: "Berlin",
|
||||
country: "Deutschland",
|
||||
paymentMethodTitle: "Kreditkarte",
|
||||
items: [item()],
|
||||
subtotal: 25.8,
|
||||
shippingCost: 2.9,
|
||||
discountAmount: 0,
|
||||
discountCode: null,
|
||||
total: 28.7,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isPaidImmediately", () => {
|
||||
it("is true for Kreditkarte", () => {
|
||||
expect(isPaidImmediately("Kreditkarte")).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for PayPal", () => {
|
||||
expect(isPaidImmediately("PayPal")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false only for Überweisung", () => {
|
||||
expect(isPaidImmediately("Überweisung")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to true for any future/unknown payment method (only Überweisung is the named exception)", () => {
|
||||
expect(isPaidImmediately("Sofortüberweisung")).toBe(true);
|
||||
expect(isPaidImmediately("Klarna")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByTaxRate (original invoice)", () => {
|
||||
it("reconciles net+tax to gross, and gross to the order total for a single rate", () => {
|
||||
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9 })], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
expect(groups).toHaveLength(1);
|
||||
const [g] = groups;
|
||||
expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
|
||||
expect(g.gross).toBeCloseTo(28.7, 2);
|
||||
});
|
||||
|
||||
it("distributes a discount proportionally, still reconciling to the discounted total", () => {
|
||||
const o = order({
|
||||
items: [item({ quantity: 1, unitPrice: 50 })],
|
||||
subtotal: 50,
|
||||
shippingCost: 0,
|
||||
discountAmount: 10,
|
||||
total: 40,
|
||||
});
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
const total = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(total).toBeCloseTo(40, 2);
|
||||
});
|
||||
|
||||
it("splits multiple tax rates into separate groups that each reconcile", () => {
|
||||
const o = order({
|
||||
items: [item({ quantity: 1, unitPrice: 20, taxRatePercent: 19 }), item({ quantity: 1, unitPrice: 10, taxRatePercent: 7 })],
|
||||
subtotal: 30,
|
||||
shippingCost: 0,
|
||||
discountAmount: 0,
|
||||
total: 30,
|
||||
});
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const g of groups) expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
|
||||
expect(groups.reduce((sum, g) => sum + g.gross, 0)).toBeCloseTo(30, 2);
|
||||
});
|
||||
|
||||
it("falls back to the tenant default rate when an item has no taxRatePercent", () => {
|
||||
const o = order({ items: [item({ taxRatePercent: undefined as unknown as number })] });
|
||||
const groups = groupByTaxRate(o, 7);
|
||||
expect(groups[0].rate).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RawProduct } from "./productsServer";
|
||||
|
||||
// A bundle's `bundleItems` is only ever resolved once, at checkout, into a
|
||||
// plain readable string — the order line stores this snapshot
|
||||
// (`items[].bundleContents`), not a structured sub-list, so a later change
|
||||
// to the bundle's own composition can never rewrite what a past order
|
||||
// actually contained. `product.bundleItems[].product` is a relationship
|
||||
// resolved by fetchProductsBySlug's depth:2 fetch — {id, name} objects
|
||||
// when populated, a bare id if depth somehow didn't reach it (skipped).
|
||||
//
|
||||
// Extracted out of app/api/checkout/route.ts into its own module (not
|
||||
// just kept as a local function there) so it's importable from a unit
|
||||
// test — Next.js route.ts files only allow HTTP-method + a few config
|
||||
// exports, not arbitrary named exports.
|
||||
export function describeBundleContents(product: RawProduct): string | null {
|
||||
if (!product.bundleItems || product.bundleItems.length === 0) return null;
|
||||
return product.bundleItems
|
||||
.map((line) => (typeof line.product === "object" ? `${line.quantity}× ${line.product.name}` : null))
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.join(", ");
|
||||
}
|
||||
@@ -316,3 +316,8 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||||
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
|
||||
}
|
||||
|
||||
// Exported for unit testing (see app/lib/__tests__/invoicePdf.test.ts) —
|
||||
// the actual money math and payment-status logic, independent of PDF
|
||||
// rendering.
|
||||
export const __testables = { isPaidImmediately, groupByTaxRate };
|
||||
|
||||
Reference in New Issue
Block a user