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,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);
|
||||
});
|
||||
Reference in new issue
Block a user