npm test runs node:test on the TypeScript sources with Node's type stripping (Node 22.14 in the image, Node 24 locally), with no dependencies. Node, unlike tsc, does not resolve extensionless relative imports. TypeScript 5.2 cannot emit .ts import extensions. tests/unit/support therefore registers a small resolve hook that appends ".ts" for imports from .ts files. - pricing, paypal-order, log-format: characterization tests of the current output. - The known defects of libretech/mp#67 are tests of the correct behaviour, marked todo: null, two-line and price-null addresses and deliveries, a deleted product, the payment surcharge missing from the breakdown, and in the log line circular JSON, the Basic credentials of an axios error, and an Error rendered as {}. - That the breakdown adds up to the amount holds today, including for the subtotals closest to a half cent, so that test is a regular one. Refs libretech/mp#71
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
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))}`);
|
|
});
|