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:
Marco
2026-07-22 11:40:31 +00:00
parent 91f6fef6ea
commit 51fee198f4
9 changed files with 1101 additions and 26 deletions
+2 -16
View File
@@ -4,25 +4,11 @@ import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../..
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
import { fetchProductsBySlug, type RawProduct } from "../../lib/productsServer";
import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
// A bundle's `bundleItems` is only ever resolved once, right here, 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).
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(", ");
}
type CheckoutBody = {
cart: CartItem[];
shippingMethodId: number;
+56
View File
@@ -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");
});
});
+65
View File
@@ -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);
});
});
+97
View File
@@ -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);
});
});
+21
View File
@@ -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(", ");
}
+5
View File
@@ -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 };