diff --git a/src/apis/types/login_types.ts b/src/apis/types/login_types.ts index 5a576a1..254d39d 100644 --- a/src/apis/types/login_types.ts +++ b/src/apis/types/login_types.ts @@ -1,5 +1,30 @@ import { Scope } from "./shared_types.ts"; +/** + * ErrorResponse + * Error responses are sent when an error (e.g. unauthorized, + * bad request, etc) occurred. + * + * @example {"error":"invalid_request","error_code":400, + * "error_debug":"The request is missing a required parameter, + * includes an invalid parameter or is otherwise malformed."} + */ +export type LoginErrorResponse = { + /** Name is the error name. */ + error: string; + /** + * Code represents the error status code (404, 403, 401, ...). + * + * @format int64 + */ + error_code?: number; + /** + * Debug contains debug information. + * This is usually not available and has to be enabled. + */ + error_debug?: string; +}; + export type LoginAuthQueryParams = { /** Value MUST be set to "code". */ response_type: "code"; @@ -181,28 +206,3 @@ export type LoginWellKnownResponse = { */ token_endpoint_auth_methods_supported?: string[]; }; - -/** - * ErrorResponse - * Error responses are sent when an error (e.g. unauthorized, - * bad request, etc) occurred. - * - * @example {"error":"invalid_request","error_code":400, - * "error_debug":"The request is missing a required parameter, - * includes an invalid parameter or is otherwise malformed."} - */ -export type LoginErrorResponse = { - /** Name is the error name. */ - error: string; - /** - * Code represents the error status code (404, 403, 401, ...). - * - * @format int64 - */ - error_code?: number; - /** - * Debug contains debug information. - * This is usually not available and has to be enabled. - */ - error_debug?: string; -}; diff --git a/src/apis/types/qr_types.ts b/src/apis/types/qr_types.ts index 8954a58..823e1c6 100644 --- a/src/apis/types/qr_types.ts +++ b/src/apis/types/qr_types.ts @@ -1,5 +1,17 @@ import { MerchantSerialNumber, ProblemJSON } from "./shared_types.ts"; +/** + * Represents the response for a QR error. + */ +export type QrErrorResponse = ProblemJSON & { + invalidParams?: { + /** @minLength 1 */ + name: string; + /** @minLength 1 */ + reason: string; + }[]; +}; + /** * @description Requested image format. * Supported values: {image/*,image/png, image/svg+xml, text/targetUrl} @@ -145,12 +157,3 @@ export type QrWebhookEvent = { */ initiatedAt: string; }; - -export type QrErrorResponse = ProblemJSON & { - invalidParams?: { - /** @minLength 1 */ - name: string; - /** @minLength 1 */ - reason: string; - }[]; -}; diff --git a/src/apis/types/recurring_types.ts b/src/apis/types/recurring_types.ts index 1f3e6e9..c54b0fb 100644 --- a/src/apis/types/recurring_types.ts +++ b/src/apis/types/recurring_types.ts @@ -1,28 +1,5 @@ -//////////////// Common types ///////////////// - import { ProblemJSON, Scope } from "./shared_types.ts"; -/** - * Only NOK is supported at the moment. Support for EUR and DKK will be provided in early 2024. - * @minLength 3 - * @maxLength 3 - * @pattern ^[A-Z]{3}$ - * @example "NOK" - */ -export type RecurringCurrencyV3 = "NOK"; - -/** - * @default "RECURRING" - * @example "RECURRING" - */ -export type ChargeType = "INITIAL" | "RECURRING"; - -/** - * Type of transaction, either direct capture or reserve capture - * @example "DIRECT_CAPTURE" - */ -export type RecurringTransactionType = "DIRECT_CAPTURE" | "RESERVE_CAPTURE"; - ///////////////// Error types ///////////////// export type RecurringErrorResponse = RecurringErrorV3 | RecurringErrorFromAzure; @@ -68,6 +45,29 @@ export type RecurringErrorFromAzure = { }; }; +//////////////// Common types ///////////////// + +/** + * Only NOK is supported at the moment. Support for EUR and DKK will be provided in early 2024. + * @minLength 3 + * @maxLength 3 + * @pattern ^[A-Z]{3}$ + * @example "NOK" + */ +export type RecurringCurrencyV3 = "NOK"; + +/** + * @default "RECURRING" + * @example "RECURRING" + */ +export type ChargeType = "INITIAL" | "RECURRING"; + +/** + * Type of transaction, either direct capture or reserve capture + * @example "DIRECT_CAPTURE" + */ +export type RecurringTransactionType = "DIRECT_CAPTURE" | "RESERVE_CAPTURE"; + //////////////// Charge types ///////////////// export type CreateChargeV3Request = { diff --git a/src/apis/types/shared_types.ts b/src/apis/types/shared_types.ts index 8a7ab94..10b2e77 100644 --- a/src/apis/types/shared_types.ts +++ b/src/apis/types/shared_types.ts @@ -80,3 +80,9 @@ export type ProblemJSON = { */ instance?: string | null; }; + +export type SDKError = { + ok: false; + message: string; + error?: TErr; +}; diff --git a/src/apis/types/webhooks_types.ts b/src/apis/types/webhooks_types.ts index ddae9f7..37720f0 100644 --- a/src/apis/types/webhooks_types.ts +++ b/src/apis/types/webhooks_types.ts @@ -2,7 +2,8 @@ import { ProblemJSON } from "./shared_types.ts"; // Error types // export type WebhooksErrorResponse = ProblemJSON & { - extraDetails: { + traceId?: string | null; + extraDetails?: { name: string; reason: string; }[] | null; diff --git a/src/base_client.ts b/src/base_client.ts index 7a95490..a4a530e 100644 --- a/src/base_client.ts +++ b/src/base_client.ts @@ -4,9 +4,11 @@ import { ClientResponse, RequestData, } from "./types.ts"; -import { buildRequest, fetchRetry } from "./base_client_helper.ts"; -import { parseError } from "./errors.ts"; +import { buildRequest } from "./base_client_helper.ts"; +import { parseError, parseRetryError } from "./errors.ts"; import { validateRequestData } from "./validate.ts"; +import { fetchRetry } from "./fetch.ts"; +import { isRetryError } from "./errors.ts"; /** * Creates a base client with the given configuration. @@ -37,6 +39,11 @@ export const baseClient = (cfg: ClientConfig): BaseClient => const res = await fetchRetry(request, cfg.retryRequests); return res; } catch (error: unknown) { + //Check if the error is a RetryError. + if (isRetryError(error)) { + return parseRetryError(); + } + // Otherwise, parse the error. return parseError(error); } }, diff --git a/src/base_client_helper.ts b/src/base_client_helper.ts index 0a2ab90..c31654c 100644 --- a/src/base_client_helper.ts +++ b/src/base_client_helper.ts @@ -1,74 +1,14 @@ -import { filterKeys, isErrorStatus, retry } from "./deps.ts"; -import { parseError } from "./errors.ts"; +import { filterKeys } from "./deps.ts"; import { ClientConfig, - ClientResponse, DefaultHeaders, OmitHeaders, RequestData, } from "./types.ts"; -/** - * Executes a fetch request with optional retry logic. - * - * @param request - The request to be executed. - * @param retryRequest - Whether to retry the request if it fails. Default is true. - * @returns A promise that resolves to the response of the request. - */ -export const fetchRetry = async ( - request: Request, - retryRequest = true, -): Promise> => { - // Execute request without retry - if (!retryRequest) { - return await fetchJSON(request); - } - // Execute request using retry - const req = retry(async () => { - return await fetchJSON(request); - }, { - multiplier: 2, - maxTimeout: 3000, - maxAttempts: 3, - minTimeout: 1000, - jitter: 0, - }); - return req; -}; - -/** - * Fetches JSON data from the specified request. - * @param request - The request to fetch JSON data from. - * @returns A promise that resolves to a ClientResponse object containing the fetched data. - * @template TOk - The type of the successful response data. - * @template TErr - The type of the error response data. - */ -export const fetchJSON = async ( - request: Request, -): Promise> => { - const response = await fetch(request); - - if (isErrorStatus(response.status)) { - // Bad Request - if (response.status === 400) { - const error = await response.json(); - return parseError(error); - } else { - // Throwing an error here will trigger a retry - throw new Error(response.statusText); - } - } - const text = await response.text(); - // Handle empty response body - if (text === "") { - return { ok: true, data: {} as TOk }; - } - const json = JSON.parse(text); - return { ok: true, data: json as TOk }; -}; - /** * Builds a Request object based on the provided configuration and request data. + * * @param cfg - The client configuration. * @param requestData - The request data containing method, headers, token, body, and URL. * @returns A Request object. @@ -141,7 +81,8 @@ export const getHeaders = ( * @returns The user agent string. */ export const getUserAgent = (): string => { - const metaUrl = import.meta.url || undefined; + const metaUrl: string | undefined = import.meta.url; + // If the sdk is loaded using require, import.meta.url will be undefined if (!metaUrl) { return "Vipps/Deno SDK/npm-require"; @@ -160,7 +101,6 @@ export const getUserAgent = (): string => { export const createSDKUserAgent = (metaUrl: string): string => { const url = new URL(metaUrl); - let userAgent = "Vipps/Deno SDK/"; // Check if the module was loaded from deno.land if ( url.host === "deno.land" && @@ -168,13 +108,10 @@ export const createSDKUserAgent = (metaUrl: string): string => { ) { // Extract the module version from the URL const sdkVersion = url.pathname.split("@")[1].split("/")[0]; - userAgent += sdkVersion; + return `Vipps/Deno SDK/${sdkVersion}`; } // Or if the module was loaded from npm else if (url.pathname.includes("node_modules")) { - userAgent += "npm-module"; + return `Vipps/Deno SDK/npm-module`; } // Otherwise, we don't know where the module was loaded from - else { - userAgent += "unknown"; - } - return userAgent; + return `Vipps/Deno SDK/unknown`; }; diff --git a/src/deps.ts b/src/deps.ts index e55bcd2..01fe939 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -2,5 +2,9 @@ export { retry, RetryError, } from "https://deno.land/std@0.212.0/async/retry.ts"; -export { isErrorStatus } from "https://deno.land/std@0.212.0/http/status.ts"; +export { + isServerErrorStatus, + isSuccessfulStatus, + STATUS_CODE, +} from "https://deno.land/std@0.212.0/http/status.ts"; export { filterKeys } from "https://deno.land/std@0.212.0/collections/mod.ts"; diff --git a/src/errors.ts b/src/errors.ts index 2e42cb1..3c1a0b3 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,27 @@ -import { QrErrorResponse } from "./apis/types/qr_types.ts"; -import { RetryError } from "./deps.ts"; -import { CheckoutErrorResponse } from "./apis/types/checkout_types.ts"; +import { RetryError, STATUS_CODE } from "./deps.ts"; +import { SDKError } from "./apis/types/shared_types.ts"; +import { RecurringErrorFromAzure } from "./mod.ts"; + +/** + * Checks if the provided JSON object is an instance of RetryError. + * @param json The JSON object to check. + * @returns True if the JSON object is an instance of RetryError, + * false otherwise. + */ +export const isRetryError = (json: unknown) => { + return json instanceof RetryError; +}; + +/** + * Parses the error and returns an object with error details. + * @returns An object with error details. + */ +export const parseRetryError = (): SDKError => { + return { + ok: false, + message: "Retry limit reached. Could not get a response from the server", + }; +}; /** * Parses the error and returns an object with error details. @@ -10,14 +31,11 @@ import { CheckoutErrorResponse } from "./apis/types/checkout_types.ts"; */ export const parseError = ( error: unknown, -): { ok: false; message: string; error?: TErr } => { - // Catch retry errors - if (error instanceof RetryError) { - return { - ok: false, - message: - "Could not get a response from the server after multiple attempts", - }; + status?: number, +): SDKError => { + // Catch ProblemJSON with details + if (isProblemJSONwithDetail(error)) { + return { ok: false, message: error.detail }; } // Catch connection errors @@ -32,7 +50,7 @@ export const parseError = ( } // Catch Forbidden - if (error instanceof Error && error.message.includes("Forbidden")) { + if (status === STATUS_CODE.Forbidden) { return { ok: false, message: @@ -40,15 +58,10 @@ export const parseError = ( }; } - // Catch regular errors - if (error instanceof Error) { - return { ok: false, message: `${error.name} - ${error.message}` }; - } - // Catch AccessTokenError if ( typeof error === "object" && error !== null && "error" in error && - "error_description" in error && "trace_id" in error + "error_description" in error ) { return { ok: false, @@ -57,47 +70,37 @@ export const parseError = ( }; } - // Catch Problem JSON - if ( - typeof error === "object" && error !== null && "type" in error && - "title" in error && "status" in error - ) { - return { - ok: false, - message: `${error.status} - ${error.title}`, - error: error as TErr, - }; - } - - // Catch Checkout Error JSON + // Catch Recurring Azure Error if ( - typeof error === "object" && error !== null && "errorCode" in error && - "errors" in error && typeof error["errors"] === "object" + typeof error === "object" && error !== null && "responseInfo" in error && + "result" in error ) { - const checkoutError = error as CheckoutErrorResponse; - const message = checkoutError.title || checkoutError.errorCode; + const azureError = error as RecurringErrorFromAzure; return { ok: false, - message, + message: azureError.result.message, error: error as TErr, }; } - // Catch QR Error JSON - if ( - typeof error === "object" && error !== null && "title" in error && - "detail" in error && "instance" in error - ) { - const qrError = error as QrErrorResponse; - const message = qrError.invalidParams?.[0]?.reason ?? qrError.detail ?? - "Unknown error"; - return { - ok: false, - message, - error: error as TErr, - }; + // Catch regular errors + if (error instanceof Error) { + return { ok: false, message: `${error.name} - ${error.message}` }; } // Default to error as string - return { ok: false, message: String(error) }; + return { ok: false, message: "Unknown error" }; +}; + +/** + * Checks if the given JSON object is a ProblemJSON with a detail property. + * + * @param json The JSON object to check. + * @returns True if the JSON object is a ProblemJSON with a detail property, false otherwise. + */ +const isProblemJSONwithDetail = (json: unknown): json is { detail: string } => { + return ( + typeof json === "object" && json !== null && + "detail" in json && typeof json["detail"] === "string" + ); }; diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..a543c86 --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,83 @@ +import { + isServerErrorStatus, + isSuccessfulStatus, + retry, + STATUS_CODE, +} from "./deps.ts"; +import { parseError } from "./errors.ts"; +import { ClientResponse } from "./types.ts"; + +/** + * Executes a fetch request with optional retry logic. + * + * @param request - The request to be executed. + * @param retryRequest - Whether to retry the request if it fails. Default is true. + * @returns A promise that resolves to the response of the request. + */ +export const fetchRetry = async ( + request: Request, + retryRequest = true, +): Promise> => { + // Execute request without retry + if (!retryRequest) { + return await fetchJSON(request); + } + // Execute request using retry + const req = retry(async () => { + return await fetchJSON(request); + }, { + multiplier: 2, + maxTimeout: 3000, + maxAttempts: 3, + minTimeout: 1000, + jitter: 0, + }); + return req; +}; + +/** + * Fetches JSON data from the specified request. + * + * @param request - The request to fetch JSON data from. + * @returns A ClientResponse object containing the fetched data. + * @template TOk - The type of the successful response data. + * @template TErr - The type of the error response data. + */ +export const fetchJSON = async ( + request: Request, +): Promise> => { + const response = await fetch(request); + + /** + * Check if the response is empty. + */ + if (response.status === STATUS_CODE.NoContent) { + return { ok: true, data: {} as TOk }; + } + + /** + * Parse the response body as JSON. We trust that the server returns + * a valid JSON response. + */ + const json = await response.json(); + + /** + * If a Server error is returned, throw an error. + * The request will be retried if retryRequest is true. + */ + if (isServerErrorStatus(response.status)) { + throw new Error(response.statusText); + } + + /** + * If the response status is a successful status, return the JSON as TOk. + */ + if (isSuccessfulStatus(response.status)) { + return { ok: true, data: json as TOk }; + } + + /** + * For any other type of error, return an Error object. + */ + return parseError(json, response.status); +}; diff --git a/tests/agreement_test.ts b/tests/agreement_test.ts index 7438a69..8a7832e 100644 --- a/tests/agreement_test.ts +++ b/tests/agreement_test.ts @@ -9,7 +9,7 @@ Deno.test("agreements - create - check correct url in TEST/MT", async () => { assertEquals(req.url, "https://apitest.vipps.no/recurring/v3/agreements"); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); diff --git a/tests/auth_test.ts b/tests/auth_test.ts index 50864d5..955e6e2 100644 --- a/tests/auth_test.ts +++ b/tests/auth_test.ts @@ -9,7 +9,7 @@ Deno.test("getToken - Should have correct url and header", async () => { mf.mock("POST@/accesstoken/get", (req: Request) => { assertEquals(req.url, "https://api.vipps.no/accesstoken/get"); assertEquals(req.headers.has("client_id"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); diff --git a/tests/base_client_helper_test.ts b/tests/base_client_helper_test.ts index 302cf11..0ef40ad 100644 --- a/tests/base_client_helper_test.ts +++ b/tests/base_client_helper_test.ts @@ -1,44 +1,11 @@ import { buildRequest, createSDKUserAgent, - fetchJSON, getHeaders, + getUserAgent, } from "../src/base_client_helper.ts"; import { ClientConfig, RequestData } from "../src/types.ts"; -import { assert, assertEquals, mf } from "./test_deps.ts"; - -Deno.test("fetchJSON - Returns successful response", async () => { - mf.install(); // mock out calls to `fetch` - - mf.mock("GET@/api", () => { - return new Response(JSON.stringify({ message: "Success" }), { - status: 200, - statusText: "OK", - }); - }); - - const request = new Request("https://example.com/api"); - - const result = await fetchJSON(request); - assertEquals(result, { ok: true, data: { message: "Success" } }); -}); - -Deno.test("fetchJSON - Returns parseError on Bad Request", async () => { - mf.install(); // mock out calls to `fetch` - - mf.mock("GET@/api", () => { - return new Response(JSON.stringify({ error: "Bad Request" }), { - status: 400, - statusText: "Bad Request", - }); - }); - - const request = new Request("https://example.com/api"); - // deno-lint-ignore no-explicit-any - const result = await fetchJSON(request) as any; - assertEquals(result.ok, false); - assert(result.message !== undefined); -}); +import { assert, assertEquals } from "./test_deps.ts"; Deno.test("buildRequest - Should return a Request object with the correct properties", () => { const cfg: ClientConfig = { @@ -148,6 +115,12 @@ Deno.test("getHeaders - Should omit headers", () => { assert(expectedHeaders["Merchant-Serial-Number"] === undefined); }); +Deno.test("getUserAgent - Should return the correct user agent", () => { + import.meta.url = "https://deno.land/x/vipps_mobilepay_sdk@1.0.0/mod.ts"; + const userAgent = getUserAgent(); + assert(userAgent !== "Vipps/Deno SDK/npm-require"); +}); + Deno.test("createUserAgent - Should return the correct user agent string when loaded from deno.land/x", () => { const expectedUserAgent = "Vipps/Deno SDK/1.0.0"; const actualUserAgent = createSDKUserAgent( @@ -166,9 +139,11 @@ Deno.test("createUserAgent - Should return the correct user agent string when lo assertEquals(actualUserAgent, expectedUserAgent); }); -Deno.test("createUserAgent - Should return the correct user agent string with unknown", () => { +Deno.test("createSDKUserAgent - Should return the correct user agent when loaded from an unknown source", () => { + const metaUrl = "https://example.com/some/other/path/mod.ts"; const expectedUserAgent = "Vipps/Deno SDK/unknown"; - const actualUserAgent = createSDKUserAgent("https://example.com/"); - assertEquals(actualUserAgent, expectedUserAgent); + const userAgent = createSDKUserAgent(metaUrl); + + assertEquals(userAgent, expectedUserAgent); }); diff --git a/tests/base_client_test.ts b/tests/base_client_test.ts index aa97559..03fa528 100644 --- a/tests/base_client_test.ts +++ b/tests/base_client_test.ts @@ -1,6 +1,7 @@ import { baseClient } from "../src/base_client.ts"; import { assertEquals, mf } from "./test_deps.ts"; import { RequestData } from "../src/types.ts"; +import { RetryError } from "../src/deps.ts"; Deno.test("makeRequest - Should exist", () => { const cfg = { merchantSerialNumber: "", subscriptionKey: "" }; @@ -16,7 +17,7 @@ Deno.test("makeRequest - Should return ok", async () => { mf.mock("GET@/foo", (req: Request) => { assertEquals(req.url, "https://api.vipps.no/foo"); assertEquals(req.method, "GET"); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -85,11 +86,51 @@ Deno.test("makeRequest - Should return ok after 2 retries", async () => { mf.mock("GET@/foo", () => { count++; if (count < 3) { - return new Response(JSON.stringify({ ok: false, error: "Bad Request" }), { - status: 400, - }); + return new Response( + JSON.stringify({ ok: false, error: "Internal Server Error" }), + { + status: 500, + }, + ); } - return new Response(JSON.stringify({ ok: true, data: {} }), { + + return new Response(JSON.stringify({}), { + status: 200, + }); + }); + + const cfg = { + merchantSerialNumber: "", + subscriptionKey: "", + retryRequests: true, + }; + const requestData: RequestData = { + method: "GET", + url: "/foo", + }; + + const client = baseClient(cfg); + + const response = await client.makeRequest(requestData); + assertEquals(response.ok, true); + + mf.reset(); +}); + +Deno.test("makeRequest - Should not return ok after 4 retries", async () => { + mf.install(); // mock out calls to `fetch` + let count = 0; + mf.mock("GET@/foo", () => { + count++; + if (count < 5) { + return new Response( + JSON.stringify({ ok: false, error: "Internal Server Error" }), + { + status: 500, + }, + ); + } + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -106,12 +147,31 @@ Deno.test("makeRequest - Should return ok after 2 retries", async () => { const client = baseClient(cfg); - const firstResponse = await client.makeRequest(requestData); - assertEquals(firstResponse.ok, false); + const response = await client.makeRequest(requestData); + assertEquals(response.ok, false); + + mf.reset(); +}); + +Deno.test("makeRequest - Should catch Retry Errors", async () => { + mf.install(); // mock out calls to `fetch` + mf.mock("GET@/foo", () => { + throw new RetryError({ foo: "bar" }, 3); + }); + + const cfg = { + merchantSerialNumber: "", + subscriptionKey: "", + retryRequests: true, + }; + const requestData: RequestData = { + method: "GET", + url: "/foo", + }; - const secondResponse = await client.makeRequest(requestData); - assertEquals(secondResponse.ok, false); + const client = baseClient(cfg); - const thirdResponse = await client.makeRequest(requestData); - assertEquals(thirdResponse.ok, true); + const response = await client.makeRequest(requestData); + assertEquals(response.ok, false); + mf.reset(); }); diff --git a/tests/epayment_test.ts b/tests/epayment_test.ts index 2ed202c..87908d0 100644 --- a/tests/epayment_test.ts +++ b/tests/epayment_test.ts @@ -9,7 +9,7 @@ Deno.test("ePayment - create - Should have correct url and header", async () => assertEquals(req.url, "https://apitest.vipps.no/epayment/v1/payments"); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -72,7 +72,7 @@ Deno.test("ePayment - info - Should have correct url and header", async () => { assertEquals(req.url, "https://apitest.vipps.no/epayment/v1/payments/foo"); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -99,7 +99,7 @@ Deno.test("ePayment - history - Should have correct url and header", async () => ); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -126,7 +126,7 @@ Deno.test("ePayment - cancel - Should have correct url and header", async () => ); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -153,7 +153,7 @@ Deno.test("ePayment - capture - Should have correct url and header", async () => ); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -185,7 +185,7 @@ Deno.test("ePayment - refund - Should have correct url and header", async () => ); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); @@ -217,7 +217,7 @@ Deno.test("ePayment - forceApprove - Should have correct url and header", async ); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { + return new Response(JSON.stringify({}), { status: 200, }); }); diff --git a/tests/error_test.ts b/tests/error_test.ts index 471814c..8f3a366 100644 --- a/tests/error_test.ts +++ b/tests/error_test.ts @@ -1,19 +1,8 @@ import { AccessTokenError } from "../src/apis/types/auth_types.ts"; -import { EPaymentErrorResponse } from "../src/apis/types/epayment_types.ts"; -import { QrErrorResponse } from "../src/apis/types/qr_types.ts"; import { RetryError } from "../src/deps.ts"; -import { parseError } from "../src/errors.ts"; -import { assertEquals } from "./test_deps.ts"; - -Deno.test("parseError - Should return correct error message for RetryError", () => { - const error = new RetryError("foo", 3); - const result = parseError(error); - assertEquals(result.ok, false); - assertEquals( - result.message, - "Could not get a response from the server after multiple attempts", - ); -}); +import { isRetryError, parseError, parseRetryError } from "../src/errors.ts"; +import { Client, RecurringErrorFromAzure } from "../src/mod.ts"; +import { assertEquals, mf } from "./test_deps.ts"; Deno.test("parseError - Should return correct error message for connection error", () => { const error = new TypeError("error trying to connect"); @@ -29,14 +18,24 @@ Deno.test("parseError - Should return correct error message for generic Error", assertEquals(result.message, `${error.name} - ${error.message}`); }); -Deno.test("parseError should return correct error message for forbidden Error", () => { - const error = new Error("Forbidden"); - const result = parseError(error); +Deno.test("parseError - should return correct error message for forbidden Error", async () => { + mf.install(); + mf.mock("GET@/epayment/v1/payments/", () => { + return new Response(JSON.stringify({ ok: false, data: {} }), { + status: 403, + }); + }); + + const client = Client({ + merchantSerialNumber: "", + subscriptionKey: "", + useTestMode: true, + retryRequests: false, + }); + + const result = await client.payment.info("testtoken", "123456789"); assertEquals(result.ok, false); - assertEquals( - result.message, - "Your credentials are not authorized for this product, please visit portal.vipps.no", - ); + mf.reset(); }); Deno.test("parseError - Should return correct error message for AccessTokenError", () => { @@ -55,56 +54,48 @@ Deno.test("parseError - Should return correct error message for AccessTokenError assertEquals(result.error, error); }); -Deno.test("parseError - Should return correct error message for Problem JSON", () => { - const error: EPaymentErrorResponse = { - type: "https://example.com/error", - title: "Some problem", - status: 400, - extraDetails: [{ - name: "Some name", - reason: "Some reason", - }], - instance: "https://example.com/instance", - traceId: "123456789", - detail: "Some detail", - }; +Deno.test("parseError should return correct error message for unknown error", () => { + const error = "Unknown error"; const result = parseError(error); + assertEquals(result.ok, false); - assertEquals(result.message, `${error.status} - ${error.title}`); - assertEquals(result.error, error); + assertEquals(result.message, "Unknown error"); }); -Deno.test("parseError - Should return detail as error message for QRErrorJSON", () => { - const error = { - title: "Invalid QR code", - detail: "The QR code is expired", - instance: "https://example.com/qr", +Deno.test("parseError - Should return correct error message for Recurring Azure Error", () => { + const error: RecurringErrorFromAzure = { + responseInfo: { + responseCode: 123, + responseMessage: "Response message", + }, + result: { + message: "Result message", + }, }; const result = parseError(error); assertEquals(result.ok, false); - assertEquals(result.message, "The QR code is expired"); + assertEquals(result.message, "Result message"); }); -Deno.test("parseError - Should return reason as error message for QRErrorJSON", () => { - const error: QrErrorResponse = { - title: "Invalid QR code", - detail: "The QR code is expired", - instance: "https://example.com/qr", - invalidParams: [ - { - name: "Some name", - reason: "Some reason", - }, - ], - }; - const result = parseError(error); - assertEquals(result.ok, false); - assertEquals(result.message, "Some reason"); +Deno.test("isRetryError - Should return true when input is an instance of RetryError", () => { + const input = new RetryError({ foo: "bar" }, 3); + const result = isRetryError(input); + assertEquals(result, true); }); -Deno.test("parseError should return correct error message for unknown error", () => { - const error = "Unknown error"; - const result = parseError(error); - assertEquals(result.ok, false); - assertEquals(result.message, String(error)); +Deno.test("isRetryError - Should return false when input is not an instance of RetryError", () => { + const input = "foo"; + const result = isRetryError(input); + assertEquals(result, false); +}); + +Deno.test("parseRetryError - Should return an SDKError object with ok set to false and a specific message", () => { + const expectedError = { + ok: false, + message: "Retry limit reached. Could not get a response from the server", + }; + + const actualError = parseRetryError(); + + assertEquals(actualError, expectedError); }); diff --git a/tests/fetch_test.ts b/tests/fetch_test.ts new file mode 100644 index 0000000..68e680d --- /dev/null +++ b/tests/fetch_test.ts @@ -0,0 +1,134 @@ +import { fetchJSON } from "../src/fetch.ts"; +import { assert, assertEquals, mf } from "./test_deps.ts"; + +Deno.test("fetchJSON - Returns successful response", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(JSON.stringify({ message: "Success" }), { + status: 200, + statusText: "OK", + }); + }); + + const request = new Request("https://example.com/api"); + + const result = await fetchJSON(request); + assertEquals(result, { ok: true, data: { message: "Success" } }); + mf.reset(); +}); + +Deno.test("fetchJSON - Returns parseError on Bad Request", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(JSON.stringify({ error: "Bad Request" }), { + status: 400, + statusText: "Bad Request", + }); + }); + + const request = new Request("https://example.com/api"); + // deno-lint-ignore no-explicit-any + const result = await fetchJSON(request) as any; + assertEquals(result.ok, false); + assert(result.message !== undefined); + mf.reset(); +}); + +Deno.test("fetchJSON - Returns parseError on Forbidden", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + statusText: "Forbidden", + }); + }); + + const request = new Request("https://example.com/api"); + // deno-lint-ignore no-explicit-any + const result = await fetchJSON(request) as any; + assertEquals(result.ok, false); + assert(result.message !== undefined); + mf.reset(); +}); + +Deno.test("fetchJSON - Returns parseError on Internal Server Error", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(JSON.stringify({ error: "Internal Server Error" }), { + status: 500, + statusText: "Internal Server Error", + }); + }); + + const request = new Request("https://example.com/api"); + + try { + await fetchJSON(request); + } catch (error) { + assert(error instanceof Error); + } + + mf.reset(); +}); + +Deno.test("fetchJSON - Catch JSON", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(JSON.stringify({}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const request = new Request("https://example.com/api"); + // deno-lint-ignore no-explicit-any + const result = await fetchJSON(request) as any; + + assertEquals(result.ok, true); + mf.reset(); +}); + +Deno.test("fetchJSON - Catch Empty Response", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response(undefined, { + status: 204, + }); + }); + + const request = new Request("https://example.com/api"); + // deno-lint-ignore no-explicit-any + const result = await fetchJSON(request) as any; + + assertEquals(result.ok, true); + assertEquals(result.data, {}); + mf.reset(); +}); + +Deno.test("fetchJSON - Catch Problem JSON with detail", async () => { + mf.install(); // mock out calls to `fetch` + + mf.mock("GET@/api", () => { + return new Response( + JSON.stringify({ + type: "https://example.com/error", + detail: "Some detail", + }), + { headers: { "content-type": "application/problem+json" }, status: 400 }, + ); + }); + + const request = new Request("https://example.com/api"); + // deno-lint-ignore no-explicit-any + const result = await fetchJSON(request) as any; + + assertEquals(result.ok, false); + assertEquals(result.message, "Some detail"); + mf.reset(); +}); diff --git a/tests/webhooks_test.ts b/tests/webhooks_test.ts index 3c00b7c..302f95c 100644 --- a/tests/webhooks_test.ts +++ b/tests/webhooks_test.ts @@ -1,5 +1,6 @@ import { assertEquals, mf } from "./test_deps.ts"; import { Client } from "../src/mod.ts"; +import { webhooksRequestFactory } from "https://deno.land/x/vipps_mobilepay_sdk@0.7.0/apis/webhooks.ts"; Deno.test("webhooks - registerWebhook - check correct url in TEST/MT", async () => { mf.install(); @@ -8,8 +9,8 @@ Deno.test("webhooks - registerWebhook - check correct url in TEST/MT", async () assertEquals(req.url, "https://apitest.vipps.no/webhooks/v1/webhooks"); assertEquals(req.headers.has("Idempotency-Key"), true); - return new Response(JSON.stringify({ ok: true, data: {} }), { - status: 200, + return new Response(JSON.stringify({}), { + status: 201, }); }); @@ -63,9 +64,10 @@ Deno.test("webhooks - registerWebhook - bad request", async () => { Deno.test("webhooks - delete webhook - OK", async () => { mf.install(); + // Testing that 204 responses are handled correctly mf.mock("DELETE@/webhooks/v1/webhooks/:webhookId", () => { return new Response(null, { - status: 200, + status: 204, }); }); @@ -86,25 +88,11 @@ Deno.test("webhooks - delete webhook - OK", async () => { mf.reset(); }); -Deno.test("webhooks - list webhooks - OK", async () => { - mf.install(); - - mf.mock("GET@/webhooks/v1/webhooks", () => { - return new Response(null, { - status: 200, - }); - }); - - const client = Client({ - merchantSerialNumber: "", - subscriptionKey: "", - useTestMode: true, - retryRequests: false, - }); - - const listResponse = await client.webhook.list("testtoken"); +Deno.test("webhooks - list webhooks - OK", () => { + const token = "your-auth-token"; - assertEquals(listResponse.ok, true); + const requestData = webhooksRequestFactory.list(token); - mf.reset(); + assertEquals(requestData.url, "/webhooks/v1/webhooks"); + assertEquals(requestData.method, "GET"); });