v0.1.2: checkout fixes for CMS v0.2.0 (#18)
build / test (push) Successful in 10s
build / build (push) Successful in 1m5s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #18.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:27 +02:00
1 parent 60b8f1ab58
commit fccff147ba
29 files changed
+1108 -71

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAlreadyCaptured } from "../../utils/captureResponse.ts";
test("recognises the CMS's answer to a repeated capture", () => {
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: true }), true);
});
test("takes the paid order of a first capture for no repeated capture", () => {
const order = { uuid: "11111111-2222-4333-8444-555555555555", total: 22.85, paymentAuthorised: true, email: "erika@example.org" };
assert.equal(isAlreadyCaptured(order), false);
assert.equal(isAlreadyCaptured({ ...order, alreadyCaptured: false }), false);
});
test("takes nothing but true for both fields as a repeated capture", () => {
assert.equal(isAlreadyCaptured({ alreadyCaptured: true }), false);
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: "true" }), false);
assert.equal(isAlreadyCaptured({ success: false, alreadyCaptured: true }), false);
for (const response of [undefined, null, "alreadyCaptured", 1, [true]]) assert.equal(isAlreadyCaptured(response), false);
});
+185
View File
@@ -0,0 +1,185 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CHECKOUT_ERROR_MESSAGES as M, checkoutErrorMessage, checkoutErrorMessageFor } from "../../utils/checkoutError.ts";
// The shop's answer for a CMS error (server/utils/cmsError.ts), as Nitro writes it for a handled error.
const shopError = (statusCode: number, data: { message: string; errors?: string[]; missing?: string[] }) => ({
error: true,
url: "/api/orders/11111111-2222-4333-8444-555555555555",
statusCode,
statusMessage: data.message,
message: data.message,
data
});
// The CMS's own answer, Strapi's error body.
const strapiError = (status: number, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name: "Error", message, details }
});
// Built like ofetch's FetchError in the browser: getters for the answer's status and parsed body.
const fetchError = (status: number | undefined, body: unknown): Error => {
const error = new Error(`[PUT] "/api/orders/11111111-2222-4333-8444-555555555555": ${status ?? "<no response>"}`);
error.name = "FetchError";
for (const [key, value] of [
["data", body],
["status", status],
["statusCode", status]
] as const) {
Object.defineProperty(error, key, { get: () => value });
}
return error;
};
const ALL_MESSAGES = new Set<string>(Object.values(M));
test("asks for a valid e-mail address when the CMS rejects data.email", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.email: must be an email address of at most 254 characters"] });
assert.equal(checkoutErrorMessage(400, body), "Bitte gib eine gültige E-Mail-Adresse ein.");
});
test("asks to check the input when the CMS rejects another field", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: ["data.invoiceAddressStructured.postalCode: must be a string of at most 200 characters, or null"]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Invalid order update", errors: ["data.delivery: no delivery method has this id"] })),
M.invalidInput
);
});
test("asks to check the input when the shop rejects a field before the CMS", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.paymentAuthorised: not a field the customer may set"] });
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
});
test("mentions the e-mail address when it is among several rejected fields", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: [
"data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp",
"data.email: must be an email address"
]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidEmail);
});
test("says that a paid order can no longer be changed", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order can no longer be changed" })), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order is already paid" })), M.alreadyPaid);
assert.equal(M.alreadyPaid, "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.");
});
test("says that a product is no longer available", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "A product in the cart is no longer available" })), M.productUnavailable);
assert.equal(M.productUnavailable, "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.");
});
test("names the fields an order that is not ready for checkout misses", () => {
const body = (missing: string[]) => shopError(400, { message: "Order is not ready for checkout", missing });
assert.equal(checkoutErrorMessage(400, body(["email"])), "Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse.");
assert.equal(
checkoutErrorMessage(400, body(["email", "invoiceAddress", "delivery"])),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse, Rechnungsadresse, Versandart."
);
});
test("says that an order is not ready without naming a field the shop does not know", () => {
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout" })), M.notReady);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["<b>phone</b>"] })), M.notReady);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["phone", "delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
});
test("asks to restart the payment when it does not fit the order or the order changed", () => {
for (const message of ["Payment does not belong to this order", "Payment does not match this order", "Order changed during the payment"]) {
assert.equal(checkoutErrorMessage(409, shopError(409, { message })), M.restartPayment, message);
}
assert.match(M.restartPayment, /Bitte starte die Zahlung neu\.$/);
});
test("asks to try again in a few minutes when PayPal is not reachable", () => {
for (const message of ["Could not create the PayPal order", "PayPal could not capture the payment"]) {
assert.equal(checkoutErrorMessage(502, shopError(502, { message })), M.paypalUnavailable, message);
}
assert.equal(checkoutErrorMessage(502, "<html>502 Bad Gateway</html>"), M.paypalUnavailable);
assert.match(M.paypalUnavailable, /versuche es in ein paar Minuten noch einmal\.$/);
});
test("asks not to pay again when PayPal may have taken the money but the order is not marked paid", () => {
assert.equal(checkoutErrorMessage(502, shopError(502, { message: "Payment could not be confirmed" })), M.paymentUnconfirmed);
assert.equal(checkoutErrorMessage(500, shopError(500, { message: "Payment could not be recorded" })), M.paymentUnconfirmed);
});
test("falls back to a general message for anything else", () => {
assert.equal(checkoutErrorMessage(404, shopError(404, { message: "Order not found" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Missing order UUID" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Product not found in cart" })), M.generic);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Something else" })), M.generic);
assert.equal(checkoutErrorMessage(500, { error: true, statusCode: 500, statusMessage: "Server Error", message: "Server Error" }), M.generic);
assert.equal(checkoutErrorMessage(503, shopError(503, { message: "The CMS did not answer" })), M.generic);
assert.equal(checkoutErrorMessage(undefined, undefined), M.generic);
assert.equal(checkoutErrorMessage(409, null), M.generic);
assert.equal(M.generic, "Es ist ein Fehler aufgetreten. Bitte versuche es erneut.");
});
test("reads the CMS's own error body as well", () => {
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Invalid order update", { errors: ["data.email: must be an email address"] })),
M.invalidEmail
);
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Order is not ready for checkout", { missing: ["delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order can no longer be changed")), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order changed during the payment")), M.restartPayment);
});
test("never shows the server's text or personal data", () => {
const personal = "erika@example.org, Musterstraße 1, 70190 Stuttgart";
const bodies = [
shopError(400, { message: personal, errors: [`data.email: ${personal}`], missing: [personal] }),
shopError(409, { message: personal }),
shopError(502, { message: personal }),
strapiError(400, personal, { errors: [personal] }),
{ message: personal },
personal
];
for (const status of [undefined, 400, 404, 409, 500, 502, 503]) {
for (const body of bodies) {
const message = checkoutErrorMessage(status, body);
assert.ok(ALL_MESSAGES.has(message) || message.startsWith("Deine Bestellung ist noch nicht vollständig."), message);
assert.ok(!message.includes("erika") && !message.includes("Muster"), message);
}
}
});
test("reads the status and the body of the error $fetch throws", () => {
const body = shopError(409, { message: "Order can no longer be changed" });
assert.equal(checkoutErrorMessageFor(fetchError(409, body)), M.alreadyPaid);
assert.equal(checkoutErrorMessageFor(fetchError(502, shopError(502, { message: "Could not create the PayPal order" }))), M.paypalUnavailable);
assert.equal(
checkoutErrorMessageFor({ statusCode: 400, data: shopError(400, { message: "Invalid order update", errors: ["data.email: x"] }) }),
M.invalidEmail
);
});
test("falls back to the general message for an error without an answer", () => {
assert.equal(checkoutErrorMessageFor(fetchError(undefined, undefined)), M.generic);
assert.equal(checkoutErrorMessageFor(new TypeError("Failed to fetch")), M.generic);
assert.equal(checkoutErrorMessageFor(undefined), M.generic);
assert.equal(checkoutErrorMessageFor("error"), M.generic);
});
+201
View File
@@ -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"
);
});
+70
View File
@@ -0,0 +1,70 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CONTACT_RECIPIENT, contactMail, mailSendUrl } from "../../server/utils/contactMail.ts";
// components/ContactForm.vue posts these fields; it sends "Kontaktanfrage über Website" when the subject is left empty.
const form = {
name: "Erika Mustermann",
email: "erika@example.org",
subject: "Notizbuch mit Firmenlogo",
message: "Hallo,\nkönnt ihr 50 Stück liefern?"
};
const required = { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
test("sends to the mail service's /v1/send/message", () => {
assert.equal(mailSendUrl("http://mail:2222"), "http://mail:2222/v1/send/message");
assert.equal(mailSendUrl("http://mail:2222/"), "http://mail:2222/v1/send/message");
});
test("builds the body the mail service reads: to_email, subject and message", () => {
assert.deepEqual(contactMail(form), {
ok: true,
mail: {
to_email: "paperwork@muellerprints.de",
subject: "Kontaktanfrage: Notizbuch mit Firmenlogo",
message: [
"Name: Erika Mustermann",
"E-Mail: erika@example.org",
"Betreff: Notizbuch mit Firmenlogo",
"",
"Nachricht:",
"Hallo,\nkönnt ihr 50 Stück liefern?",
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
});
assert.equal(CONTACT_RECIPIENT, "paperwork@muellerprints.de");
});
test("fills in the subject when the form sends none", () => {
for (const subject of [undefined, "", " "]) {
const result = contactMail({ ...form, subject });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Anfrage über Website");
assert.equal(result.ok && result.mail.message.split("\n")[2], "Betreff: Kontaktanfrage über Website");
}
});
test("keeps a line break in the subject from starting another mail header", () => {
const result = contactMail({ ...form, subject: "Frage\r\nBcc: someone@example.org\nX-Spam: yes" });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Frage Bcc: someone@example.org X-Spam: yes");
});
test("requires a name, an e-mail address and a message", () => {
for (const field of ["name", "email", "message"]) {
assert.deepEqual(contactMail({ ...form, [field]: undefined }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: " " }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: ["a"] }), required, field);
}
for (const body of [undefined, null, "name=Erika", [form]]) assert.deepEqual(contactMail(body), required);
});
test("rejects an invalid e-mail address", () => {
for (const email of ["erika", "erika@example", "erika @example.org", "@example.org"]) {
assert.deepEqual(contactMail({ ...form, email }), { ok: false, message: "Ungültige E-Mail-Adresse" }, email);
}
});
+168
View File
@@ -0,0 +1,168 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CUSTOMER_UPDATE_FIELDS, invalidOrderUpdate, pickCustomerUpdate } from "../../server/utils/customerUpdate.ts";
// The body as readBody hands it to the route: sent by useShopApi().updateOrder as JSON.
const overTheWire = (body: unknown): unknown => JSON.parse(JSON.stringify(body));
const address = {
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE"
};
const otherAddress = {
givenName: "Max",
familyName: "Muster",
streetAddress: "Hauptstraße 5",
postalCode: "10115",
addressLevel2: "Berlin",
country: "DE"
};
// pages/checkout/1.vue: cart.update({ email, acceptedTermsAndConditionsAt }).
const step1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-09T08:15:00.000Z" } };
// pages/checkout/2.vue without a separate delivery address: the invoice address is sent as the delivery address too.
const step2 = {
data: {
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
invoiceAddressStructured: address,
deliveryAddressStructured: address,
delivery: 1
}
};
// The order's attributes in the CMS (src/api/order/content-types/order/schema.json) that only the server writes, and Strapi's own.
const SERVER_FIELDS = [
"id",
"uuid",
"date",
"customer",
"invoice",
"deliveryNote",
"hash",
"payment",
"VAT",
"subtotal",
"total",
"cart",
"paymentAuthorised",
"paymentStatus",
"paypalOrderId",
"paypalCaptureId",
"paymentCapturedAt",
"emailSent",
"invoiceSent",
"deliveryNoteSent",
"invoiceNumber",
"deliveryNoteNumber",
"deliveryTrackingNumber",
"createdAt",
"updatedAt",
"publishedAt"
];
test("lists the seven fields of the checkout's steps", () => {
assert.deepEqual(
[...CUSTOMER_UPDATE_FIELDS],
[
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
]
);
});
test("passes the payload of checkout step 1 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step1)), { ok: true, data: step1.data });
});
test("passes the payload of checkout step 2 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step2)), { ok: true, data: step2.data });
});
test("passes the payload of checkout step 2 with a separate delivery address unchanged", () => {
const body = {
data: {
...step2.data,
deliveryAddress: "Max Muster\nHauptstraße 5\n10115 Berlin",
deliveryAddressStructured: otherAddress,
delivery: 2
}
};
assert.deepEqual(pickCustomerUpdate(overTheWire(body)), { ok: true, data: body.data });
});
test("leaves the values to the CMS, which checks them", () => {
const body = { data: { email: "no address", delivery: null, invoiceAddressStructured: { street: "x" } } };
assert.deepEqual(pickCustomerUpdate(body), { ok: true, data: body.data });
});
test("rejects each field only the server writes", () => {
for (const field of SERVER_FIELDS) {
assert.deepEqual(
pickCustomerUpdate({ data: { [field]: 1 } }),
{ ok: false, unknown: [`data.${field}`], errors: [`data.${field}: not a field the customer may set`] },
field
);
}
});
test("rejects the whole body instead of dropping the field, and names every rejected field", () => {
const body = { data: { ...step1.data, paymentAuthorised: true, total: 0.01 } };
assert.deepEqual(pickCustomerUpdate(body), {
ok: false,
unknown: ["data.paymentAuthorised", "data.total"],
errors: ["data.paymentAuthorised: not a field the customer may set", "data.total: not a field the customer may set"]
});
});
test("rejects a field sent beside data", () => {
assert.deepEqual(pickCustomerUpdate({ email: "erika@example.org", data: {} }), {
ok: false,
unknown: ["email"],
errors: ["email: not accepted, the fields belong in data"]
});
});
test("rejects a body without data, with data that is not an object, and a body that is not an object", () => {
assert.deepEqual(pickCustomerUpdate({}), { ok: false, unknown: [], errors: ["data: missing"] });
assert.deepEqual(pickCustomerUpdate({ data: [step1.data] }), { ok: false, unknown: [], errors: ["data: must be an object"] });
assert.deepEqual(pickCustomerUpdate({ data: null }), { ok: false, unknown: [], errors: ["data: must be an object"] });
for (const body of [undefined, null, "data", [step1]]) {
assert.deepEqual(pickCustomerUpdate(body), { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] });
}
});
test("rejects __proto__ and constructor without touching any prototype", () => {
const result = pickCustomerUpdate(JSON.parse('{"data":{"__proto__":{"paymentAuthorised":true},"constructor":{"prototype":{}}}}'));
assert.equal(result.ok, false);
assert.deepEqual(result.ok === false && result.unknown, ["data.__proto__", "data.constructor"]);
assert.equal(({} as Record<string, unknown>).paymentAuthorised, undefined);
});
test("cuts a long field name in the answer", () => {
const result = pickCustomerUpdate({ data: { ["x".repeat(100)]: 1 } });
assert.deepEqual(result.ok === false && result.unknown, [`data.${"x".repeat(64)}…`]);
});
test("answers a rejected body with a 400 in the shape of the CMS's own 400", () => {
assert.deepEqual(invalidOrderUpdate(["data.total: not a field the customer may set"]), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors: ["data.total: not a field the customer may set"] }
});
});
+6
View File
@@ -0,0 +1,6 @@
// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike Vite and Nitro it does
// not resolve extensionless relative imports such as `import { … } from "./cmsError"` in a server/utils module.
// 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: `./cmsError` imported from a .ts file resolves to `./cmsError.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;
}
}
}