Files
shop/tests/unit/cmsError.test.ts
libretech 5c773a99e6 feat(orders): pass the CMS's status and safe error fields to the browser
The order routes (get, put, add-product, remove-product, checkout, capture)
call forwardToCms, which throws a CMS error on as
createError({ statusCode, statusMessage, data: { message, errors?, missing? } }),
built by the pure shopErrorFromCms (server/utils/cmsError.ts).

Before, the FetchError was thrown on as it was: the browser got the CMS's
status, but Nitro treated it as unhandled, answered "Server Error" without
data and logged every CMS 4xx as [unhandled]. Now the browser also gets the
CMS's message, the errors of a rejected update and the fields a checkout
misses, and no other field. A status that is the shop's own fault (401, 403,
...) is answered 500, a CMS that does not answer 503; 5xx are logged without
the query and the order uuid.

npm test runs tests/unit with Node's type stripping and no dependencies, as
in libreshop/cms. nuxt.config keeps tests/ out of the app's type check.

Refs libretech/mp#71
2026-10-09 02:32:14 +02:00

202 lines
8.5 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { cmsErrorLogLine, shopErrorFromCms } from "../../server/utils/cmsError.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
const CART = `http://cms:5555/api/orders/${UUID}/cart`;
const CHECKOUT = `http://cms:5555/api/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example%2Fcheckout%2F3`;
const CAPTURE = `http://cms:5555/api/orders/${UUID}/capture/5O190127TN364715T`;
// Built like ofetch's createFetchError (ofetch 1.5): an Error named FetchError, with getters for the response's status and parsed body.
const fetchError = (method: string, url: string, response?: { status: number; statusText: string; body: unknown }): Error => {
const status = response ? `${response.status} ${response.statusText}` : "<no response> fetch failed";
const error = new Error(`[${method}] ${JSON.stringify(url)}: ${status}`);
error.name = "FetchError";
const fields: [string, unknown][] = [
["data", response?.body],
["status", response?.status],
["statusCode", response?.status],
["statusText", response?.statusText],
["statusMessage", response?.statusText]
];
for (const [key, value] of fields) Object.defineProperty(error, key, { get: () => value });
return error;
};
// Strapi's error body, as ctx.badRequest, ctx.notFound, ctx.conflict and ctx.badGateway write it.
const strapiError = (status: number, name: string, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name, message, details }
});
const cmsAnswer = (method: string, url: string, status: number, statusText: string, body: unknown) =>
shopErrorFromCms(fetchError(method, url, { status, statusText, body }));
test("passes a rejected order update on as 400 with the CMS's errors", () => {
const errors = ["data.email: must be an email address of at most 254 characters", "data.total: not a field the customer may set"];
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", strapiError(400, "BadRequestError", "Invalid order update", { errors })), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors }
});
});
test("passes a checkout that is not ready on as 400 with the fields it misses", () => {
const body = strapiError(400, "BadRequestError", "Order is not ready for checkout", { missing: ["email", "delivery"] });
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout", missing: ["email", "delivery"] }
});
});
test("passes on no detail but errors and missing", () => {
const details = { reason: "deliveryAddress has no postal code", order: { email: "erika@example.org" }, errors: "data.email" };
const body = { ...strapiError(400, "BadRequestError", "Order is not ready for checkout", details), meta: { email: "erika@example.org" } };
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout" }
});
});
test("passes the conflicts of a paid order, an unavailable product and a payment that does not fit the order on as 409", () => {
for (const [url, message] of [
[CART, "Order can no longer be changed"],
[CHECKOUT, "A product in the cart is no longer available"],
[CAPTURE, "Order is already paid"],
[CAPTURE, "Payment does not belong to this order"],
[CAPTURE, "Payment does not match this order"],
[CAPTURE, "Order changed during the payment"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 409, "Conflict", strapiError(409, "ConflictError", message)),
{ statusCode: 409, statusMessage: message, data: { message } },
message
);
}
});
test("passes a missing order on as 404", () => {
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "Order not found")), {
statusCode: 404,
statusMessage: "Order not found",
data: { message: "Order not found" }
});
});
test("passes PayPal's errors on as 502", () => {
for (const [url, message] of [
[CHECKOUT, "Could not create the PayPal order"],
[CAPTURE, "PayPal could not capture the payment"],
[CAPTURE, "Payment could not be confirmed"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 502, "Bad Gateway", strapiError(502, "BadGatewayError", message)),
{ statusCode: 502, statusMessage: message, data: { message } },
message
);
}
});
test("passes a payment the CMS could not record on as 500 with its message", () => {
const body = strapiError(500, "InternalServerError", "Payment could not be recorded");
assert.deepEqual(cmsAnswer("POST", CAPTURE, 500, "Internal Server Error", body), {
statusCode: 500,
statusMessage: "Payment could not be recorded",
data: { message: "Payment could not be recorded" }
});
});
test("answers 500 without the CMS's message for a status that is the shop's own fault", () => {
for (const [status, statusText] of [
[401, "Unauthorized"],
[403, "Forbidden"],
[405, "Method Not Allowed"],
[413, "Payload Too Large"]
] as const) {
assert.deepEqual(
cmsAnswer("PUT", CART, status, statusText, strapiError(status, "Error", "Missing or invalid credentials")),
{ statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } },
String(status)
);
}
});
test("answers 503 when the CMS does not answer", () => {
assert.deepEqual(shopErrorFromCms(fetchError("GET", CART)), {
statusCode: 503,
statusMessage: "Service Unavailable",
data: { message: "The CMS did not answer" }
});
});
test("leaves any other error to be rethrown as it is", () => {
assert.equal(shopErrorFromCms(new TypeError("Cannot read properties of undefined (reading 'uuid')")), undefined);
assert.equal(shopErrorFromCms({ name: "FetchError", status: 409, data: strapiError(409, "ConflictError", "Order is already paid") }), undefined);
assert.equal(shopErrorFromCms("FetchError"), undefined);
assert.equal(shopErrorFromCms(undefined), undefined);
});
test("keeps only the strings of the CMS's lists, at most 20, and cuts a long one", () => {
// Strapi's own validation errors are objects with the path and the message: they are dropped.
const objects = strapiError(400, "ValidationError", "Invalid order update", {
errors: [{ path: ["email"], message: "email must be a valid email" }]
});
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", objects)?.data, { message: "Invalid order update" });
const many = Array.from({ length: 25 }, (_, index) => `data.field${index}: not a field the customer may set`);
const long = `data.${"x".repeat(300)}: not a field the customer may set`;
const body = strapiError(400, "BadRequestError", "Invalid order update", { errors: [long, 7, ...many] });
const data = cmsAnswer("PUT", CART, 400, "Bad Request", body)?.data;
assert.equal(data?.errors?.length, 20);
assert.equal(data?.errors?.[0], `${long.slice(0, 200)}…`);
assert.equal(data?.errors?.[1], many[0]);
});
test("answers with the status's reason phrase when the body is no CMS error", () => {
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 502, "Bad Gateway", "<html><body>502 Bad Gateway</body></html>"), {
statusCode: 502,
statusMessage: "Bad Gateway",
data: { message: "Bad Gateway" }
});
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "")), {
statusCode: 404,
statusMessage: "Not Found",
data: { message: "Not Found" }
});
});
test("keeps a message the status line cannot carry in the data only", () => {
const long = `Order ${"x".repeat(250)}`;
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", "Bestellung gesperrt…")), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: "Bestellung gesperrt…" }
});
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", long)), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: `${long.slice(0, 200)}…` }
});
});
test("logs a failed request without its query and without the order's uuid", () => {
const error = { statusCode: 502, statusMessage: "Bad Gateway", data: { message: "Could not create the PayPal order" } };
assert.equal(
cmsErrorLogLine("post", `/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example`, error),
"[cms] POST /orders/:uuid/checkout: 502 Could not create the PayPal order"
);
assert.equal(
cmsErrorLogLine("GET", `/orders/${UUID.toUpperCase()}/cart`, error),
"[cms] GET /orders/:uuid/cart: 502 Could not create the PayPal order"
);
});