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 | 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((_, 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); });