feat(checkout): show a German message when a checkout step fails
checkoutErrorMessage (utils/checkoutError.ts) maps the status and body of a
failed request, the shop's answer or a raw CMS (Strapi) error, to one fixed
message for the customer, addressed with "du": an invalid e-mail address
(when data.email is among the rejected fields), other invalid input, an order
already paid, a product no longer available, an order not ready for checkout
(naming the missing steps), a payment that does not fit the order or an order
changed during the payment ("Bitte starte die Zahlung neu"), PayPal not
reachable ("versuche es in ein paar Minuten noch einmal"), a payment PayPal
may have taken that the CMS could not confirm or record ("Bitte bezahle nicht
noch einmal"), and a general fallback. It never shows the server's text.
Steps 1 and 2 show the message above their submit button, in the style of
step 3's payment error, instead of logging the error only. Step 3 shows it
for a failed PayPal order creation or capture. The e-mail input of step 1 is
type="email" (Input.vue takes a type).
Refs libretech/mp#71
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user