diff --git a/src/__tests__/lib/api/resources/ContactExports.test.ts b/src/__tests__/lib/api/resources/ContactExports.test.ts index 140b8c3..9109284 100644 --- a/src/__tests__/lib/api/resources/ContactExports.test.ts +++ b/src/__tests__/lib/api/resources/ContactExports.test.ts @@ -111,8 +111,8 @@ describe("lib/api/resources/ContactExports: ", () => { } catch (error) { expect(error).toBeInstanceOf(MailtrapError); if (error instanceof MailtrapError) { - // axios logger returns "[object Object]" for error objects, so we check for that - expect(error.message).toBe("[object Object]"); + // When errors object doesn't match recognized pattern, falls back to default Axios error message + expect(error.message).toBe("Request failed with status code 422"); } } }); diff --git a/src/__tests__/lib/api/resources/ContactImports.test.ts b/src/__tests__/lib/api/resources/ContactImports.test.ts index 82a13ca..3980ae0 100644 --- a/src/__tests__/lib/api/resources/ContactImports.test.ts +++ b/src/__tests__/lib/api/resources/ContactImports.test.ts @@ -198,11 +198,9 @@ describe("lib/api/resources/ContactImports: ", () => { } catch (error) { expect(error).toBeInstanceOf(MailtrapError); if (error instanceof MailtrapError) { - // Note: Current axios-logger doesn't properly handle array of objects format, - // so it falls back to stringifying the array, resulting in [object Object],[object Object] - // This test documents the current behavior. Updating axios-logger to properly - // parse this format will be a separate task. - expect(error.message).toBe("[object Object],[object Object]"); + expect(error.message).toBe( + "invalid-email-1: email: is invalid, is required | invalid-email-2: Contact limit exceeded" + ); } } }); diff --git a/src/__tests__/lib/axios-logger.test.ts b/src/__tests__/lib/axios-logger.test.ts index 4e09838..4037de2 100644 --- a/src/__tests__/lib/axios-logger.test.ts +++ b/src/__tests__/lib/axios-logger.test.ts @@ -49,5 +49,660 @@ describe("lib/axios-logger: ", () => { } } }); + + it("extracts single error string from response data", () => { + const responseData = { error: "Not Found" }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "404", + { headers: {} } as any, + { + data: responseData, + status: 404, + } + ); + axiosError.response = { + data: responseData, + status: 404, + statusText: "Not Found", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("Not Found"); + } + } + }); + + it("extracts array of error strings from response data", () => { + const responseData = { + success: false, + errors: [ + "'subject' is required", + "must specify either text or html body", + ], + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "400", + { headers: {} } as any, + { + data: responseData, + status: 400, + } + ); + axiosError.response = { + data: responseData, + status: 400, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe( + "'subject' is required,must specify either text or html body" + ); + } + } + }); + + it("extracts errors from array of error objects with nested errors", () => { + const responseData = { + errors: [ + { + email: "invalid-email-1", + errors: { + email: ["is invalid", "is required"], + }, + }, + { + email: "invalid-email-2", + errors: { + base: ["Contact limit exceeded"], + }, + }, + ], + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe( + "invalid-email-1: email: is invalid, is required | invalid-email-2: Contact limit exceeded" + ); + } + } + }); + + it("extracts errors from array of error objects with direct base field", () => { + const responseData = { + errors: [ + { + base: ["contacts are required"], + }, + ], + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("contacts are required"); + } + } + }); + + it("extracts errors from object with name property", () => { + const responseData = { + errors: { + name: ["can't be blank", "is too short"], + }, + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("can't be blank, is too short"); + } + } + }); + + it("extracts errors from object with base property", () => { + const responseData = { + errors: { + base: ["Something went wrong"], + }, + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("Something went wrong"); + } + } + }); + + it("extracts errors from object with field-specific errors", () => { + const responseData = { + errors: { + email: ["is invalid", "top level domain is too short"], + }, + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe( + "email: is invalid, top level domain is too short" + ); + } + } + }); + + it("handles error object with multiple field errors", () => { + const responseData = { + errors: { + email: ["is invalid"], + phone: ["can't be blank"], + }, + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(3); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toContain("email: is invalid"); + expect(error.message).toContain("phone: can't be blank"); + } + } + }); + + it("falls back to default message when response data is invalid", () => { + const responseData = null; + // @ts-ignore + const axiosError = new AxiosError( + "Network error", + "500", + { headers: {} } as any, + { + data: responseData, + status: 500, + } + ); + axiosError.response = { + data: responseData, + status: 500, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("Network error"); + } + } + }); + + it("preserves cause property with original axios error", () => { + const responseData = { error: "Not Found" }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "404", + { headers: {} } as any, + { + data: responseData, + status: 404, + } + ); + axiosError.response = { + data: responseData, + status: 404, + statusText: "Not Found", + headers: {}, + config: {} as any, + }; + + expect.assertions(3); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause).toBe(axiosError); + expect(error.message).toBe("Not Found"); + } + } + }); + + it("returns identifier when error object has no messages but has identifier", () => { + const responseData = { + errors: [ + { + email: "test@example.com", + // No errors property, just identifier + }, + ], + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("test@example.com"); + } + } + }); + + it("preserves plain-text error responses", () => { + const responseData = "Plain text error message"; + // @ts-ignore + const axiosError = new AxiosError( + "Network error", + "500", + { headers: {} } as any, + { + data: responseData, + status: 500, + } + ); + axiosError.response = { + data: responseData, + status: 500, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // Plain-text responses should be preserved + expect(error.message).toBe("Plain text error message"); + } + } + }); + + it("converts non-object, non-string data to string", () => { + const responseData = 404; + // @ts-ignore + const axiosError = new AxiosError( + "Network error", + "500", + { headers: {} } as any, + { + data: responseData, + status: 500, + } + ); + axiosError.response = { + data: responseData, + status: 500, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // Non-object types should be converted to string + expect(error.message).toBe("404"); + } + } + }); + + it("returns default message when data is null", () => { + const responseData = null; + // @ts-ignore + const axiosError = new AxiosError( + "Network error", + "500", + { headers: {} } as any, + { + data: responseData, + status: 500, + } + ); + axiosError.response = { + data: responseData, + status: 500, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("Network error"); + } + } + }); + + it("returns default message when response data has no error or errors", () => { + const responseData = { success: true }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "200", + { headers: {} } as any, + { + data: responseData, + status: 200, + } + ); + axiosError.response = { + data: responseData, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("Request failed"); + } + } + }); + + it("handles error object with no identifier and no messages", () => { + const responseData = { + errors: [ + { + // No email, no id, no errors property, and someOtherField is not an array + someOtherField: "value", + }, + ], + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // When all error objects return null, extractMessagesFromErrorObjects returns empty string "" + // Empty string is falsy, so if (extracted) fails and falls through to default message + expect(error.message).toBe("Request failed"); + } + } + }); + + it("handles errors object that doesn't match any pattern", () => { + const responseData = { + errors: { + // Not name/base, and not an array of field errors + someField: "not an array", + }, + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // Should fall back to default error message when errors shape is unrecognized + expect(error.message).toBe("Request failed"); + } + } + }); + + it("handles errors as non-array, non-object value", () => { + const responseData = { + errors: "string error", + }; + // @ts-ignore + const axiosError = new AxiosError( + "Request failed", + "422", + { headers: {} } as any, + { + data: responseData, + status: 422, + } + ); + axiosError.response = { + data: responseData, + status: 422, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + expect.assertions(2); + + try { + axiosLogger(axiosError); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toBe("string error"); + } + } + }); }); }); diff --git a/src/lib/axios-logger.ts b/src/lib/axios-logger.ts index 478f048..7fad97b 100644 --- a/src/lib/axios-logger.ts +++ b/src/lib/axios-logger.ts @@ -14,52 +14,204 @@ const hasErrorProperty = ( return (obj as AxiosErrorObject)?.[propertyName] !== undefined; }; +/** + * Formats a single field error message. + */ +function formatFieldError(field: string, fieldErrors: unknown[]): string { + const messages = fieldErrors.map((err) => String(err)).join(", "); + + return field === "base" ? messages : `${field}: ${messages}`; +} + +/** + * Extracts and formats field errors from an error object. + */ +function extractFieldErrors(errorsObj: Record): string[] { + return Object.entries(errorsObj) + .filter(([, fieldErrors]) => Array.isArray(fieldErrors)) + .map(([field, fieldErrors]) => + formatFieldError(field, fieldErrors as unknown[]) + ) + .filter((msg) => msg.length > 0); +} + +/** + * Gets identifier (email or id) from an error object. + */ +function getErrorIdentifier(errorObj: Record): string { + return (errorObj.email || errorObj.id || "") as string; +} + +/** + * Extracts errors from nested "errors" property. + */ +function extractNestedErrors( + errorObj: Record +): string[] | null { + if ( + errorObj.errors && + typeof errorObj.errors === "object" && + !Array.isArray(errorObj.errors) + ) { + return extractFieldErrors(errorObj.errors as Record); + } + + return null; +} + +/** + * Extracts errors directly from object properties (excluding identifiers). + */ +function extractDirectErrors(errorObj: Record): string[] { + const directErrors = Object.entries(errorObj) + .filter( + ([field]) => field !== "email" && field !== "id" && field !== "errors" + ) + .reduce((acc, [field, value]) => { + acc[field] = value; + return acc; + }, {} as Record); + + return extractFieldErrors(directErrors); +} + +/** + * Formats a single error object into a message string. + */ +function formatErrorMessage(errorObj: Record): string | null { + const identifier = getErrorIdentifier(errorObj); + const itemMessages = + extractNestedErrors(errorObj) || extractDirectErrors(errorObj); + + if (itemMessages.length > 0) { + const formattedMessage = itemMessages.join("; "); + return identifier ? `${identifier}: ${formattedMessage}` : formattedMessage; + } + + if (identifier) { + return String(identifier); + } + + return null; +} + +/** + * Extracts error messages from an array of error objects. + * Each object may have nested errors with field-specific messages. + */ +function extractMessagesFromErrorObjects( + errorObjects: Array> +): string { + const messages = errorObjects + .map(formatErrorMessage) + .filter((msg): msg is string => msg !== null); + + return messages.join(" | "); +} + +/** + * Extracts error message from server response data. + */ +function extractErrorMessage(data: unknown, defaultMessage: string): string { + // Preserve plain-text error responses + if (typeof data === "string") { + return data; + } + + // Convert other non-object types to string + if (data && typeof data !== "object") { + return String(data); + } + + // Fall back to default message for null/undefined or non-objects + if (data === null || data === undefined || typeof data !== "object") { + return defaultMessage; + } + + // error is in `data.error` + if ("error" in data && data.error) { + return String(data.error); + } + + // errors are in `data.errors` + if ("errors" in data && data.errors) { + const { errors } = data; + + // errors is a string + if (typeof errors === "string") { + return errors; + } + + // errors is an array of strings + if (Array.isArray(errors) && errors.length > 0) { + if (typeof errors[0] === "string") { + return errors.join(","); + } + + // errors is an array of objects + if (typeof errors[0] === "object" && errors[0] !== null) { + const extracted = extractMessagesFromErrorObjects( + errors as Array> + ); + if (extracted) { + return extracted; + } + } + } + + // errors is an object (could have name/base or field-specific errors) + if ( + typeof errors === "object" && + !Array.isArray(errors) && + errors !== null + ) { + // check for name/base properties first (legacy format) + const errorNames = + hasErrorProperty(errors, "name") && errors.name.join(", "); + const errorBase = + hasErrorProperty(errors, "base") && errors.base.join(", "); + + if (errorNames) return errorNames; + if (errorBase) return errorBase; + + // extract field-specific errors (e.g., { "email": ["is invalid", ...] }) + const fieldMessages = extractFieldErrors( + errors as Record + ); + + if (fieldMessages.length > 0) { + return fieldMessages.join("; "); + } + + // If errors object doesn't match any recognized pattern, fall back to default message + return defaultMessage; + } + + // If errors doesn't match any recognized format, fall back to default message + return defaultMessage; + } + + return defaultMessage; +} + +/** + * Extracts error message from axios error. + * Context information (status code, URL, etc.) is available in error.cause. + */ +function buildErrorMessage(error: AxiosError): string { + const primaryMessage = error.response?.data + ? extractErrorMessage(error.response.data, error.message) + : error.message; + + return primaryMessage; +} + /** * Error handler for axios response. */ export default function handleSendingError(error: AxiosError | unknown) { if (axios.isAxiosError(error)) { - /** - * Handles case where error is in `data.errors`. - */ - const serverErrorsObject = - error.response?.data && - typeof error.response.data === "object" && - "errors" in error.response.data && - error.response.data.errors; - - /** - * Handles case where error is in `data.error`. - */ - const serverErrorObject = - error.response?.data && - typeof error.response.data === "object" && - "error" in error.response.data && - error.response.data.error; - - /** - * Joins error messages contained in `name` property. - */ - const errorNames = - hasErrorProperty(serverErrorsObject, "name") && - serverErrorsObject.name.join(", "); - - /** - * Joins error messages contained in `base` property. - */ - const errorBase = - hasErrorProperty(serverErrorsObject, "base") && - serverErrorsObject.base.join(", "); - - /** - * Selects available error. - */ - const message = - errorNames || - errorBase || - serverErrorsObject || - serverErrorObject || - error.message; + const message = buildErrorMessage(error); // @ts-expect-error weird typing around Error class, but it's tested to work throw new MailtrapError(message, { cause: error });