Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
1 parent
9b0c6af8b7
commit
5002cac469
24 files changed
+2911
-313
No files matched your search
@@ -0,0 +1,375 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
ALREADY_PAID,
|
||||
NOT_CONFIRMED,
|
||||
NOT_FOUND,
|
||||
NOT_MATCHING,
|
||||
NOT_RECORDED,
|
||||
NOT_THIS_ORDER,
|
||||
ORDER_CHANGED,
|
||||
PAYPAL_FAILED,
|
||||
capturePayment,
|
||||
captureRequestId
|
||||
} from "../../src/checkout/capture-payment.ts";
|
||||
import { formatLogLine } from "../../src/logging/format.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
const PAYPAL_ORDER_ID = "5O190127TN364715T";
|
||||
const OTHER_PAYPAL_ORDER_ID = "8MC585209K746392H";
|
||||
const CAPTURE_ID = "3C679366HH908993F";
|
||||
// The server's time when the capture request arrives.
|
||||
const NOW = new Date("2026-10-08T19:13:05.000Z");
|
||||
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
|
||||
|
||||
// PayPal's Orders v2 JSON for the order below, see paypal-verify.test.ts. Every call returns a fresh copy to change.
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
|
||||
|
||||
// The row the capture reads with strapi.db.query("api::order.order").findOne({ where: { uuid } }) after a checkout stored the
|
||||
// PayPal order it created: subtotal 17.90, delivery 4.95, total 22.85.
|
||||
const orderRow = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: UUID,
|
||||
email: "erika@example.org",
|
||||
subtotal: 17.9,
|
||||
total: 22.85,
|
||||
VAT: 2.86,
|
||||
paymentAuthorised: false,
|
||||
paymentStatus: null,
|
||||
invoiceSent: false,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
paypalCaptureId: null,
|
||||
paymentCapturedAt: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
/**
|
||||
* Fakes of the dependencies over one order row, which record every call and every log line. updateOrder writes like the
|
||||
* controller's update with where { id, paypalOrderId }: only while the row still carries that PayPal order, else it answers null.
|
||||
* duringCapture runs while PayPal captures, as a request that changes the order at that moment would.
|
||||
*/
|
||||
const setup = ({
|
||||
row = orderRow(),
|
||||
paypalOrder = fixture("paypal-order-approved.json"),
|
||||
captured = fixture("paypal-order-captured.json"),
|
||||
getError = undefined,
|
||||
captureError = undefined,
|
||||
updateError = undefined,
|
||||
duringCapture = (row) => {}
|
||||
} = {}) => {
|
||||
const calls = { findOrder: [], getPayPalOrder: [], capturePayPalOrder: [], updateOrder: [] };
|
||||
const logs = [];
|
||||
const deps = {
|
||||
findOrder: async (uuid) => {
|
||||
calls.findOrder.push(uuid);
|
||||
return row !== null && row.uuid === uuid ? { ...row } : null;
|
||||
},
|
||||
getPayPalOrder: async (id) => {
|
||||
calls.getPayPalOrder.push(id);
|
||||
if (getError) throw getError;
|
||||
return paypalOrder;
|
||||
},
|
||||
capturePayPalOrder: async (id, requestId) => {
|
||||
calls.capturePayPalOrder.push({ id, requestId });
|
||||
duringCapture(row);
|
||||
if (captureError) throw captureError;
|
||||
return captured;
|
||||
},
|
||||
updateOrder: async (id, paypalOrderId, data) => {
|
||||
calls.updateOrder.push({ id, paypalOrderId, data });
|
||||
if (updateError) throw updateError;
|
||||
if (row.id !== id || row.paypalOrderId !== paypalOrderId) return null;
|
||||
Object.assign(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
log: {
|
||||
info: (message, fields) => logs.push({ level: "info", message, fields }),
|
||||
warn: (message, fields) => logs.push({ level: "warn", message, fields }),
|
||||
error: (message, fields) => logs.push({ level: "error", message, fields })
|
||||
}
|
||||
};
|
||||
return { deps, calls, logs, row };
|
||||
};
|
||||
|
||||
const capture = (deps, { uuid = UUID, paypalOrderId = PAYPAL_ORDER_ID } = {}) => capturePayment(deps, { uuid, paypalOrderId, now: NOW });
|
||||
|
||||
const paypalCalls = (calls) => calls.getPayPalOrder.length + calls.capturePayPalOrder.length;
|
||||
|
||||
// What must never reach the log: the credentials of a failed request, and the buyer's data in PayPal's responses.
|
||||
const NEVER_LOGGED = [CREDENTIALS, "Authorization", "Erika", "Mustermann", "erika", "Musterstraße", "QYR5Z8XDVJNXQ", "INSTRUMENT_DECLINED"];
|
||||
|
||||
/** Checks the recorded log, and each line as the console shows it (config/logger.ts formats with formatLogLine). */
|
||||
const assertCleanLog = (logs) => {
|
||||
const lines = logs.map(({ level, message, fields }) =>
|
||||
formatLogLine({ timestamp: "2026-10-08 19:13:05.000", level, message: `app:${level[0]}:order-controller: ${message}`, ...fields })
|
||||
);
|
||||
for (const text of [JSON.stringify(logs), ...lines]) {
|
||||
for (const value of NEVER_LOGGED) assert.ok(!text.includes(value), `the log holds ${value}: ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
// What the PayPal SDK throws for an error response: @apimatic/core's ApiError, here the SDK's CustomError, with the request, whose
|
||||
// Authorization header holds the client credentials (token request) or an access token, and PayPal's error body.
|
||||
const paypalApiError = (statusCode: number, message: string) =>
|
||||
Object.assign(new Error(message), {
|
||||
request: {
|
||||
method: "POST",
|
||||
url: `https://api-m.sandbox.paypal.com/v2/checkout/orders/${PAYPAL_ORDER_ID}/capture`,
|
||||
headers: { Authorization: `Basic ${CREDENTIALS}`, "PayPal-Request-Id": captureRequestId(UUID, PAYPAL_ORDER_ID) }
|
||||
},
|
||||
statusCode,
|
||||
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
|
||||
body: JSON.stringify({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "INSTRUMENT_DECLINED" }], debug_id: "f1a2b3c4d5e6f" })
|
||||
});
|
||||
|
||||
// What axios throws when PayPal cannot be reached: the request config with its headers.
|
||||
const networkError = (message = "getaddrinfo EAI_AGAIN api-m.sandbox.paypal.com") =>
|
||||
Object.assign(new Error(message), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.sandbox.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
|
||||
// The capture
|
||||
|
||||
test("captures the PayPal order the checkout stored and writes exactly the payment fields", async () => {
|
||||
const { deps, calls, logs } = setup();
|
||||
const result = await capture(deps);
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.alreadyCaptured, false);
|
||||
assert.deepEqual(calls.findOrder, [UUID]);
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.deepEqual(calls.capturePayPalOrder, [{ id: PAYPAL_ORDER_ID, requestId: `capture-${UUID}-${PAYPAL_ORDER_ID}` }]);
|
||||
assert.deepEqual(calls.updateOrder, [
|
||||
{
|
||||
id: 4711,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
data: { paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID, paymentCapturedAt: "2026-10-08T19:13:05.000Z" }
|
||||
}
|
||||
]);
|
||||
// the controller answers the updated order, sanitized
|
||||
assert.equal(result.order.paymentAuthorised, true);
|
||||
assert.deepEqual(logs, [{ level: "info", message: "Captured the payment", fields: { order: 4711, capture: CAPTURE_ID } }]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("sends the same PayPal-Request-Id on every attempt, so that PayPal captures once", async () => {
|
||||
assert.equal(captureRequestId(UUID, PAYPAL_ORDER_ID), "capture-11111111-2222-4333-8444-555555555555-5O190127TN364715T");
|
||||
// PayPal accepts at most 108 characters
|
||||
assert.ok(captureRequestId(UUID, PAYPAL_ORDER_ID).length <= 108);
|
||||
|
||||
// The first attempt times out after PayPal may have captured. The retry repeats the id, and PayPal answers the first capture.
|
||||
const first = setup({ captureError: Object.assign(networkError("timeout of 30000ms exceeded"), { code: "ECONNABORTED" }) });
|
||||
assert.deepEqual(await capture(first.deps), { status: 502, message: PAYPAL_FAILED });
|
||||
const retry = setup();
|
||||
assert.equal((await capture(retry.deps)).status, 200);
|
||||
|
||||
assert.equal(first.calls.capturePayPalOrder[0].requestId, retry.calls.capturePayPalOrder[0].requestId);
|
||||
});
|
||||
|
||||
// Paying a cheap order, capturing an expensive one
|
||||
|
||||
test("rejects a PayPal order the checkout did not store on the order, without asking PayPal", async () => {
|
||||
// The buyer paid order A with PayPal order OTHER_PAYPAL_ORDER_ID and calls the capture of the expensive order B with it.
|
||||
const { deps, calls, logs, row } = setup({ row: orderRow({ id: 4712, uuid: UUID, total: 249 }) });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture with a PayPal order the checkout did not create for the order",
|
||||
fields: { order: 4712, hasPayPalOrder: true }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects every PayPal order for an order without a stored one, without asking PayPal", async () => {
|
||||
// no checkout yet, an order from before the checkout stored it, or a cart changed since the checkout
|
||||
for (const paypalOrderId of [null, undefined, ""]) {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paypalOrderId }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.hasPayPalOrder, false);
|
||||
}
|
||||
|
||||
// a request without a PayPal order id
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: "" }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order whose amount is not the order total", async () => {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
paypalOrder.purchase_units[0].amount.value = "2.29";
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.equal(calls.capturePayPalOrder.length, 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture: the PayPal order does not match the order",
|
||||
fields: { order: 4711, reason: "the PayPal order amount 2.29 is not the order total 22.85" }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order created for another order, in another currency or not approved", async () => {
|
||||
const cases: [(paypalOrder) => void, string][] = [
|
||||
[
|
||||
(paypalOrder) => (paypalOrder.purchase_units[0].custom_id = "99999999-8888-4777-8666-555555555555"),
|
||||
"the custom_id of the PayPal order is not the order's uuid"
|
||||
],
|
||||
[(paypalOrder) => (paypalOrder.purchase_units[0].amount.currency_code = "USD"), "the PayPal order currency is USD, not EUR"],
|
||||
[(paypalOrder) => (paypalOrder.status = "CREATED"), "the PayPal order status is CREATED, not APPROVED"]
|
||||
];
|
||||
for (const [change, reason] of cases) {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
change(paypalOrder);
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.equal(calls.capturePayPalOrder.length + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.reason, reason);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// A paid order
|
||||
|
||||
test("answers alreadyCaptured for a paid order and the PayPal order it was paid with, without a call or a write", async () => {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 200, alreadyCaptured: true });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test("rejects another PayPal order for a paid order, without a call or a write", async () => {
|
||||
const paidRows = [
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }),
|
||||
// paid before the checkout stored the PayPal order
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "authorised", paypalOrderId: null })
|
||||
];
|
||||
for (const row of paidRows) {
|
||||
const { deps, calls } = setup({ row });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: ALREADY_PAID });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("answers 404 for a missing order, without asking PayPal", async () => {
|
||||
const { deps, calls } = setup({ row: null });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 404, message: NOT_FOUND });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
|
||||
// No uuid: no lookup at all, since Strapi drops an undefined where condition and would find any order.
|
||||
for (const uuid of ["", undefined]) {
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capturePayment(deps, { uuid, paypalOrderId: PAYPAL_ORDER_ID, now: NOW }), { status: 404, message: NOT_FOUND });
|
||||
assert.deepEqual(calls.findOrder, []);
|
||||
}
|
||||
});
|
||||
|
||||
// PayPal fails
|
||||
|
||||
test("answers 502 when PayPal fails, and logs the error's name, message and status code but not its request", async () => {
|
||||
const failures: [string, Error & { statusCode?: number }][] = [
|
||||
["getError", paypalApiError(401, "Authentication failed due to missing authorization header, or invalid authentication credentials.")],
|
||||
["getError", networkError()],
|
||||
["captureError", paypalApiError(422, "The requested action could not be performed, semantically incorrect, or failed business validation.")],
|
||||
["captureError", networkError()]
|
||||
];
|
||||
for (const [failing, error] of failures) {
|
||||
const { deps, calls, logs, row } = setup({ [failing]: error });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: PAYPAL_FAILED });
|
||||
assert.equal(calls.capturePayPalOrder.length, failing === "getError" ? 0 : 1);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0].level, "error");
|
||||
assert.deepEqual(logs[0].fields, { order: 4711, error: { name: error.name, message: error.message, statusCode: error.statusCode } });
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// After PayPal answered the capture
|
||||
|
||||
test("keeps the order unpaid and answers 502 when the capture does not match, logging the order and the reason only", async () => {
|
||||
const pending = fixture("paypal-order-captured.json");
|
||||
pending.purchase_units[0].payments.captures[0].status = "PENDING";
|
||||
const lower = fixture("paypal-order-captured.json");
|
||||
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
|
||||
const minimal = { id: PAYPAL_ORDER_ID, status: "COMPLETED", links: [] };
|
||||
|
||||
const cases: [unknown, string][] = [
|
||||
[pending, "the capture status is PENDING, not COMPLETED"],
|
||||
[lower, "the capture amount 2.29 is not the order total 22.85"],
|
||||
[minimal, "the capture response has no capture"]
|
||||
];
|
||||
for (const [captured, reason] of cases) {
|
||||
const { deps, calls, logs, row } = setup({ captured });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: NOT_CONFIRMED });
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund",
|
||||
fields: { order: 4711, reason }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the order unpaid when its cart or address changed during the capture", async () => {
|
||||
// addProduct, removeProduct and updateByUuid clear paypalOrderId in the write that changes the order
|
||||
const { deps, calls, logs, row } = setup({
|
||||
duringCapture: (row) => {
|
||||
row.total = 249;
|
||||
row.paypalOrderId = null;
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: ORDER_CHANGED });
|
||||
assert.equal(calls.updateOrder.length, 1);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund",
|
||||
fields: { order: 4711, capture: CAPTURE_ID }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("answers 500 when the order cannot be marked paid after the capture, and logs the capture", async () => {
|
||||
const { deps, logs } = setup({ updateError: new Error("Connection terminated unexpectedly") });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 500, message: NOT_RECORDED });
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order could not be marked paid: check the order",
|
||||
fields: {
|
||||
order: 4711,
|
||||
capture: CAPTURE_ID,
|
||||
error: { name: "Error", message: "Connection terminated unexpectedly", statusCode: undefined }
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseCustomerUpdate } from "../../src/checkout/customer-update.ts";
|
||||
|
||||
// The server's time when the update arrives.
|
||||
const NOW = new Date("2026-10-08T19:12:05.000Z");
|
||||
|
||||
const parse = (body: unknown) => parseCustomerUpdate(body, NOW);
|
||||
|
||||
// The names of the fields a body is rejected for, or [] if it is accepted.
|
||||
const rejectedFields = (body: unknown) => {
|
||||
const result = parse(body);
|
||||
return result.ok ? [] : result.fields;
|
||||
};
|
||||
|
||||
// What the controller receives: the JSON the shop sent, parsed by Strapi's body parser.
|
||||
const wire = (body: unknown) => JSON.parse(JSON.stringify(body));
|
||||
|
||||
// The shop's checkout, libreshop/shop: useCart().update(data) sends { data } (composables/useShopApi.ts, updateOrder), and its
|
||||
// server route forwards that body unchanged.
|
||||
// Step 1, pages/checkout/1.vue: the email and the time the browser thinks the terms were accepted.
|
||||
const STEP_1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:03.512Z" } };
|
||||
|
||||
// Step 2, pages/checkout/2.vue: addressToString and mapToStructuredAddress of the form, and the id of the chosen delivery method.
|
||||
// Without a different delivery address, the delivery address is the invoice address.
|
||||
const ERIKA = {
|
||||
givenName: "Erika",
|
||||
familyName: "Mustermann",
|
||||
streetAddress: "Musterstraße 1",
|
||||
postalCode: "70190",
|
||||
addressLevel2: "Stuttgart",
|
||||
country: "DE"
|
||||
};
|
||||
const ERIKA_TEXT = "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart";
|
||||
const STEP_2 = {
|
||||
data: { invoiceAddress: ERIKA_TEXT, deliveryAddress: ERIKA_TEXT, invoiceAddressStructured: ERIKA, deliveryAddressStructured: ERIKA, delivery: 1 }
|
||||
};
|
||||
|
||||
const MAX = {
|
||||
givenName: "Max",
|
||||
familyName: "Mustermann",
|
||||
streetAddress: "Hauptstraße 5",
|
||||
postalCode: "10115",
|
||||
addressLevel2: "Berlin",
|
||||
country: "DE"
|
||||
};
|
||||
const MAX_TEXT = "Max Mustermann\nHauptstraße 5\n10115 Berlin";
|
||||
|
||||
// Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), among them the PayPal
|
||||
// order the checkout stores and the capture it verified, and the fields Strapi manages; with values a client would send to get a
|
||||
// paid order for less.
|
||||
const SERVER_FIELDS = {
|
||||
paymentAuthorised: true,
|
||||
paymentStatus: "captured",
|
||||
paypalOrderId: "5O190127TN364715T",
|
||||
paypalCaptureId: "3C679366HH908993F",
|
||||
paymentCapturedAt: "2026-01-01T00:00:00.000Z",
|
||||
total: 0.01,
|
||||
subtotal: 0.01,
|
||||
VAT: 0,
|
||||
invoiceNumber: "R-1-1",
|
||||
invoice: 1,
|
||||
invoiceSent: false,
|
||||
deliveryNote: 1,
|
||||
deliveryNoteNumber: "L-1-1",
|
||||
deliveryNoteSent: true,
|
||||
deliveryTrackingNumber: "00340434161094042557",
|
||||
emailSent: true,
|
||||
cart: [{ product: 101, count: 100 }],
|
||||
customer: 1,
|
||||
payment: 1,
|
||||
uuid: "11111111-2222-4333-8444-555555555555",
|
||||
hash: "0",
|
||||
date: "2026-01-01",
|
||||
id: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
publishedAt: "2026-01-01T00:00:00.000Z",
|
||||
createdBy: 1,
|
||||
updatedBy: 1
|
||||
};
|
||||
|
||||
// What the shop sends
|
||||
|
||||
test("accepts the shop's step 1 payload: the email, and the terms accepted at the server's time", (t) => {
|
||||
const result = parse(wire(STEP_1));
|
||||
t.diagnostic(`output: ${JSON.stringify(result)}`);
|
||||
|
||||
assert.deepEqual(result, { ok: true, data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" } });
|
||||
});
|
||||
|
||||
test("accepts the shop's step 2 payload: both addresses as text and structured, and the delivery method", (t) => {
|
||||
const result = parse(wire(STEP_2));
|
||||
t.diagnostic(`output: ${JSON.stringify(result)}`);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
data: {
|
||||
invoiceAddress: ERIKA_TEXT,
|
||||
deliveryAddress: ERIKA_TEXT,
|
||||
invoiceAddressStructured: ERIKA,
|
||||
deliveryAddressStructured: ERIKA,
|
||||
delivery: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the shop's step 2 payload with a different delivery address", () => {
|
||||
const body = { data: { ...STEP_2.data, deliveryAddress: MAX_TEXT, deliveryAddressStructured: MAX, delivery: 2 } };
|
||||
|
||||
assert.deepEqual(parse(wire(body)), { ok: true, data: body.data });
|
||||
});
|
||||
|
||||
// What only the server writes
|
||||
|
||||
for (const [field, value] of Object.entries(SERVER_FIELDS)) {
|
||||
test(`rejects ${field}, which only the server writes`, () => {
|
||||
assert.deepEqual(parse({ data: { [field]: value } }), {
|
||||
ok: false,
|
||||
errors: [`data.${field}: not a field the customer may set`],
|
||||
fields: [`data.${field}`]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects the whole update, not just the server-only fields, when they come with the shop's fields", () => {
|
||||
assert.deepEqual(parse({ data: { ...STEP_1.data, paymentAuthorised: true, paymentStatus: "authorised" } }), {
|
||||
ok: false,
|
||||
errors: ["data.paymentAuthorised: not a field the customer may set", "data.paymentStatus: not a field the customer may set"],
|
||||
fields: ["data.paymentAuthorised", "data.paymentStatus"]
|
||||
});
|
||||
});
|
||||
|
||||
test("names the rejected fields but never repeats their values", () => {
|
||||
const result = parse({
|
||||
data: {
|
||||
email: "SECRET erika@example.org",
|
||||
invoiceAddress: { SECRET: true },
|
||||
invoiceAddressStructured: { ...ERIKA, givenName: "SECRET".repeat(40) },
|
||||
invoiceNumber: "R-SECRET",
|
||||
delivery: "SECRET"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.ok ? [] : result.fields, [
|
||||
"data.email",
|
||||
"data.invoiceAddress",
|
||||
"data.invoiceAddressStructured.givenName",
|
||||
"data.invoiceNumber",
|
||||
"data.delivery"
|
||||
]);
|
||||
assert.ok(!JSON.stringify(result).includes("SECRET"), JSON.stringify(result));
|
||||
});
|
||||
|
||||
// The terms
|
||||
|
||||
test("records the server's time as the time the terms were accepted, whatever the client sends", () => {
|
||||
for (const sent of ["2026-10-08T19:12:03.512Z", "1999-12-31T23:00:00.000Z", "2030-01-01T00:00:00.000Z", "yes", true, 1]) {
|
||||
assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), {
|
||||
ok: true,
|
||||
data: { acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" }
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(parseCustomerUpdate({ data: { acceptedTermsAndConditionsAt: true } }, new Date("2026-10-09T06:30:00.000Z")), {
|
||||
ok: true,
|
||||
data: { acceptedTermsAndConditionsAt: "2026-10-09T06:30:00.000Z" }
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a terms acceptance without a true value", () => {
|
||||
for (const sent of [false, null, "", 0]) {
|
||||
assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), {
|
||||
ok: false,
|
||||
errors: ["data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp"],
|
||||
fields: ["data.acceptedTermsAndConditionsAt"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The email
|
||||
|
||||
test("accepts an email address trimmed, up to 254 characters", () => {
|
||||
const longest = `${"e".repeat(242)}@example.org`;
|
||||
assert.equal(longest.length, 254);
|
||||
|
||||
assert.deepEqual(parse({ data: { email: " erika@example.org\n" } }), { ok: true, data: { email: "erika@example.org" } });
|
||||
assert.deepEqual(parse({ data: { email: longest } }), { ok: true, data: { email: longest } });
|
||||
});
|
||||
|
||||
test("rejects an email that is not an address of at most 254 characters", () => {
|
||||
const emails = [
|
||||
"",
|
||||
" ",
|
||||
"erika",
|
||||
"erika.example.org",
|
||||
"@example.org",
|
||||
"erika@",
|
||||
`${"e".repeat(243)}@example.org`,
|
||||
"erika mustermann@example.org",
|
||||
"erika@example.org\r\nBcc: everyone@example.org",
|
||||
"erika@exa\u0000mple.org",
|
||||
null,
|
||||
42,
|
||||
true,
|
||||
["erika@example.org"],
|
||||
{ address: "erika@example.org" }
|
||||
];
|
||||
for (const email of emails) {
|
||||
assert.deepEqual(
|
||||
parse({ data: { email } }),
|
||||
{ ok: false, errors: ["data.email: must be an email address of at most 254 characters"], fields: ["data.email"] },
|
||||
JSON.stringify(email)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The addresses
|
||||
|
||||
test("accepts an invoice or delivery address of up to 500 characters", () => {
|
||||
const longest = `${ERIKA_TEXT}\n${"c/o ".repeat(113)}`;
|
||||
assert.equal(longest.length, 500);
|
||||
|
||||
assert.deepEqual(parse({ data: { invoiceAddress: longest, deliveryAddress: longest } }), {
|
||||
ok: true,
|
||||
data: { invoiceAddress: longest, deliveryAddress: longest }
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an invoice or delivery address that is not a string of at most 500 characters", () => {
|
||||
for (const field of ["invoiceAddress", "deliveryAddress"]) {
|
||||
for (const value of ["a".repeat(501), null, 42, true, [ERIKA_TEXT], { street: "Musterstraße 1" }]) {
|
||||
assert.deepEqual(parse({ data: { [field]: value } }), {
|
||||
ok: false,
|
||||
errors: [`data.${field}: must be a string of at most 500 characters`],
|
||||
fields: [`data.${field}`]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a structured address with only some components, null for a component without a value, and 200 characters", () => {
|
||||
const partial = { givenName: "Erika", familyName: null, postalCode: "70190", country: "DE" };
|
||||
const longest = { ...ERIKA, streetAddress: "s".repeat(200) };
|
||||
|
||||
assert.deepEqual(parse({ data: { invoiceAddressStructured: partial } }), { ok: true, data: { invoiceAddressStructured: partial } });
|
||||
assert.deepEqual(parse({ data: { deliveryAddressStructured: longest } }), { ok: true, data: { deliveryAddressStructured: longest } });
|
||||
});
|
||||
|
||||
test("rejects a structured address with keys that are not its components, and names them", () => {
|
||||
assert.deepEqual(parse({ data: { invoiceAddressStructured: { ...ERIKA, id: 7, street: "Musterstraße 1" } } }), {
|
||||
ok: false,
|
||||
errors: [
|
||||
"data.invoiceAddressStructured.id: not a structured address field",
|
||||
"data.invoiceAddressStructured.street: not a structured address field"
|
||||
],
|
||||
fields: ["data.invoiceAddressStructured.id", "data.invoiceAddressStructured.street"]
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a structured address that is not an object", () => {
|
||||
for (const field of ["invoiceAddressStructured", "deliveryAddressStructured"]) {
|
||||
for (const value of [null, ERIKA_TEXT, 42, true, [ERIKA]]) {
|
||||
assert.deepEqual(parse({ data: { [field]: value } }), {
|
||||
ok: false,
|
||||
errors: [`data.${field}: must be an object`],
|
||||
fields: [`data.${field}`]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a structured address component that is not a string of at most 200 characters, or null", () => {
|
||||
for (const value of ["s".repeat(201), 42, true, ["Erika"], { name: "Erika" }]) {
|
||||
assert.deepEqual(parse({ data: { deliveryAddressStructured: { ...ERIKA, givenName: value } } }), {
|
||||
ok: false,
|
||||
errors: ["data.deliveryAddressStructured.givenName: must be a string of at most 200 characters, or null"],
|
||||
fields: ["data.deliveryAddressStructured.givenName"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The delivery method
|
||||
|
||||
test("accepts the id of a delivery method", () => {
|
||||
for (const delivery of [1, 2, 2147483647]) {
|
||||
assert.deepEqual(parse({ data: { delivery } }), { ok: true, data: { delivery } });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a delivery that is not a positive integer", () => {
|
||||
// NaN and Infinity cannot come as JSON, but the parser does not rely on that.
|
||||
for (const delivery of [0, -1, 1.5, "1", null, true, [1], { id: 1 }, 2147483648, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
assert.deepEqual(parse({ data: { delivery } }), {
|
||||
ok: false,
|
||||
errors: ["data.delivery: must be the id of a delivery method, a positive integer"],
|
||||
fields: ["data.delivery"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The body
|
||||
|
||||
test("rejects a body that is not an object", () => {
|
||||
for (const body of [undefined, null, "", "data", 42, true, [], [STEP_1]]) {
|
||||
assert.deepEqual(parse(body), { ok: false, errors: ["body: must be an object of the form { data: { … } }"], fields: ["body"] });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a body without data, and names the fields sent outside it", () => {
|
||||
assert.deepEqual(parse({}), { ok: false, errors: ["data: missing"], fields: ["data"] });
|
||||
assert.deepEqual(parse(STEP_1.data), {
|
||||
ok: false,
|
||||
errors: [
|
||||
"email: not accepted, the fields belong in data",
|
||||
"acceptedTermsAndConditionsAt: not accepted, the fields belong in data",
|
||||
"data: missing"
|
||||
],
|
||||
fields: ["email", "acceptedTermsAndConditionsAt", "data"]
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects data that is not an object", () => {
|
||||
for (const data of [null, "email=erika@example.org", 42, true, [], [STEP_1.data]]) {
|
||||
assert.deepEqual(parse({ data }), { ok: false, errors: ["data: must be an object"], fields: ["data"] });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects keys next to data", () => {
|
||||
assert.deepEqual(rejectedFields({ ...STEP_1, meta: {}, paymentAuthorised: true }), ["meta", "paymentAuthorised"]);
|
||||
});
|
||||
|
||||
test("rejects __proto__ and constructor like any other key", () => {
|
||||
assert.deepEqual(rejectedFields(JSON.parse('{"__proto__": {"paymentAuthorised": true}, "data": {}}')), ["__proto__"]);
|
||||
assert.deepEqual(rejectedFields(JSON.parse('{"data": {"__proto__": {"paymentAuthorised": true}, "constructor": {}}}')), [
|
||||
"data.__proto__",
|
||||
"data.constructor"
|
||||
]);
|
||||
assert.deepEqual(rejectedFields(JSON.parse('{"data": {"invoiceAddressStructured": {"__proto__": {"givenName": "Erika"}}}}')), [
|
||||
"data.invoiceAddressStructured.__proto__"
|
||||
]);
|
||||
});
|
||||
|
||||
test("cuts a rejected key to 64 characters", () => {
|
||||
assert.deepEqual(rejectedFields({ data: { ["k".repeat(10000)]: 1 } }), [`data.${"k".repeat(64)}…`]);
|
||||
});
|
||||
|
||||
test("accepts an update without fields", () => {
|
||||
assert.deepEqual(parse({ data: {} }), { ok: true, data: {} });
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"id": "5O190127TN364715T",
|
||||
"intent": "CAPTURE",
|
||||
"status": "APPROVED",
|
||||
"payment_source": {
|
||||
"paypal": {
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"account_id": "QYR5Z8XDVJNXQ",
|
||||
"account_status": "VERIFIED",
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase_units": [
|
||||
{
|
||||
"reference_id": "11111111-2222-4333-8444-555555555555",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85",
|
||||
"breakdown": {
|
||||
"item_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "15.04"
|
||||
},
|
||||
"shipping": {
|
||||
"currency_code": "EUR",
|
||||
"value": "4.95"
|
||||
},
|
||||
"tax_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "2.86"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payee": {
|
||||
"email_address": "merchant@example.com",
|
||||
"merchant_id": "7KNGBPH2U58GQ"
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"shipping": {
|
||||
"name": {
|
||||
"full_name": "Erika Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"address_line_1": "Musterstraße 1",
|
||||
"admin_area_2": "Stuttgart",
|
||||
"postal_code": "70190",
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"payer": {
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"payer_id": "QYR5Z8XDVJNXQ",
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"create_time": "2026-10-08T19:12:40Z",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "update",
|
||||
"method": "PATCH"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
|
||||
"rel": "capture",
|
||||
"method": "POST"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"id": "5O190127TN364715T",
|
||||
"intent": "CAPTURE",
|
||||
"status": "COMPLETED",
|
||||
"payment_source": {
|
||||
"paypal": {
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"account_id": "QYR5Z8XDVJNXQ",
|
||||
"account_status": "VERIFIED",
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase_units": [
|
||||
{
|
||||
"reference_id": "11111111-2222-4333-8444-555555555555",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85",
|
||||
"breakdown": {
|
||||
"item_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "15.04"
|
||||
},
|
||||
"shipping": {
|
||||
"currency_code": "EUR",
|
||||
"value": "4.95"
|
||||
},
|
||||
"tax_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "2.86"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payee": {
|
||||
"email_address": "merchant@example.com",
|
||||
"merchant_id": "7KNGBPH2U58GQ"
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"shipping": {
|
||||
"name": {
|
||||
"full_name": "Erika Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"address_line_1": "Musterstraße 1",
|
||||
"admin_area_2": "Stuttgart",
|
||||
"postal_code": "70190",
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"payments": {
|
||||
"captures": [
|
||||
{
|
||||
"id": "3C679366HH908993F",
|
||||
"status": "COMPLETED",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85"
|
||||
},
|
||||
"final_capture": true,
|
||||
"seller_protection": {
|
||||
"status": "ELIGIBLE",
|
||||
"dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"]
|
||||
},
|
||||
"seller_receivable_breakdown": {
|
||||
"gross_amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85"
|
||||
},
|
||||
"paypal_fee": {
|
||||
"currency_code": "EUR",
|
||||
"value": "0.92"
|
||||
},
|
||||
"net_amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "21.93"
|
||||
}
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund",
|
||||
"rel": "refund",
|
||||
"method": "POST"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "up",
|
||||
"method": "GET"
|
||||
}
|
||||
],
|
||||
"create_time": "2026-10-08T19:13:02Z",
|
||||
"update_time": "2026-10-08T19:13:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"payer": {
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"payer_id": "QYR5Z8XDVJNXQ",
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"create_time": "2026-10-08T19:12:40Z",
|
||||
"update_time": "2026-10-08T19:13:02Z",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { describeError } from "../../src/logging/error.ts";
|
||||
import { formatLogLine } from "../../src/logging/format.ts";
|
||||
|
||||
const TIMESTAMP = "2026-10-08 21:29:40.426";
|
||||
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
|
||||
|
||||
// What axios throws when PayPal cannot be reached: the request config with its headers.
|
||||
const networkError = () =>
|
||||
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
|
||||
test("keeps the name, message and status code of a PayPal SDK error, not its request, headers or body", () => {
|
||||
// @apimatic/core's ApiError, which the SDK's CustomError and OAuthProviderError extend
|
||||
const error = Object.assign(new Error("OAuth 2 Authorization endpoint returned error."), {
|
||||
request: { method: "POST", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } },
|
||||
statusCode: 401,
|
||||
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
|
||||
body: '{"error":"invalid_client","error_description":"Client Authentication failed"}'
|
||||
});
|
||||
|
||||
assert.deepEqual(describeError(error), { name: "Error", message: "OAuth 2 Authorization endpoint returned error.", statusCode: 401 });
|
||||
});
|
||||
|
||||
test("keeps the name and message of an axios network error, not its config", () => {
|
||||
assert.deepEqual(describeError(networkError()), { name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("keeps the first line of a message: the SDK's response validation error quotes the response after it", () => {
|
||||
const error = new Error(
|
||||
'The response did not match the response schema.\n\n> Issue #1\n\nExpected value to be of type \'string\'.\n\nGiven value: {"email_address":"erika.buyer@example.org"}'
|
||||
);
|
||||
|
||||
assert.deepEqual(describeError(error), { name: "Error", message: "The response did not match the response schema.", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("cuts a long message", () => {
|
||||
assert.equal(describeError(new Error("x".repeat(500))).message, `${"x".repeat(200)}…`);
|
||||
});
|
||||
|
||||
test("describes a thrown value that is not an error by its type alone", () => {
|
||||
assert.deepEqual(describeError("/result/error"), { name: "string", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError(undefined), { name: "undefined", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError(null), { name: "object", message: "", statusCode: undefined });
|
||||
assert.deepEqual(describeError({ statusCode: "500" }), { name: "object", message: "", statusCode: undefined });
|
||||
});
|
||||
|
||||
test("the log line of the description holds no credentials", () => {
|
||||
// The formatter now also keeps them out of the line of the error object itself, see log-format.test.ts.
|
||||
const line = formatLogLine({
|
||||
timestamp: TIMESTAMP,
|
||||
level: "error",
|
||||
message: "app:e:order-controller: PayPal did not capture the payment",
|
||||
order: 4711,
|
||||
error: describeError(networkError())
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
line,
|
||||
'2026-10-08 21:29:40.426Z error: app:e:order-controller: PayPal did not capture the payment {\n "order": 4711,\n "error": {\n "name": "AxiosError",\n "message": "getaddrinfo EAI_AGAIN api-m.paypal.com"\n }\n}'
|
||||
);
|
||||
assert.ok(!line.includes(CREDENTIALS));
|
||||
});
|
||||
+130
-37
@@ -33,48 +33,141 @@ test("ignores the symbol keys winston adds", () => {
|
||||
assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello ");
|
||||
});
|
||||
|
||||
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
||||
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
|
||||
|
||||
test(
|
||||
"does not throw on a circular reference",
|
||||
{ todo: "JSON.stringify throws on a circular reference — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
|
||||
request.self = request;
|
||||
test("does not throw on a circular reference", () => {
|
||||
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
|
||||
request.self = request;
|
||||
|
||||
assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request }));
|
||||
assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request }));
|
||||
});
|
||||
|
||||
test("does not write the Authorization header of an axios-like error into the line", () => {
|
||||
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
// What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack.
|
||||
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
|
||||
|
||||
assert.ok(!line.includes(CREDENTIALS), line);
|
||||
});
|
||||
|
||||
test("renders an Error with its message instead of {}", () => {
|
||||
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
|
||||
// strapi.log.error("…", { error }), as in the order controller's capturePayment
|
||||
const line = formatLogLine({
|
||||
timestamp: TIMESTAMP,
|
||||
level: "error",
|
||||
message: "app:e:order-controller: Error capturing payment for order x",
|
||||
error
|
||||
});
|
||||
|
||||
assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line);
|
||||
});
|
||||
|
||||
// Safe serialisation.
|
||||
|
||||
const fieldsOf = (line: string) => JSON.parse(line.slice(line.indexOf("{")));
|
||||
|
||||
test("redacts the fields named like a credential at any depth, whatever their case", () => {
|
||||
const line = formatLogLine({
|
||||
timestamp: TIMESTAMP,
|
||||
level: "debug",
|
||||
message: "app:d:test",
|
||||
token: "A21AAF…",
|
||||
request: {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Basic ${CREDENTIALS}`, Cookie: "koa.sess=abc", "X-API-Key": "k1", "Content-Type": "application/json" },
|
||||
body: { grant_type: "client_credentials", client_secret: "s3cr3t", user: { password: "hunter2", api_key: "k2", apikey: "k3" } },
|
||||
responses: [{ headers: { "set-cookie": ["koa.sess=def"] }, data: { access_token: "A21AAG…", expires_in: 32400 } }]
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(fieldsOf(line), {
|
||||
token: "[redacted]",
|
||||
request: {
|
||||
method: "POST",
|
||||
headers: { Authorization: "[redacted]", Cookie: "[redacted]", "X-API-Key": "[redacted]", "Content-Type": "application/json" },
|
||||
body: {
|
||||
grant_type: "client_credentials",
|
||||
client_secret: "[redacted]",
|
||||
user: { password: "[redacted]", api_key: "[redacted]", apikey: "[redacted]" }
|
||||
},
|
||||
responses: [{ headers: { "set-cookie": "[redacted]" }, data: { access_token: "[redacted]", expires_in: 32400 } }]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("writes the other fields of an object that contains itself, and the reference to it as [Circular]", () => {
|
||||
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
|
||||
request.socket = { request, remoteAddress: "151.101.1.21" };
|
||||
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request, list: [request] });
|
||||
|
||||
assert.deepEqual(fieldsOf(line), {
|
||||
request: { method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } },
|
||||
list: [{ method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } }]
|
||||
});
|
||||
});
|
||||
|
||||
test("writes an object that is referenced twice, but not by itself, both times", () => {
|
||||
const delivery = { id: 1, price: 4.95 };
|
||||
|
||||
assert.deepEqual(fieldsOf(formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "m", before: delivery, after: delivery })), {
|
||||
before: { id: 1, price: 4.95 },
|
||||
after: { id: 1, price: 4.95 }
|
||||
});
|
||||
});
|
||||
|
||||
test("writes an Error as its name and the first line of its message, and its stack only when asked", () => {
|
||||
const error = Object.assign(new Error('The response did not match the response schema.\n\nGiven value: {"email_address":"erika@example.org"}'), {
|
||||
request: { url: "https://api-m.paypal.com/v2/checkout/orders", headers: { Authorization: "Bearer A21AAF…" } },
|
||||
statusCode: 201
|
||||
});
|
||||
const info = { timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", error };
|
||||
|
||||
assert.deepEqual(fieldsOf(formatLogLine(info)), { error: { name: "Error", message: "The response did not match the response schema." } });
|
||||
const withStack = fieldsOf(formatLogLine(info, { stack: true }));
|
||||
assert.equal(withStack.error.stack, error.stack);
|
||||
assert.deepEqual(Object.keys(withStack.error), ["name", "message", "stack"]);
|
||||
});
|
||||
|
||||
test("writes an instance of a class without toJSON as its class name, so the headers a request object keeps stay out", () => {
|
||||
class ClientRequest {
|
||||
_header = `POST /v1/oauth2/token HTTP/1.1\r\nAuthorization: Basic ${CREDENTIALS}\r\n\r\n`;
|
||||
method = "POST";
|
||||
}
|
||||
);
|
||||
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", request: new ClientRequest(), seen: new Set([1]) });
|
||||
|
||||
test(
|
||||
"does not write the Authorization header of an axios-like error into the line",
|
||||
{ todo: "the PayPal client credentials are logged — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
// What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack.
|
||||
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
|
||||
assert.deepEqual(fieldsOf(line), { request: "[ClientRequest]", seen: "[Set]" });
|
||||
assert.ok(!line.includes(CREDENTIALS), line);
|
||||
});
|
||||
|
||||
assert.ok(!line.includes(CREDENTIALS), line);
|
||||
test("writes an instance of a class with toJSON as what toJSON returns, redacted", () => {
|
||||
// Like axios's AxiosHeaders, which an axios error carries in config.headers.
|
||||
class AxiosHeaders {
|
||||
toJSON() {
|
||||
return { Accept: "application/json", Authorization: `Basic ${CREDENTIALS}` };
|
||||
}
|
||||
}
|
||||
);
|
||||
const at = new Date("2026-10-08T19:13:05.000Z");
|
||||
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", config: { headers: new AxiosHeaders() }, at });
|
||||
|
||||
test(
|
||||
"renders an Error with its message instead of {}",
|
||||
{ todo: "an Error is rendered as {} — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
|
||||
// strapi.log.error("…", { error }), as in the order controller's capturePayment
|
||||
const line = formatLogLine({
|
||||
timestamp: TIMESTAMP,
|
||||
level: "error",
|
||||
message: "app:e:order-controller: Error capturing payment for order x",
|
||||
error
|
||||
});
|
||||
assert.deepEqual(fieldsOf(line), {
|
||||
config: { headers: { Accept: "application/json", Authorization: "[redacted]" } },
|
||||
at: "2026-10-08T19:13:05.000Z"
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line);
|
||||
}
|
||||
);
|
||||
test("writes a bigint as its digits and a field that cannot be read as [unreadable]", () => {
|
||||
const unreadable = {
|
||||
get value() {
|
||||
throw new Error("no");
|
||||
}
|
||||
};
|
||||
// The PayPal SDK keeps an OAuth token's expiry as a bigint, which JSON.stringify throws on.
|
||||
const line = formatLogLine({ timestamp: TIMESTAMP, level: "debug", message: "m", expiry: 1791587585n, unreadable });
|
||||
|
||||
assert.deepEqual(fieldsOf(line), { expiry: "1791587585", unreadable: "[unreadable]" });
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isLockedForCustomer, missingCheckoutFields } from "../../src/checkout/order-state.ts";
|
||||
|
||||
// Shaped like the row PUT /orders/:uuid/cart reads with strapi.db.query("api::order.order").findOne before it updates.
|
||||
const order = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: "11111111-2222-4333-8444-555555555555",
|
||||
email: "erika@example.org",
|
||||
paymentAuthorised: false,
|
||||
paymentStatus: null,
|
||||
invoiceSent: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
test("an order is open for the customer until its payment is authorised", () => {
|
||||
assert.equal(isLockedForCustomer(order()), false);
|
||||
// false is the schema's default, but a row can hold null
|
||||
assert.equal(isLockedForCustomer(order({ paymentAuthorised: null })), false);
|
||||
assert.equal(isLockedForCustomer({}), false);
|
||||
});
|
||||
|
||||
test("an order is locked once its payment is authorised", () => {
|
||||
// what capturePayment writes once the capture is verified (src/checkout/capture-payment.ts)
|
||||
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "captured" })), true);
|
||||
// what it wrote before
|
||||
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "authorised" })), true);
|
||||
});
|
||||
|
||||
// Shaped like the row POST /orders/:uuid/checkout reads (strapi.db.query findOne with orderDefaultParams), after both checkout steps.
|
||||
const readyForCheckout = (overrides = {}) =>
|
||||
order({
|
||||
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
|
||||
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
|
||||
delivery: { id: 1, name: "DHL", price: 4.95 },
|
||||
...overrides
|
||||
});
|
||||
|
||||
test("an order that went through both checkout steps lacks nothing for the checkout", () => {
|
||||
assert.deepEqual(missingCheckoutFields(readyForCheckout()), []);
|
||||
});
|
||||
|
||||
test("names the email, invoice address and delivery method an order still lacks for the checkout", () => {
|
||||
// a new order: POST /orders creates it empty
|
||||
assert.deepEqual(missingCheckoutFields({ email: null, invoiceAddress: null, delivery: null }), ["email", "invoiceAddress", "delivery"]);
|
||||
assert.deepEqual(missingCheckoutFields({}), ["email", "invoiceAddress", "delivery"]);
|
||||
// after step 1 alone
|
||||
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: null, deliveryAddress: null, delivery: null })), [
|
||||
"invoiceAddress",
|
||||
"delivery"
|
||||
]);
|
||||
assert.deepEqual(missingCheckoutFields(readyForCheckout({ email: " " })), ["email"]);
|
||||
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: "" })), ["invoiceAddress"]);
|
||||
// a delivery method that was deleted is populated as null
|
||||
assert.deepEqual(missingCheckoutFields(readyForCheckout({ delivery: null })), ["delivery"]);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PAYPAL_TIMEOUT_MS, breaksClient, createPayPalClient, paypalClientConfiguration } from "../../src/checkout/paypal-client.ts";
|
||||
|
||||
const TOKEN_URL = "https://api-m.paypal.com/v1/oauth2/token";
|
||||
const ORDERS_URL = "https://api-m.paypal.com/v2/checkout/orders";
|
||||
const PAYPAL_ORDER = '{"id":"5O190127TN364715T","status":"CREATED"}';
|
||||
|
||||
// What axios throws when PayPal cannot be reached: no status code, the request in config.
|
||||
const networkError = (url = TOKEN_URL) =>
|
||||
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), { name: "AxiosError", code: "EAI_AGAIN", config: { method: "post", url } });
|
||||
|
||||
// What the SDK throws when PayPal answers with an error status (@apimatic/core's ApiError): the status code and the request.
|
||||
const apiError = (statusCode: number, url = ORDERS_URL) =>
|
||||
Object.assign(new Error(`PayPal answered ${statusCode}`), { statusCode, request: { method: "POST", url }, headers: {}, body: "{}" });
|
||||
|
||||
/**
|
||||
* A fake of the SDK's OrdersController over a Client. Like the SDK, every client requests its OAuth token on its first call and
|
||||
* keeps the promise for its lifetime, also when the token request failed: every later call fails with that same error and
|
||||
* requests nothing. The token request fails while dns.up is false. Each construction and token request is counted.
|
||||
*/
|
||||
const fakeSdk = () => {
|
||||
const sdk = { dnsUp: false, constructions: 0, tokenRequests: 0, configurations: [] as unknown[] };
|
||||
const createController = (configuration: unknown) => {
|
||||
sdk.constructions += 1;
|
||||
sdk.configurations.push(configuration);
|
||||
let token: Promise<string> | undefined;
|
||||
const authenticate = () => {
|
||||
if (token === undefined) {
|
||||
sdk.tokenRequests += 1;
|
||||
token = sdk.dnsUp ? Promise.resolve("A21AAF…") : Promise.reject(networkError());
|
||||
token.catch(() => {});
|
||||
}
|
||||
return token;
|
||||
};
|
||||
return {
|
||||
ordersCreate: async () => {
|
||||
await authenticate();
|
||||
return { statusCode: 201, body: PAYPAL_ORDER };
|
||||
}
|
||||
};
|
||||
};
|
||||
return { sdk, createController };
|
||||
};
|
||||
|
||||
test("a client of the SDK keeps a failed token request: every later call fails without a request", async () => {
|
||||
// What the replacement below works around, as the harness's scenario S7 observed with the real SDK.
|
||||
const { sdk, createController } = fakeSdk();
|
||||
const orders = createController({});
|
||||
|
||||
const first = await orders.ordersCreate().catch((error) => error);
|
||||
sdk.dnsUp = true;
|
||||
const second = await orders.ordersCreate().catch((error) => error);
|
||||
|
||||
assert.equal(second, first);
|
||||
assert.equal(sdk.tokenRequests, 1);
|
||||
});
|
||||
|
||||
test("replaces the client after a failed token request, so that the next call requests a new token", async () => {
|
||||
const { sdk, createController } = fakeSdk();
|
||||
const replaced: unknown[] = [];
|
||||
const client = createPayPalClient({ configuration: {}, createController, onReplace: (error) => replaced.push(error) });
|
||||
|
||||
await assert.rejects(
|
||||
client.call((orders) => orders.ordersCreate()),
|
||||
{ name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com" }
|
||||
);
|
||||
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [1, 1, 1]);
|
||||
|
||||
sdk.dnsUp = true;
|
||||
assert.deepEqual(await client.call((orders) => orders.ordersCreate()), { statusCode: 201, body: PAYPAL_ORDER });
|
||||
assert.deepEqual([sdk.constructions, sdk.tokenRequests], [2, 2]);
|
||||
|
||||
// The new client keeps its token.
|
||||
await client.call((orders) => orders.ordersCreate());
|
||||
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [2, 2, 1]);
|
||||
});
|
||||
|
||||
test("keeps the client after an answer of PayPal that leaves it usable", async () => {
|
||||
let constructions = 0;
|
||||
const client = createPayPalClient({
|
||||
configuration: {},
|
||||
createController: () => {
|
||||
constructions += 1;
|
||||
return { ordersCreate: async () => Promise.reject(apiError(422)) };
|
||||
}
|
||||
});
|
||||
|
||||
for (let call = 0; call < 3; call++) {
|
||||
const error = await client.call((orders) => orders.ordersCreate()).catch((rejection) => rejection);
|
||||
assert.equal(error.statusCode, 422);
|
||||
}
|
||||
assert.equal(constructions, 1);
|
||||
});
|
||||
|
||||
test("a call that fails on a replaced client does not replace its successor", async () => {
|
||||
let constructions = 0;
|
||||
const pending: ((error: unknown) => void)[] = [];
|
||||
const replaced: unknown[] = [];
|
||||
const client = createPayPalClient({
|
||||
configuration: {},
|
||||
createController: () => {
|
||||
constructions += 1;
|
||||
return { ordersGet: () => new Promise<never>((_, reject) => pending.push(reject)) };
|
||||
},
|
||||
onReplace: (error) => replaced.push(error)
|
||||
});
|
||||
|
||||
// Two calls run on the first client. The first one fails, which replaces the client, and a third call starts on the second.
|
||||
const first = client.call((orders) => orders.ordersGet());
|
||||
const second = client.call((orders) => orders.ordersGet());
|
||||
pending[0](networkError(ORDERS_URL));
|
||||
await assert.rejects(first);
|
||||
const third = client.call((orders) => orders.ordersGet());
|
||||
assert.equal(constructions, 2);
|
||||
|
||||
// The second call fails on the first client only now: the second client stays.
|
||||
pending[1](networkError(ORDERS_URL));
|
||||
await assert.rejects(second);
|
||||
const fourth = client.call((orders) => orders.ordersGet());
|
||||
assert.deepEqual([constructions, replaced.length], [2, 1]);
|
||||
|
||||
pending[2](apiError(404));
|
||||
pending[3](apiError(404));
|
||||
await Promise.all([assert.rejects(third), assert.rejects(fourth)]);
|
||||
});
|
||||
|
||||
test("breaks the client with an error of the token request, without an answer, with 401 and with 5xx", () => {
|
||||
for (const error of [
|
||||
networkError(TOKEN_URL),
|
||||
networkError(ORDERS_URL),
|
||||
Object.assign(new Error("timeout of 15000ms exceeded"), { name: "AxiosError", code: "ECONNABORTED", config: { url: ORDERS_URL } }),
|
||||
apiError(400, TOKEN_URL),
|
||||
apiError(429, TOKEN_URL),
|
||||
apiError(401, TOKEN_URL),
|
||||
apiError(401, ORDERS_URL),
|
||||
apiError(500, ORDERS_URL),
|
||||
apiError(503, ORDERS_URL),
|
||||
// The SDK's ResponseValidationError carries the status of the answer it could not read.
|
||||
Object.assign(new Error("The response did not match the response schema."), { statusCode: 200, request: { url: TOKEN_URL } }),
|
||||
new Error("The HTTP call was aborted."),
|
||||
"a string",
|
||||
undefined
|
||||
]) {
|
||||
assert.equal(breaksClient(error), true, String(error?.message ?? error));
|
||||
}
|
||||
for (const error of [apiError(400), apiError(404), apiError(409), apiError(422), apiError(429)]) {
|
||||
assert.equal(breaksClient(error), false, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
test("configures the SDK client with a timeout of 15 s and without logging bodies or headers", async () => {
|
||||
const configuration = paypalClientConfiguration({ clientId: "DUMMY_CLIENT_ID", clientSecret: "DUMMY_CLIENT_SECRET", environment: "Production" });
|
||||
|
||||
assert.equal(PAYPAL_TIMEOUT_MS, 15000);
|
||||
assert.deepEqual(configuration, {
|
||||
clientCredentialsAuthCredentials: { oAuthClientId: "DUMMY_CLIENT_ID", oAuthClientSecret: "DUMMY_CLIENT_SECRET" },
|
||||
timeout: 15000,
|
||||
environment: "Production",
|
||||
logging: { logRequest: { logBody: false, logHeaders: false }, logResponse: { logBody: false, logHeaders: false } }
|
||||
});
|
||||
|
||||
// Every client the factory creates, also after a replacement, gets this configuration.
|
||||
const { sdk, createController } = fakeSdk();
|
||||
const client = createPayPalClient({ configuration, createController });
|
||||
await client.call((orders) => orders.ordersCreate()).catch(() => {});
|
||||
sdk.dnsUp = true;
|
||||
await client.call((orders) => orders.ordersCreate());
|
||||
assert.equal(sdk.constructions, 2);
|
||||
for (const passed of sdk.configurations) assert.equal((passed as { timeout: number }).timeout, 15000);
|
||||
});
|
||||
+246
-73
@@ -1,6 +1,12 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
|
||||
import {
|
||||
CheckoutNotReadyError,
|
||||
ProductUnavailableError,
|
||||
PRODUCT_UNAVAILABLE,
|
||||
buildPayPalOrderRequest,
|
||||
checkoutFailure
|
||||
} from "../../src/checkout/paypal-order.ts";
|
||||
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
|
||||
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
|
||||
|
||||
@@ -27,7 +33,7 @@ const validOrder = (overrides = {}) => ({
|
||||
});
|
||||
|
||||
// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173).
|
||||
const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
|
||||
const orderFor = (lines: [number, number][], deliveryPrice: number | null, paymentPrice: number | null = null) => {
|
||||
const cart = lines.map(([price, count], index) => ({
|
||||
id: index + 1,
|
||||
count,
|
||||
@@ -37,17 +43,15 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
|
||||
return validOrder({
|
||||
cart,
|
||||
subtotal,
|
||||
total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
|
||||
total: Math.round((subtotal + (deliveryPrice ?? 0) + (paymentPrice ?? 0)) * 100) / 100,
|
||||
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
|
||||
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
|
||||
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice },
|
||||
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
|
||||
});
|
||||
};
|
||||
|
||||
// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw.
|
||||
const buildSession = (order) => {
|
||||
buildPayPalOrderItems(order);
|
||||
return buildPayPalOrderRequest(RETURN_URL, order);
|
||||
};
|
||||
// What createSessionOrThrow would send to PayPal, or what it would throw before asking PayPal.
|
||||
const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order);
|
||||
|
||||
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
|
||||
|
||||
@@ -71,6 +75,15 @@ const assertNoTypeError = (build: () => unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
const thrown = (build: () => unknown): unknown => {
|
||||
try {
|
||||
build();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail("did not throw");
|
||||
};
|
||||
|
||||
// Characterization: what the builders do today.
|
||||
|
||||
test("builds the PayPal order request for a valid order", () => {
|
||||
@@ -117,85 +130,61 @@ test("sends no shipping amount when the order has no delivery method", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an order without a total before computing the items", () => {
|
||||
for (const total of [0, null, undefined]) {
|
||||
assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" });
|
||||
// The request used to be built after a list of items that was never sent; computing it threw "Cart is empty or has no total".
|
||||
// That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502.
|
||||
test("rejects an empty cart or an order without a total as not ready for checkout", () => {
|
||||
for (const overrides of [{ total: 0 }, { total: null }, { total: undefined }, { cart: [] }, { cart: null }]) {
|
||||
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "Cart is empty or has no total" });
|
||||
}
|
||||
});
|
||||
|
||||
test("computes one item per cart line, which is not sent to PayPal", () => {
|
||||
const order = validOrder({
|
||||
cart: [
|
||||
{ id: 1, count: 1, product: notebook },
|
||||
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
|
||||
]
|
||||
});
|
||||
test("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => {
|
||||
// 3 × 12.50: per unit 10.50 net and 2.00 tax, 31.50 and 6.00 for the three; the subtotal of 37.50 has 31.51 net and 5.99 VAT.
|
||||
const unitNetCents = Math.round((1250 * 100) / 119);
|
||||
const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0];
|
||||
|
||||
assert.deepEqual(buildPayPalOrderItems(order), [
|
||||
{
|
||||
name: "Notizbuch A5 Punktraster",
|
||||
unitAmount: { currencyCode: "EUR", value: "15.04" },
|
||||
tax: { currencyCode: "EUR", value: "2.86" },
|
||||
quantity: "1"
|
||||
},
|
||||
{
|
||||
name: "Journaling Booklet",
|
||||
unitAmount: { currencyCode: "EUR", value: "10.50" },
|
||||
tax: { currencyCode: "EUR", value: "2.00" },
|
||||
quantity: "3"
|
||||
assert.equal("items" in unit, false);
|
||||
assert.deepEqual([3 * unitNetCents, 3 * (1250 - unitNetCents)], [3150, 600]);
|
||||
assert.deepEqual(unit.amount, {
|
||||
currencyCode: "EUR",
|
||||
value: "42.45",
|
||||
breakdown: {
|
||||
itemTotal: { currencyCode: "EUR", value: "31.51" },
|
||||
taxTotal: { currencyCode: "EUR", value: "5.99" },
|
||||
shipping: { currencyCode: "EUR", value: "4.95" }
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
||||
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
|
||||
|
||||
test(
|
||||
"D1: a missing delivery address is rejected with a clear validation error",
|
||||
{ todo: "null deliveryAddress throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
|
||||
}
|
||||
);
|
||||
test("D1: a missing delivery address is rejected with a clear validation error", () => {
|
||||
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
|
||||
});
|
||||
|
||||
test(
|
||||
"D2: a two-line delivery address does not throw a TypeError",
|
||||
{ todo: "a two-line address throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
|
||||
}
|
||||
);
|
||||
test("D2: a two-line delivery address does not throw a TypeError", () => {
|
||||
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
|
||||
});
|
||||
|
||||
test(
|
||||
"D3: a pickup whose delivery price is null is sent with shipping 0.00",
|
||||
{ todo: "delivery price null throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
|
||||
test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => {
|
||||
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
|
||||
|
||||
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
|
||||
}
|
||||
);
|
||||
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
|
||||
});
|
||||
|
||||
test(
|
||||
"D4: a cart line whose product was deleted is rejected with a clear error",
|
||||
{ todo: "a deleted product throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
|
||||
}
|
||||
);
|
||||
test("D4: a cart line whose product was deleted is rejected with a clear error", () => {
|
||||
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
|
||||
});
|
||||
|
||||
test(
|
||||
"D5: with a payment surcharge the breakdown still adds up to the amount",
|
||||
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
|
||||
() => {
|
||||
// total = 17.90 + 4.95 shipping + 1.50 surcharge
|
||||
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
|
||||
test("D5: with a payment surcharge the breakdown still adds up to the amount", () => {
|
||||
// total = 17.90 + 4.95 shipping + 1.50 surcharge
|
||||
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
|
||||
|
||||
assert.equal(amount.value, "24.35");
|
||||
assert.equal(breakdownCents(amount.breakdown), cents(amount));
|
||||
}
|
||||
);
|
||||
assert.equal(amount.value, "24.35");
|
||||
assert.equal(breakdownCents(amount.breakdown), cents(amount));
|
||||
});
|
||||
|
||||
// Not a todo: this holds today for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
|
||||
// This held before the fixes too, for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
|
||||
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
|
||||
const carts: { lines: [number, number][]; delivery: number | null }[] = [
|
||||
{ lines: [[17.9, 1]], delivery: 4.95 },
|
||||
@@ -232,3 +221,187 @@ test("D6: the breakdown adds up to the amount to the cent, also for the subtotal
|
||||
// Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory.
|
||||
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`);
|
||||
});
|
||||
|
||||
// Shipping address: the structured delivery address, else the structured invoice address, else the text delivery address.
|
||||
|
||||
const structured = (overrides = {}) => ({
|
||||
givenName: "Erika",
|
||||
familyName: "Mustermann",
|
||||
streetAddress: "Musterstraße 1",
|
||||
postalCode: "70190",
|
||||
addressLevel2: "Stuttgart",
|
||||
country: "DE",
|
||||
...overrides
|
||||
});
|
||||
const berlin = structured({ givenName: "Max", streetAddress: "Hauptstraße 5", postalCode: "10115", addressLevel2: "Berlin" });
|
||||
const berlinShipping = {
|
||||
name: { fullName: "Max Mustermann" },
|
||||
address: { addressLine1: "Hauptstraße 5", postalCode: "10115", adminArea2: "Berlin", countryCode: "DE" }
|
||||
};
|
||||
// An address.structured-address component that was saved without components: Strapi fills in the default country.
|
||||
const emptyStructured = { givenName: null, familyName: null, streetAddress: null, postalCode: null, addressLevel2: null, country: "DE" };
|
||||
const shippingOf = (order) => buildSession(order).purchaseUnits[0].shipping;
|
||||
|
||||
test("takes the shipping address from the structured delivery address before the invoice and the text address", () => {
|
||||
assert.deepEqual(shippingOf(validOrder({ deliveryAddressStructured: berlin, invoiceAddressStructured: structured() })), berlinShipping);
|
||||
});
|
||||
|
||||
test("falls back to the structured invoice address when no structured delivery address is entered", () => {
|
||||
for (const deliveryAddressStructured of [null, undefined, emptyStructured, { givenName: " ", country: "DE" }]) {
|
||||
assert.deepEqual(
|
||||
shippingOf(validOrder({ deliveryAddressStructured, invoiceAddressStructured: berlin, deliveryAddress: null })),
|
||||
berlinShipping
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("trims the components, names the buyer by the name parts there are and upper-cases the country, DE by default", () => {
|
||||
const order = (address) => validOrder({ deliveryAddressStructured: { ...structured(), ...address } });
|
||||
|
||||
assert.deepEqual(shippingOf(order({ givenName: " Erika ", familyName: null, streetAddress: " Musterstraße 1 ", country: "at" })), {
|
||||
name: { fullName: "Erika" },
|
||||
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "AT" }
|
||||
});
|
||||
for (const country of [null, undefined, ""]) {
|
||||
assert.equal(shippingOf(order({ country })).address.countryCode, "DE");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a structured address that is entered but incomplete instead of shipping to another address", () => {
|
||||
const order = validOrder({ deliveryAddressStructured: structured({ streetAddress: "", postalCode: null }), invoiceAddressStructured: berlin });
|
||||
|
||||
assert.throws(() => buildSession(order), { name: "CheckoutNotReadyError", message: "deliveryAddressStructured lacks streetAddress, postalCode" });
|
||||
assert.throws(() => buildSession(validOrder({ invoiceAddressStructured: structured({ givenName: null, familyName: "" }) })), {
|
||||
name: "CheckoutNotReadyError",
|
||||
message: "invoiceAddressStructured lacks givenName and familyName"
|
||||
});
|
||||
assert.throws(() => buildSession(validOrder({ deliveryAddressStructured: structured({ country: "Deutschland" }) })), {
|
||||
name: "CheckoutNotReadyError",
|
||||
message: "deliveryAddressStructured has no two-letter country code"
|
||||
});
|
||||
});
|
||||
|
||||
test("parses the text delivery address when no structured address is entered", () => {
|
||||
const shippingFor = (deliveryAddress) => shippingOf(validOrder({ deliveryAddress, deliveryAddressStructured: emptyStructured }));
|
||||
|
||||
assert.deepEqual(shippingFor(" Erika Mustermann\r\nMusterstraße 1\r\n\r\n70190 Stuttgart-Bad Cannstatt \n"), {
|
||||
name: { fullName: "Erika Mustermann" },
|
||||
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart-Bad Cannstatt", countryCode: "DE" }
|
||||
});
|
||||
// The street is the line above the postal code and city, as in a German address; the lines after the name go into addressLine2.
|
||||
assert.deepEqual(shippingFor("Erika Mustermann\nc/o Muster GmbH\nHinterhaus\nMusterstraße 1\n70190 Stuttgart"), {
|
||||
name: { fullName: "Erika Mustermann" },
|
||||
address: {
|
||||
addressLine1: "Musterstraße 1",
|
||||
addressLine2: "c/o Muster GmbH, Hinterhaus",
|
||||
postalCode: "70190",
|
||||
adminArea2: "Stuttgart",
|
||||
countryCode: "DE"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a text delivery address it cannot parse, and a missing one, with a validation error", () => {
|
||||
const cases: [unknown, string][] = [
|
||||
["Erika Mustermann\nMusterstraße 1", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
|
||||
["Erika Mustermann\n\nMusterstraße 1\n", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
|
||||
["Erika Mustermann\nMusterstraße 1\n70190", "The last line of deliveryAddress is not the postal code and the city"],
|
||||
[" \n ", "The order has no delivery address"],
|
||||
["", "The order has no delivery address"],
|
||||
[null, "The order has no delivery address"],
|
||||
[undefined, "The order has no delivery address"]
|
||||
];
|
||||
for (const [deliveryAddress, message] of cases) {
|
||||
assert.throws(() => buildSession(validOrder({ deliveryAddress })), { name: "CheckoutNotReadyError", message });
|
||||
}
|
||||
});
|
||||
|
||||
// Money in whole cents, and the errors the checkout answers.
|
||||
|
||||
test("reads decimals that arrive as strings like numbers", () => {
|
||||
const order = validOrder({ subtotal: "17.90", total: "22.85", VAT: "2.86", delivery: { id: 1, name: "DHL", price: "4.95" } });
|
||||
|
||||
assert.deepEqual(buildSession(order), buildSession(validOrder()));
|
||||
});
|
||||
|
||||
test("rejects totals that do not add up, as after a price change, instead of sending PayPal a breakdown it rejects", () => {
|
||||
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
|
||||
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
|
||||
name: "CheckoutNotReadyError",
|
||||
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595 + surcharge 0"
|
||||
});
|
||||
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
|
||||
name: "CheckoutNotReadyError",
|
||||
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495 + surcharge 0"
|
||||
});
|
||||
for (const overrides of [{ VAT: null }, { subtotal: undefined }, { VAT: "n/a" }]) {
|
||||
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "The order has no subtotal or no VAT" });
|
||||
}
|
||||
});
|
||||
|
||||
test("sends a payment surcharge as handling, and no handling for a payment method that costs nothing", () => {
|
||||
const breakdownOf = (order) => buildSession(order).purchaseUnits[0].amount.breakdown;
|
||||
|
||||
assert.deepEqual(breakdownOf(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })), {
|
||||
itemTotal: { currencyCode: "EUR", value: "15.04" },
|
||||
taxTotal: { currencyCode: "EUR", value: "2.86" },
|
||||
shipping: { currencyCode: "EUR", value: "4.95" },
|
||||
handling: { currencyCode: "EUR", value: "1.50" }
|
||||
});
|
||||
for (const payment of [null, undefined, { id: 1, name: "PayPal", price: 0 }, { id: 1, name: "PayPal", price: null }]) {
|
||||
assert.equal("handling" in breakdownOf(validOrder({ payment })), false);
|
||||
}
|
||||
// A surcharge the total lacks, as when the price of the payment method changed after the totals were computed.
|
||||
assert.throws(() => buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 } })), {
|
||||
name: "CheckoutNotReadyError",
|
||||
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 495 + surcharge 150"
|
||||
});
|
||||
});
|
||||
|
||||
test("adds up to the cent with a surcharge, with a pickup without a price, for every whole-cent subtotal up to 300.00", () => {
|
||||
const mismatches: string[] = [];
|
||||
for (let cent = 1; cent <= 30000; cent++) {
|
||||
for (const [delivery, payment] of [
|
||||
[4.95, 0.35],
|
||||
[0, 1.5]
|
||||
]) {
|
||||
const order = orderFor([[cent / 100, 1]], delivery, payment);
|
||||
if (delivery === 0) order.delivery = { id: 2, name: "Abholung", price: null };
|
||||
const { amount } = buildSession(order).purchaseUnits[0];
|
||||
if (breakdownCents(amount.breakdown) !== cents(amount) || cents(amount.breakdown.handling) !== Math.round(payment * 100)) {
|
||||
mismatches.push(`${cent}/${delivery}/${payment}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${mismatches.slice(0, 3).join(", ")}`);
|
||||
});
|
||||
|
||||
test("rejects a deleted product next to available ones as unavailable", () => {
|
||||
const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });
|
||||
|
||||
assert.throws(
|
||||
() => buildSession(order),
|
||||
(error) => error instanceof ProductUnavailableError && error.name === "ProductUnavailableError" && error.message === PRODUCT_UNAVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
test("answers an order that is not ready 400 with the reason, a deleted product 409 and any other error 502", () => {
|
||||
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: null })))), {
|
||||
status: 400,
|
||||
message: "Order is not ready for checkout",
|
||||
reason: "The order has no delivery address"
|
||||
});
|
||||
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })))).status, 400);
|
||||
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ total: 0 })))).status, 400);
|
||||
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })))), {
|
||||
status: 409,
|
||||
message: "A product in the cart is no longer available"
|
||||
});
|
||||
assert.ok(thrown(() => buildSession(validOrder({ total: 0 }))) instanceof CheckoutNotReadyError);
|
||||
|
||||
const sdkError = Object.assign(new Error("OAuth 2 provider says client authentication failed."), { statusCode: 401 });
|
||||
for (const error of [sdkError, new TypeError("Cannot read properties of null (reading 'split')"), "/result/error", undefined]) {
|
||||
assert.deepEqual(checkoutFailure(error), { status: 502, message: "Could not create the PayPal order" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts";
|
||||
import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it
|
||||
// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with
|
||||
// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change.
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
|
||||
const approved = () => fixture("paypal-order-approved.json");
|
||||
const captured = () => fixture("paypal-order-captured.json");
|
||||
|
||||
// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85.
|
||||
const order = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: UUID,
|
||||
email: "erika@example.org",
|
||||
subtotal: 17.9,
|
||||
total: 22.85,
|
||||
VAT: 2.86,
|
||||
delivery: { id: 1, name: "DHL", price: 4.95 },
|
||||
invoiceNumber: "R-12-4711",
|
||||
cart: [
|
||||
{ id: 1, count: 1, product: { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } } }
|
||||
],
|
||||
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
|
||||
...overrides
|
||||
});
|
||||
|
||||
// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it.
|
||||
const PERSONAL_DATA = [
|
||||
"Erika",
|
||||
"Mustermann",
|
||||
"erika",
|
||||
"QYR5Z8XDVJNXQ",
|
||||
"Musterstraße",
|
||||
"70190",
|
||||
"Stuttgart",
|
||||
"merchant@example.com",
|
||||
"7KNGBPH2U58GQ"
|
||||
];
|
||||
|
||||
/** The reason of a rejection, after checking that it repeats no personal data. */
|
||||
const rejection = (result: { ok: boolean; reason?: string }): string => {
|
||||
assert.equal(result.ok, false, "expected a rejection");
|
||||
for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`);
|
||||
return result.reason;
|
||||
};
|
||||
|
||||
// Money
|
||||
|
||||
test("parses PayPal's money values into integer cents", () => {
|
||||
assert.equal(parseCents("22.85"), 2285);
|
||||
assert.equal(parseCents("0.01"), 1);
|
||||
assert.equal(parseCents("4.9"), 490);
|
||||
assert.equal(parseCents("100"), 10000);
|
||||
assert.equal(parseCents("0.00"), 0);
|
||||
assert.equal(parseCents("1234567.89"), 123456789);
|
||||
});
|
||||
|
||||
test("parses nothing but a non-negative amount with at most two decimals", () => {
|
||||
for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) {
|
||||
assert.equal(parseCents(value), null, JSON.stringify(value));
|
||||
}
|
||||
for (const value of [22.85, null, undefined, {}, ["22.85"]]) {
|
||||
assert.equal(parseCents(value), null, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("turns the order total into integer cents, also where its float is a hair below the cent", () => {
|
||||
// 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point
|
||||
assert.equal(orderTotalCents({ total: 22.85 }), 2285);
|
||||
assert.equal(orderTotalCents({ total: 0.29 }), 29);
|
||||
assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285);
|
||||
for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) {
|
||||
assert.equal(orderTotalCents({ total }), null, String(total));
|
||||
}
|
||||
});
|
||||
|
||||
test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => {
|
||||
const mismatches: number[] = [];
|
||||
for (let cents = 1; cents <= 300000; cents++) {
|
||||
// The order service rounds every total to the cent; an order without a delivery method has its subtotal as its total.
|
||||
const total = cents / 100;
|
||||
const VAT = Math.round((total / 1.19) * 0.19 * 100) / 100;
|
||||
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order({ subtotal: total, total, VAT, delivery: null }));
|
||||
const { amount } = request.purchaseUnits[0];
|
||||
if (parseCents(amount.value) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total);
|
||||
}
|
||||
assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`);
|
||||
});
|
||||
|
||||
test("the fixtures belong to the PayPal order the checkout requests for the order", () => {
|
||||
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0];
|
||||
|
||||
for (const paypalOrder of [approved(), captured()]) {
|
||||
const unit = paypalOrder.purchase_units[0];
|
||||
assert.equal(unit.custom_id, request.customId);
|
||||
assert.equal(unit.amount.value, request.amount.value);
|
||||
assert.equal(unit.amount.currency_code, request.amount.currencyCode);
|
||||
}
|
||||
});
|
||||
|
||||
// The PayPal order the buyer approved
|
||||
|
||||
test("accepts the PayPal order the buyer approved for the order", () => {
|
||||
assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true });
|
||||
});
|
||||
|
||||
test("rejects a PayPal order that is not approved", () => {
|
||||
for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`);
|
||||
}
|
||||
const { status, ...withoutStatus } = approved();
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED");
|
||||
// a value that is not one of PayPal's statuses is not repeated
|
||||
assert.equal(
|
||||
rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })),
|
||||
"the PayPal order status is missing or unexpected, not APPROVED"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects the PayPal order of a cheap order captured for an expensive one", () => {
|
||||
// The buyer pays order A, then calls the capture of order B with A's PayPal order.
|
||||
const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 });
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid");
|
||||
});
|
||||
|
||||
test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => {
|
||||
const paypalOrder = approved();
|
||||
delete paypalOrder.purchase_units[0].custom_id;
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
|
||||
// undefined is not compared with undefined, nor "" with ""
|
||||
assert.equal(
|
||||
rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)),
|
||||
"the custom_id of the PayPal order is not the order's uuid"
|
||||
);
|
||||
paypalOrder.purchase_units[0].custom_id = "";
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
|
||||
});
|
||||
|
||||
test("rejects a PayPal order whose amount is not the order total", () => {
|
||||
const withAmount = (value: unknown) => {
|
||||
const paypalOrder = approved();
|
||||
paypalOrder.purchase_units[0].amount.value = value;
|
||||
return paypalOrder;
|
||||
};
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85");
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85");
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85");
|
||||
// the cart changed after the checkout
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00");
|
||||
for (const value of ["22.850", "22,85", "", 22.85, null]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a PayPal order in another currency or without an amount", () => {
|
||||
const paypalOrder = approved();
|
||||
paypalOrder.purchase_units[0].amount.currency_code = "USD";
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR");
|
||||
|
||||
paypalOrder.purchase_units[0].amount.currency_code = "Euro";
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR");
|
||||
|
||||
delete paypalOrder.purchase_units[0].amount;
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount");
|
||||
});
|
||||
|
||||
test("rejects every PayPal order for an order without a total", () => {
|
||||
for (const total of [null, 0]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit");
|
||||
const { purchase_units, ...withoutUnits } = approved();
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit");
|
||||
|
||||
// "/result/error" is what PayPalApi.checkPaymentStatus answered for every error
|
||||
for (const value of [null, undefined, "/result/error", [], 42]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// The PayPal order after the capture
|
||||
|
||||
test("accepts the completed capture of the order total and returns the capture id", () => {
|
||||
assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" });
|
||||
});
|
||||
|
||||
test("rejects a capture response whose PayPal order is not completed", () => {
|
||||
for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) {
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a capture PayPal holds for review, declined or refunded", () => {
|
||||
for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) {
|
||||
const response = captured();
|
||||
response.purchase_units[0].payments.captures[0].status = status;
|
||||
if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" };
|
||||
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a capture response without a capture", () => {
|
||||
// what PayPal answers with Prefer: return=minimal, which the capture used to ask for
|
||||
const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links };
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture");
|
||||
|
||||
const withoutPayments = captured();
|
||||
delete withoutPayments.purchase_units[0].payments;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture");
|
||||
|
||||
const withoutCaptures = captured();
|
||||
withoutCaptures.purchase_units[0].payments.captures = [];
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture");
|
||||
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture");
|
||||
});
|
||||
|
||||
test("rejects a capture of another amount or in another currency", () => {
|
||||
const lower = captured();
|
||||
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85");
|
||||
|
||||
// the cart changed during the capture
|
||||
assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00");
|
||||
|
||||
const usd = captured();
|
||||
usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD";
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR");
|
||||
|
||||
const withoutAmount = captured();
|
||||
delete withoutAmount.purchase_units[0].payments.captures[0].amount;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount");
|
||||
});
|
||||
|
||||
test("rejects a capture without a valid id", () => {
|
||||
for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) {
|
||||
const response = captured();
|
||||
response.purchase_units[0].payments.captures[0].id = id;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id));
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects anything that is not a capture response", () => {
|
||||
for (const value of [null, undefined, "/result/error", [], 42]) {
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user