172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
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);
|
|
});
|