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
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user