Compare commits

11 Commits
Author SHA1 Message Date
libretech 707ca854fb ci: run the unit tests, and build images only for pull requests and releases
build / test (pull_request) Failing after 15s
build / build (pull_request) Skipped
Every push to main published :main and :sha-* images, which nothing pulls.
The libreshop images had grown to 5.8 GB in the registry, on a Gitea host that
is nearly out of space.

- test: npm test on Node 22.14.0, the Node of the image, for every pull
  request, every push to main and every release tag.
- build: after the tests, for a pull request (build only) and for a v* tag
  (build and, with PUBLISH_ENABLED, publish :vX.Y.Z and :latest).

Refs libretech/mp#71
2026-10-08 22:26:46 +02:00
libretech cbcc54bd75 test(checkout): add dependency-free unit tests that pin the checkout defects
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
2026-10-08 22:15:57 +02:00
libretech 7fd4aeff17 refactor(logging): move the console line format into src/logging/format.ts
The printf template of config/logger.ts becomes formatLogLine, with the same
logic, so it can be unit tested without winston.

No behaviour change.

Refs libretech/mp#71
2026-10-08 22:10:28 +02:00
libretech 9873c184d6 refactor(checkout): move VAT rates, pricing and the PayPal order into pure modules
src/checkout/ holds code without the PayPal SDK or Strapi at runtime, so it can be
unit tested without node_modules:

- vat.ts: vatIncludedDecimal and vatDecimal. config/constants.ts re-exports them
  under the same names and keeps vatDecimalExcluded.
- pricing.ts: calculateTotalProductPrice, unchanged. The product service
  re-exports it, so every importer keeps working.
- paypal-order.ts: the order request createSessionOrThrow builds, in two halves.
  buildPayPalOrderItems (the total guard and the unsent items) still runs
  before the try block, and buildPayPalOrderRequest inside it. Errors and log
  lines stay as they were, for every input. The SDK enums are replaced by their
  string values (CAPTURE, SET_PROVIDED_ADDRESS).

No behaviour change.

Refs libretech/mp#71
2026-10-08 22:10:28 +02:00
libretech 901a3b385f feat(product): price standalone products from the cover alone
build / build (push) Successful in 8m52s
Recovered work in progress from 2026-05. A product-cover can be marked
standalone, and a standalone product (Journaling Booklet) prices as the
cover price without the pattern/pages/ruling lookups, which do not exist
for it. The remaining component lookups are now guarded so a missing
relation yields null instead of calling findOne with undefined.
2026-09-23 22:10:06 +02:00
libretech 7f7cacc448 ci: bump runner-image pin v0.1.0 → v0.2.0 (#4)
build / build (push) Successful in 5m57s
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-05-04 23:46:06 +02:00
libretech fa2a81ecd1 fix(ci): correct image digest separator
build / build (push) Successful in 7m47s
2026-04-30 12:20:33 +02:00
libretech 8e0554572b ci: digest-pin runner-image v0.1.0
build / build (push) Failing after 0s
2026-04-30 12:17:55 +02:00
libretech 3213a5a400 ci: update runner-image path to public/ namespace
build / build (push) Failing after 1s
2026-04-30 11:57:23 +02:00
libretech 5c36ce7dbb chore: update action ref to public/actions
build / build (push) Successful in 6m4s
2026-04-29 22:50:14 +02:00
libretech 9600bc8fd3 chore: slim build.yml to use shared docker-build action (#3)
build / build (push) Successful in 6m43s
2026-04-29 22:08:31 +02:00
20 changed files with 603 additions and 139 deletions
+28 -38
View File
@@ -1,4 +1,4 @@
name: Build and publish name: build
on: on:
push: push:
@@ -7,49 +7,39 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
# Required secrets:
# REGISTRY git.librete.ch
# REGISTRY_USER libretech-bot
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
# Required variable:
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
#
# Image: git.librete.ch/libreshop/cms
# main pushes → :main + :sha-<short>
# tag pushes → :<tag> + :latest
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: git.librete.ch/libretech/runner-image:v1 image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# The Node version of the image (Dockerfile FROM), so the tests run on the runtime's Node.
- uses: actions/setup-node@v4
with:
node-version: "22.14.0"
- run: npm test
env:
npm_config_update_notifier: "false"
# 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
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 - uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
- name: Login (only when publishing)
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
uses: docker/login-action@v3
with: with:
registry: ${{ secrets.REGISTRY }} registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }} registry_user: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }} registry_pass: ${{ secrets.REGISTRY_PASS }}
publish: ${{ startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_ENABLED == 'true' }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/libreshop/cms
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -4
View File
@@ -1,4 +1,5 @@
import { Environment } from "@paypal/paypal-server-sdk"; import { Environment } from "@paypal/paypal-server-sdk";
import { vatDecimal } from "../src/checkout/vat";
export const pdfApiUrl = process.env.PDF_API_ADDRESS!; export const pdfApiUrl = process.env.PDF_API_ADDRESS!;
export const mailApiUrl = process.env.MAIL_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 shopLogoUrl = process.env.SHOP_LOGO_URL ?? "";
export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? ""; export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? "";
// TODO: Should be retrieved from DepotApi export { vatIncludedDecimal, vatDecimal } from "../src/checkout/vat";
export const vatIncludedDecimal = 1.19;
// TODO: Should be retrieved from DepotApi
export const vatDecimal = 0.19;
export const vatDecimalExcluded = 1 - vatDecimal; export const vatDecimalExcluded = 1 - vatDecimal;
export const maxProductsSitemap = 500; export const maxProductsSitemap = 500;
+2 -8
View File
@@ -1,16 +1,10 @@
import { winston } from "@strapi/logger"; import { winston } from "@strapi/logger";
import { formatLogLine } from "../src/logging/format";
export default { export default {
transports: [ transports: [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(winston.format.printf(formatLogLine))
winston.format.printf(({ timestamp, level, message, ...rest }) => {
let restString = JSON.stringify(rest, undefined, 2);
restString = restString === "{}" ? "" : restString;
return `${timestamp}Z ${level}: ${message} ${restString}`;
})
)
}) })
] ]
}; };
+1
View File
@@ -7,6 +7,7 @@
"build": "strapi build --debug", "build": "strapi build --debug",
"strapi": "strapi", "strapi": "strapi",
"lint:fix": "prettier --write \"{src,types,config}/**/*.{js,jsx,ts,tsx}\"", "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", "generate:types": "strapi ts:generate-types",
"import": "npm run strapi import -- -f database/export.tar.gz --force", "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')", "export": "npm run strapi export -- --no-encrypt --file database/export_$(date +'%Y%m%d%H%M%S')",
@@ -54,6 +54,10 @@
"price": { "price": {
"type": "decimal" "type": "decimal"
}, },
"standalone": {
"type": "boolean",
"default": false
},
"products": { "products": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
@@ -11,10 +11,26 @@ export default {
strapi.log.verbose("app:v:product-lifecycle: Before create", { strapi.log.verbose("app:v:product-lifecycle: Before create", {
params params
}); });
const cover = await strapi.entityService.findOne("api::product-cover.product-cover", params.data?.cover, { fields: ["price"] }); const cover = params.data?.cover
const pattern = await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data?.pattern); ? await strapi.entityService.findOne("api::product-cover.product-cover", params.data.cover, { fields: ["price", "standalone"] })
const pages = await strapi.entityService.findOne("api::product-page.product-page", params.data?.pages, { fields: ["price"] }); : null;
const ruling = await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data?.ruling, { fields: ["price"] });
// Standalone products (e.g. Journaling Booklet) have no pattern/pages/ruling.
// Price is the cover price alone — skip combinatorial component lookups.
if (params.data?.standalone || (cover as any)?.standalone) {
params.data.totalPrice = cover?.price ?? 0;
return;
}
const pattern = params.data?.pattern
? await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data.pattern)
: null;
const pages = params.data?.pages
? await strapi.entityService.findOne("api::product-page.product-page", params.data.pages, { fields: ["price"] })
: null;
const ruling = params.data?.ruling
? await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data.ruling, { fields: ["price"] })
: null;
const product = { const product = {
...params.data, ...params.data,
cover, cover,
+18 -1
View File
@@ -108,6 +108,12 @@ export default factories.createCoreController("api::product.product", ({ strapi
strapi.log.verbose(`Fetching variants for product ${id}`); strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern — no variants.
if (!product.pattern?.id) {
return [];
}
const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -146,6 +152,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
const { pattern, cover, ruling, pages } = product; const { pattern, cover, ruling, pages } = product;
// Standalone products (e.g. Journaling Booklet) have no pattern → no variants.
if (!pattern?.id) {
return { allProductPattern: [], productVariants: [], patterns: [] };
}
const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -203,6 +214,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern → no variant groups.
if (!product.pattern?.id) {
return { pages: [], cover: [], ruling: [] };
}
const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>( const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>(
"api::product.product", "api::product.product",
{ {
@@ -335,7 +351,8 @@ export default factories.createCoreController("api::product.product", ({ strapi
const productGroups = new Map(); const productGroups = new Map();
allProductsSanitized.forEach((product) => { allProductsSanitized.forEach((product) => {
const key = `${product.cover.id}-${product.pattern.id}`; // Standalone products (e.g. Journaling Booklet) have no pattern — never grouped.
const key = product.pattern?.id ? `${product.cover?.id}-${product.pattern.id}` : `standalone-${product.id}`;
if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) { if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) {
productGroups.set(key, product); productGroups.set(key, product);
} }
+3 -4
View File
@@ -3,7 +3,7 @@
*/ */
import { factories } from "@strapi/strapi"; import { factories } from "@strapi/strapi";
import { Product } from "../../../../types"; import { calculateTotalProductPrice } from "../../../checkout/pricing";
export default factories.createCoreService("api::product.product", ({ strapi }) => ({ export default factories.createCoreService("api::product.product", ({ strapi }) => ({
find: async (params: Record<string, any>) => { find: async (params: Record<string, any>) => {
@@ -110,9 +110,8 @@ export default factories.createCoreService("api::product.product", ({ strapi })
} }
})); }));
export const calculateTotalProductPrice = (product: Partial<Product>): number => { // Implemented in src/checkout/pricing.ts; re-exported so existing importers keep working.
return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); export { calculateTotalProductPrice };
};
export interface ProductParams { export interface ProductParams {
populate?: { populate?: {
+124
View File
@@ -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"
}
});
+5
View File
@@ -0,0 +1,5 @@
import type { Product } from "../../types";
export const calculateTotalProductPrice = (product: Partial<Product>): number => {
return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0);
};
+4
View File
@@ -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;
+14
View File
@@ -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: `<timestamp>Z <level>: <message> <all other fields as indented JSON, if any>`. */
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}`;
};
+6 -79
View File
@@ -1,16 +1,7 @@
import { import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk";
Client, import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
Environment,
LogLevel,
OrdersController,
CheckoutPaymentIntent,
OrderRequest,
OrderApplicationContextShippingPreference,
Item
} from "@paypal/paypal-server-sdk";
import { paypalClientId, paypalClientSecret, paypalEnvironment, vatIncludedDecimal, vatDecimalExcluded } from "../../config/constants";
import { Order } from "../../types"; import { Order } from "../../types";
import { calculateTotalProductPrice } from "../api/product/services/product"; import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order";
class PayPalApi { class PayPalApi {
private readonly client: Client; private readonly client: Client;
@@ -34,76 +25,12 @@ class PayPalApi {
} }
async createSessionOrThrow(returnUrl: string, order: Order) { async createSessionOrThrow(returnUrl: string, order: Order) {
if (!order.total) throw new Error("Cart is empty or has no total"); // Outside the try block, so these throws are not logged here. The items are computed but not sent.
buildPayPalOrderItems(order);
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;
});
try { try {
const collect = { const collect = {
body: { body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest,
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,
prefer: "return=minimal" prefer: "return=minimal"
}; };
+11 -1
View File
@@ -110,8 +110,12 @@ async function generateNewProducts(strapi: Strapi, componentType: string, compon
strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`); strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`);
// Standalone covers (e.g. the Journaling Booklet) are not combinatorial —
// their product is created manually and must never be fanned out.
const sellableCovers = covers.filter((cover) => cover && !(cover as ProductCover).standalone);
// Generate new combinations // Generate new combinations
for (const cover of covers) { for (const cover of sellableCovers) {
for (const pattern of patterns) { for (const pattern of patterns) {
for (const page of pages) { for (const page of pages) {
for (const ruling of rulings) { for (const ruling of rulings) {
@@ -181,6 +185,12 @@ async function updateRelatedProducts(strapi: Strapi, componentType: string, comp
for (const product of products) { for (const product of products) {
try { try {
// Standalone products (cover-only, e.g. Journaling Booklet) are not
// factory-managed — never rename/reslug/delete them.
if (!product.pattern?.id) {
continue;
}
// Get the current components for this product // Get the current components for this product
const cover = product.cover?.id; const cover = product.cover?.id;
const pattern = product.pattern?.id; const pattern = product.pattern?.id;
+80
View File
@@ -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<string, unknown> = { 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);
}
);
+234
View File
@@ -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))}`);
});
+26
View File
@@ -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);
});
+6
View File
@@ -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);
+14
View File
@@ -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;
}
}
}
+1
View File
@@ -453,6 +453,7 @@ export interface ApiProductCoverProductCover extends Schema.CollectionType {
number number
> & > &
Attribute.DefaultTo<0>; Attribute.DefaultTo<0>;
standalone: Attribute.Boolean & Attribute.DefaultTo<false>;
updatedAt: Attribute.DateTime; updatedAt: Attribute.DateTime;
updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private; updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private;
}; };