v0.1.2: checkout fixes for CMS v0.2.0 (#18)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #18.
This commit is contained in:
1 parent
60b8f1ab58
commit
fccff147ba
29 files changed
+1108
-71
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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"] }
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user