From 9b0c6af8b7493ce2a9b027a516ec48bac99dca2c Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 8 Oct 2026 22:36:47 +0200 Subject: [PATCH] Test the checkout code; publish images only for releases (#5) - src/checkout/ (PayPal order request, pricing, VAT) and src/logging/format.ts hold the checkout's pure code, moved out of PayPalApi, the product service and config without a behaviour change. - npm test runs node:test on the TypeScript sources (type stripping), with no dependencies: 21 tests, 13 pin today's output, 8 describe the correct behaviour for the known defects of https://git.librete.ch/libretech/mp/issues/67 and are marked todo. - CI: the tests run in the image's base (node:22.14.0-alpine) for every pull request, push to main and release tag. The image is built for pull requests and built and published only for v* tags; main no longer publishes :main and :sha-* images. Refs https://git.librete.ch/libretech/mp/issues/71 Co-authored-by: Michael Czechowski --- .gitea/workflows/build.yml | 22 ++- config/constants.ts | 6 +- config/logger.ts | 10 +- package.json | 1 + src/api/product/services/product.ts | 7 +- src/checkout/paypal-order.ts | 124 +++++++++++++++ src/checkout/pricing.ts | 5 + src/checkout/vat.ts | 4 + src/logging/format.ts | 14 ++ src/services/PayPalApi.ts | 85 +--------- tests/unit/log-format.test.ts | 80 ++++++++++ tests/unit/paypal-order.test.ts | 234 ++++++++++++++++++++++++++++ tests/unit/pricing.test.ts | 26 ++++ tests/unit/support/register.mjs | 6 + tests/unit/support/resolve-ts.mjs | 14 ++ 15 files changed, 542 insertions(+), 96 deletions(-) create mode 100644 src/checkout/paypal-order.ts create mode 100644 src/checkout/pricing.ts create mode 100644 src/checkout/vat.ts create mode 100644 src/logging/format.ts create mode 100644 tests/unit/log-format.test.ts create mode 100644 tests/unit/paypal-order.test.ts create mode 100644 tests/unit/pricing.test.ts create mode 100644 tests/unit/support/register.mjs create mode 100644 tests/unit/support/resolve-ts.mjs diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 340fb8d..ef24a9e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -8,7 +8,27 @@ on: branches: [main] jobs: + test: + runs-on: ubuntu-latest + container: + image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + # The tests need no dependencies. They run in the image's base (Dockerfile FROM), so on the runtime's Node, + # without network. The source is piped in because the job container's paths do not exist on the Docker host. + - name: npm test, on the Node of the image + run: | + base=$(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile | head -1) + tar -c --exclude=.git . | docker run -i --rm --network none -e npm_config_update_notifier=false "$base" \ + sh -c 'mkdir /w && cd /w && tar -x && npm test' + + # The image is built to check a pull request, and built and published only for a release tag. + # Nothing pulls per-commit images, so main no longer publishes :main and :sha-* images. build: + needs: test + if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest container: image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e @@ -21,4 +41,4 @@ jobs: registry: ${{ secrets.REGISTRY }} registry_user: ${{ secrets.REGISTRY_USER }} registry_pass: ${{ secrets.REGISTRY_PASS }} - publish: ${{ github.event_name == 'push' && vars.PUBLISH_ENABLED == 'true' }} + publish: ${{ startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_ENABLED == 'true' }} diff --git a/config/constants.ts b/config/constants.ts index d3de926..0fa3b8c 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -1,4 +1,5 @@ import { Environment } from "@paypal/paypal-server-sdk"; +import { vatDecimal } from "../src/checkout/vat"; export const pdfApiUrl = process.env.PDF_API_ADDRESS!; export const mailApiUrl = process.env.MAIL_API_ADDRESS!; @@ -18,10 +19,7 @@ export const shopEmail = process.env.SHOP_EMAIL ?? process.env.ADMIN_EMAIL_ADDRE export const shopLogoUrl = process.env.SHOP_LOGO_URL ?? ""; export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? ""; -// TODO: Should be retrieved from DepotApi -export const vatIncludedDecimal = 1.19; -// TODO: Should be retrieved from DepotApi -export const vatDecimal = 0.19; +export { vatIncludedDecimal, vatDecimal } from "../src/checkout/vat"; export const vatDecimalExcluded = 1 - vatDecimal; export const maxProductsSitemap = 500; diff --git a/config/logger.ts b/config/logger.ts index a0d1663..ece6c76 100644 --- a/config/logger.ts +++ b/config/logger.ts @@ -1,16 +1,10 @@ import { winston } from "@strapi/logger"; +import { formatLogLine } from "../src/logging/format"; export default { transports: [ new winston.transports.Console({ - format: winston.format.combine( - winston.format.printf(({ timestamp, level, message, ...rest }) => { - let restString = JSON.stringify(rest, undefined, 2); - restString = restString === "{}" ? "" : restString; - - return `${timestamp}Z ${level}: ${message} ${restString}`; - }) - ) + format: winston.format.combine(winston.format.printf(formatLogLine)) }) ] }; diff --git a/package.json b/package.json index 8fffac7..ca30b40 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "strapi build --debug", "strapi": "strapi", "lint:fix": "prettier --write \"{src,types,config}/**/*.{js,jsx,ts,tsx}\"", + "test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'", "generate:types": "strapi ts:generate-types", "import": "npm run strapi import -- -f database/export.tar.gz --force", "export": "npm run strapi export -- --no-encrypt --file database/export_$(date +'%Y%m%d%H%M%S')", diff --git a/src/api/product/services/product.ts b/src/api/product/services/product.ts index 799b57f..5982994 100644 --- a/src/api/product/services/product.ts +++ b/src/api/product/services/product.ts @@ -3,7 +3,7 @@ */ import { factories } from "@strapi/strapi"; -import { Product } from "../../../../types"; +import { calculateTotalProductPrice } from "../../../checkout/pricing"; export default factories.createCoreService("api::product.product", ({ strapi }) => ({ find: async (params: Record) => { @@ -110,9 +110,8 @@ export default factories.createCoreService("api::product.product", ({ strapi }) } })); -export const calculateTotalProductPrice = (product: Partial): number => { - return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); -}; +// Implemented in src/checkout/pricing.ts; re-exported so existing importers keep working. +export { calculateTotalProductPrice }; export interface ProductParams { populate?: { diff --git a/src/checkout/paypal-order.ts b/src/checkout/paypal-order.ts new file mode 100644 index 0000000..7cf509c --- /dev/null +++ b/src/checkout/paypal-order.ts @@ -0,0 +1,124 @@ +import type { Order } from "../../types"; +import { calculateTotalProductPrice } from "./pricing"; +import { vatIncludedDecimal } from "./vat"; + +// The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested. +// String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1. + +export type PayPalMoney = { + currencyCode: string; + value: string; +}; + +export type PayPalOrderItem = { + name: string; + unitAmount: PayPalMoney; + tax: PayPalMoney; + quantity: string; +}; + +export type PayPalOrderRequest = { + /** CheckoutPaymentIntent.Capture */ + intent: "CAPTURE"; + purchaseUnits: { + referenceId: string; + amount: PayPalMoney & { + breakdown: { + itemTotal: PayPalMoney; + taxTotal: PayPalMoney; + shipping: PayPalMoney | undefined; + }; + }; + shipping: { + name: { fullName: string }; + address: { addressLine1: string; postalCode: string; adminArea2: string; countryCode: string }; + }; + customId: string; + invoiceId: string; + }[]; + applicationContext: { + returnUrl: string; + cancelUrl: string; + /** OrderApplicationContextShippingPreference.SetProvidedAddress */ + shippingPreference: "SET_PROVIDED_ADDRESS"; + }; +}; + +/** + * The part of PayPalApi.createSessionOrThrow that runs before its try block, so nothing thrown here is logged there. + * Rejects an order without a total, then computes one item per cart line. The items are not sent to PayPal, but computing + * them throws a TypeError for a cart line without a product. + */ +export const buildPayPalOrderItems = (order: Order): PayPalOrderItem[] => { + if (!order.total) throw new Error("Cart is empty or has no total"); + + return order.cart.map((item) => { + const totalProductPrice = calculateTotalProductPrice(item.product); + const amount = totalProductPrice / vatIncludedDecimal; + const tax = totalProductPrice - amount; + + return { + name: item.product.name, + unitAmount: { + currencyCode: "EUR", + value: amount.toFixed(2) + }, + tax: { + currencyCode: "EUR", + value: tax.toFixed(2) + }, + quantity: item.count.toString() + }; + }); +}; + +/** + * The order request body PayPalApi.createSessionOrThrow sends. It is built inside that try block, so whatever throws here is logged there. + */ +export const buildPayPalOrderRequest = (returnUrl: string, order: Order): PayPalOrderRequest => ({ + intent: "CAPTURE", + purchaseUnits: [ + { + // items: computed by buildPayPalOrderItems, but not sent + referenceId: order.uuid, + amount: { + currencyCode: "EUR", + value: order.total.toFixed(2), + breakdown: { + itemTotal: { + currencyCode: "EUR", + value: (Math.round((order.subtotal / vatIncludedDecimal) * 100) / 100).toFixed(2) + }, + taxTotal: { + currencyCode: "EUR", + value: order.VAT.toFixed(2) + }, + shipping: order.delivery + ? { + currencyCode: "EUR", + value: order.delivery.price.toFixed(2) + } + : undefined + } + }, + shipping: { + name: { + fullName: order.deliveryAddress.split("\n")[0] + }, + address: { + addressLine1: order.deliveryAddress.split("\n")[1], + postalCode: order.deliveryAddress.split("\n")[2].slice(0, 5), + adminArea2: order.deliveryAddress.split("\n")[2].slice(6), + countryCode: "DE" + } + }, + customId: order.uuid, + invoiceId: order.invoiceNumber + } + ], + applicationContext: { + returnUrl: returnUrl, + cancelUrl: returnUrl, + shippingPreference: "SET_PROVIDED_ADDRESS" + } +}); diff --git a/src/checkout/pricing.ts b/src/checkout/pricing.ts new file mode 100644 index 0000000..7e449db --- /dev/null +++ b/src/checkout/pricing.ts @@ -0,0 +1,5 @@ +import type { Product } from "../../types"; + +export const calculateTotalProductPrice = (product: Partial): number => { + return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); +}; diff --git a/src/checkout/vat.ts b/src/checkout/vat.ts new file mode 100644 index 0000000..462c1f5 --- /dev/null +++ b/src/checkout/vat.ts @@ -0,0 +1,4 @@ +// TODO: Should be retrieved from DepotApi +export const vatIncludedDecimal = 1.19; +// TODO: Should be retrieved from DepotApi +export const vatDecimal = 0.19; diff --git a/src/logging/format.ts b/src/logging/format.ts new file mode 100644 index 0000000..693645a --- /dev/null +++ b/src/logging/format.ts @@ -0,0 +1,14 @@ +/** What winston hands a printf template (logform's TransformableInfo); Strapi's default format has added `timestamp`. */ +export type LogInfo = { + level: string; + message: unknown; + [key: string | symbol]: unknown; +}; + +/** One console line: `Z : `. */ +export const formatLogLine = ({ timestamp, level, message, ...rest }: LogInfo): string => { + let restString = JSON.stringify(rest, undefined, 2); + restString = restString === "{}" ? "" : restString; + + return `${timestamp}Z ${level}: ${message} ${restString}`; +}; diff --git a/src/services/PayPalApi.ts b/src/services/PayPalApi.ts index c121839..304b392 100644 --- a/src/services/PayPalApi.ts +++ b/src/services/PayPalApi.ts @@ -1,16 +1,7 @@ -import { - Client, - Environment, - LogLevel, - OrdersController, - CheckoutPaymentIntent, - OrderRequest, - OrderApplicationContextShippingPreference, - Item -} from "@paypal/paypal-server-sdk"; -import { paypalClientId, paypalClientSecret, paypalEnvironment, vatIncludedDecimal, vatDecimalExcluded } from "../../config/constants"; +import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk"; +import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; import { Order } from "../../types"; -import { calculateTotalProductPrice } from "../api/product/services/product"; +import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order"; class PayPalApi { private readonly client: Client; @@ -34,76 +25,12 @@ class PayPalApi { } async createSessionOrThrow(returnUrl: string, order: Order) { - if (!order.total) throw new Error("Cart is empty or has no total"); - - const items = order.cart.map((item) => { - const totalProductPrice = calculateTotalProductPrice(item.product); - const amount = totalProductPrice / vatIncludedDecimal; - const tax = totalProductPrice - amount; - - return { - name: item.product.name, - unitAmount: { - currencyCode: "EUR", - value: amount.toFixed(2) - }, - tax: { - currencyCode: "EUR", - value: tax.toFixed(2) - }, - quantity: item.count.toString() - } as Item; - }); + // Outside the try block, so these throws are not logged here. The items are computed but not sent. + buildPayPalOrderItems(order); try { const collect = { - body: { - intent: CheckoutPaymentIntent.Capture, - purchaseUnits: [ - { - // items, - referenceId: order.uuid, - amount: { - currencyCode: "EUR", - value: order.total.toFixed(2), - breakdown: { - itemTotal: { - currencyCode: "EUR", - value: (Math.round((order.subtotal / vatIncludedDecimal) * 100) / 100).toFixed(2) - }, - taxTotal: { - currencyCode: "EUR", - value: order.VAT.toFixed(2) - }, - shipping: order.delivery - ? { - currencyCode: "EUR", - value: order.delivery.price.toFixed(2) - } - : undefined - } - }, - shipping: { - name: { - fullName: order.deliveryAddress.split("\n")[0] - }, - address: { - addressLine1: order.deliveryAddress.split("\n")[1], - postalCode: order.deliveryAddress.split("\n")[2].slice(0, 5), - adminArea2: order.deliveryAddress.split("\n")[2].slice(6), - countryCode: "DE" - } - }, - customId: order.uuid, - invoiceId: order.invoiceNumber - } - ], - applicationContext: { - returnUrl: returnUrl, - cancelUrl: returnUrl, - shippingPreference: OrderApplicationContextShippingPreference.SetProvidedAddress - } - } as OrderRequest, + body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest, prefer: "return=minimal" }; diff --git a/tests/unit/log-format.test.ts b/tests/unit/log-format.test.ts new file mode 100644 index 0000000..5265264 --- /dev/null +++ b/tests/unit/log-format.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatLogLine } from "../../src/logging/format.ts"; + +const TIMESTAMP = "2026-10-08 21:29:40.426"; +const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET" + +// Characterization: what the console line looks like today. + +test("renders timestamp, level and message, with a trailing space when there are no other fields", () => { + assert.equal( + formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "app:i:paypal-api: hello" }), + "2026-10-08 21:29:40.426Z info: app:i:paypal-api: hello " + ); +}); + +test("appends the other fields as JSON indented by two spaces", () => { + assert.equal( + formatLogLine({ + timestamp: TIMESTAMP, + level: "debug", + message: "app:d:order-service: Calculating totals", + productsTotal: 17.9, + deliveryPrice: 4.95 + }), + '2026-10-08 21:29:40.426Z debug: app:d:order-service: Calculating totals {\n "productsTotal": 17.9,\n "deliveryPrice": 4.95\n}' + ); +}); + +test("ignores the symbol keys winston adds", () => { + const info = { timestamp: TIMESTAMP, level: "info", message: "hello", [Symbol.for("level")]: "info", [Symbol.for("splat")]: [] }; + + assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello "); +}); + +// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected. + +test( + "does not throw on a circular reference", + { todo: "JSON.stringify throws on a circular reference — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + const request: Record = { method: "POST", path: "/v1/oauth2/token" }; + request.self = request; + + assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request })); + } +); + +test( + "does not write the Authorization header of an axios-like error into the line", + { todo: "the PayPal client credentials are logged — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), { + name: "AxiosError", + code: "EAI_AGAIN", + config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } } + }); + // What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack. + const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP }); + + assert.ok(!line.includes(CREDENTIALS), line); + } +); + +test( + "renders an Error with its message instead of {}", + { todo: "an Error is rendered as {} — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"); + // strapi.log.error("…", { error }), as in the order controller's capturePayment + const line = formatLogLine({ + timestamp: TIMESTAMP, + level: "error", + message: "app:e:order-controller: Error capturing payment for order x", + error + }); + + assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line); + } +); diff --git a/tests/unit/paypal-order.test.ts b/tests/unit/paypal-order.test.ts new file mode 100644 index 0000000..104baea --- /dev/null +++ b/tests/unit/paypal-order.test.ts @@ -0,0 +1,234 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts"; +import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts"; +import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts"; + +const RETURN_URL = "https://shop.example/checkout/3"; +const UUID = "11111111-2222-4333-8444-555555555555"; + +const notebook = { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } }; + +// Shaped like the order the checkout controller passes to createSessionOrThrow (strapi.db.query findOne with orderDefaultParams), +// with the totals the order service computes: subtotal 17.90, total 17.90 + 4.95 = 22.85, VAT round(17.90 / 1.19 * 0.19) = 2.86. +const validOrder = (overrides = {}) => ({ + id: 4711, + uuid: UUID, + email: "erika@example.org", + subtotal: 17.9, + total: 22.85, + VAT: 2.86, + delivery: { id: 1, name: "DHL", price: 4.95 }, + payment: null, + invoiceNumber: "R-12-4711", + cart: [{ id: 1, count: 1, product: notebook }], + deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart", + ...overrides +}); + +// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173). +const orderFor = (lines: [number, number][], deliveryPrice: number | null) => { + const cart = lines.map(([price, count], index) => ({ + id: index + 1, + count, + product: { id: 201 + index, name: `Produkt ${index + 1}`, cover: { price } } + })); + const subtotal = cart.reduce((v, p) => v + (calculateTotalProductPrice(p.product) ?? 0) * p.count, 0); + return validOrder({ + cart, + subtotal, + total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100, + VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100, + delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice } + }); +}; + +// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw. +const buildSession = (order) => { + buildPayPalOrderItems(order); + return buildPayPalOrderRequest(RETURN_URL, order); +}; + +const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0); + +// PayPal rejects an order unless amount = item_total + tax_total + shipping + handling + insurance - shipping_discount - discount. +const breakdownCents = (breakdown) => + cents(breakdown.itemTotal) + + cents(breakdown.taxTotal) + + cents(breakdown.shipping) + + cents(breakdown.handling) + + cents(breakdown.insurance) - + cents(breakdown.shippingDiscount) - + cents(breakdown.discount); + +const isClearError = (pattern: RegExp) => (error: unknown) => error instanceof Error && !(error instanceof TypeError) && pattern.test(error.message); + +const assertNoTypeError = (build: () => unknown) => { + try { + build(); + } catch (error) { + assert.ok(!(error instanceof TypeError), `threw ${error}`); + } +}; + +// Characterization: what the builders do today. + +test("builds the PayPal order request for a valid order", () => { + const expected = { + intent: "CAPTURE", + purchaseUnits: [ + { + referenceId: UUID, + amount: { + currencyCode: "EUR", + value: "22.85", + breakdown: { + itemTotal: { currencyCode: "EUR", value: "15.04" }, + taxTotal: { currencyCode: "EUR", value: "2.86" }, + shipping: { currencyCode: "EUR", value: "4.95" } + } + }, + shipping: { + name: { fullName: "Erika Mustermann" }, + address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "DE" } + }, + customId: UUID, + invoiceId: "R-12-4711" + } + ], + applicationContext: { returnUrl: RETURN_URL, cancelUrl: RETURN_URL, shippingPreference: "SET_PROVIDED_ADDRESS" } + }; + const request = buildPayPalOrderRequest(RETURN_URL, validOrder()); + + assert.deepEqual(request, expected); + // The key order is visible in the "Creating PayPal order" log line. + assert.equal(JSON.stringify(request), JSON.stringify(expected)); +}); + +test("sends no shipping amount when the order has no delivery method", () => { + assert.deepEqual(buildPayPalOrderRequest(RETURN_URL, validOrder({ delivery: null, total: 17.9 })).purchaseUnits[0].amount, { + currencyCode: "EUR", + value: "17.90", + breakdown: { + itemTotal: { currencyCode: "EUR", value: "15.04" }, + taxTotal: { currencyCode: "EUR", value: "2.86" }, + shipping: undefined + } + }); +}); + +test("rejects an order without a total before computing the items", () => { + for (const total of [0, null, undefined]) { + assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" }); + } +}); + +test("computes one item per cart line, which is not sent to PayPal", () => { + const order = validOrder({ + cart: [ + { id: 1, count: 1, product: notebook }, + { id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } } + ] + }); + + assert.deepEqual(buildPayPalOrderItems(order), [ + { + name: "Notizbuch A5 Punktraster", + unitAmount: { currencyCode: "EUR", value: "15.04" }, + tax: { currencyCode: "EUR", value: "2.86" }, + quantity: "1" + }, + { + name: "Journaling Booklet", + unitAmount: { currencyCode: "EUR", value: "10.50" }, + tax: { currencyCode: "EUR", value: "2.00" }, + quantity: "3" + } + ]); +}); + +// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected. + +test( + "D1: a missing delivery address is rejected with a clear validation error", + { todo: "null deliveryAddress throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i)); + } +); + +test( + "D2: a two-line delivery address does not throw a TypeError", + { todo: "a two-line address throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" }))); + } +); + +test( + "D3: a pickup whose delivery price is null is sent with shipping 0.00", + { todo: "delivery price null throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 }); + + assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" }); + } +); + +test( + "D4: a cart line whose product was deleted is rejected with a clear error", + { todo: "a deleted product throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i)); + } +); + +test( + "D5: with a payment surcharge the breakdown still adds up to the amount", + { todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" }, + () => { + // total = 17.90 + 4.95 shipping + 1.50 surcharge + const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0]; + + assert.equal(amount.value, "24.35"); + assert.equal(breakdownCents(amount.breakdown), cents(amount)); + } +); + +// Not a todo: this holds today for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above. +test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => { + const carts: { lines: [number, number][]; delivery: number | null }[] = [ + { lines: [[17.9, 1]], delivery: 4.95 }, + // subtotal 36.10000000000001 in floating point + { + lines: [ + [4.15, 7], + [2.35, 3] + ], + delivery: 4.95 + }, + // 29.03: net 24.394958, as close to a half cent as a whole-cent subtotal gets + { + lines: [ + [9.99, 2], + [9.05, 1] + ], + delivery: 4.95 + }, + // 41.18: net 34.605042, the same from above; no delivery method + { lines: [[20.59, 2]], delivery: null }, + { lines: [[5.23, 1]], delivery: 4.95 }, + { lines: [[5.48, 1]], delivery: 4.95 } + ]; + for (let cent = 1; cent <= 30000; cent++) { + carts.push({ lines: [[cent / 100, 1]], delivery: 4.95 }, { lines: [[cent / 100, 1]], delivery: null }); + } + + const mismatches = carts.filter(({ lines, delivery }) => { + const { amount } = buildSession(orderFor(lines, delivery)).purchaseUnits[0]; + return breakdownCents(amount.breakdown) !== cents(amount); + }); + + // Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory. + assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`); +}); diff --git a/tests/unit/pricing.test.ts b/tests/unit/pricing.test.ts new file mode 100644 index 0000000..557200b --- /dev/null +++ b/tests/unit/pricing.test.ts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts"; + +// Characterization: what calculateTotalProductPrice returns today. + +test("adds up the cover, pages and ruling prices", () => { + assert.equal(calculateTotalProductPrice({ cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0.5 } }), 18.4); +}); + +test("prices a standalone product from its cover alone", () => { + assert.equal(calculateTotalProductPrice({ cover: { price: 9.5 } }), 9.5); +}); + +test("counts a missing component or price as 0", () => { + assert.equal(calculateTotalProductPrice({ cover: null, pages: { price: null }, ruling: undefined }), 0); +}); + +test("returns 0 for a missing product instead of throwing", () => { + assert.equal(calculateTotalProductPrice(null), 0); + assert.equal(calculateTotalProductPrice(undefined), 0); +}); + +test("does not round the sum", () => { + assert.equal(calculateTotalProductPrice({ cover: { price: 0.1 }, pages: { price: 0.2 } }), 0.30000000000000004); +}); diff --git a/tests/unit/support/register.mjs b/tests/unit/support/register.mjs new file mode 100644 index 0000000..b221a70 --- /dev/null +++ b/tests/unit/support/register.mjs @@ -0,0 +1,6 @@ +// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike tsc it does not +// resolve extensionless relative imports such as `import { vatIncludedDecimal } from "./vat"` in src/checkout/paypal-order.ts. +// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths. +import { register } from "node:module"; + +register("./resolve-ts.mjs", import.meta.url); diff --git a/tests/unit/support/resolve-ts.mjs b/tests/unit/support/resolve-ts.mjs new file mode 100644 index 0000000..ede799e --- /dev/null +++ b/tests/unit/support/resolve-ts.mjs @@ -0,0 +1,14 @@ +// Module resolve hook, registered by ./register.mjs: `./vat` imported from a .ts file resolves to `./vat.ts`. +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (error) { + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error; + try { + return await nextResolve(`${specifier}.ts`, context); + } catch { + throw error; + } + } +}