diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index 9fabe6cfc84..c9e2608f799 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -26,7 +26,7 @@ import { } from '../lib/newsletter-marketing-opt-in'; import { getErrorType, - getResponseErrorDescription, + getResponseFailureDetails, } from '../lib/newsletterSignupFailureDetails'; import { EVENT_DESCRIPTION_TO_ACTION, @@ -394,11 +394,10 @@ export const SecureSignup = ({ if (!response.ok) { trackingDetails.responseStatus = response.status; - const errorDescription = - await getResponseErrorDescription(response); - if (errorDescription) { - trackingDetails.errorDescription = errorDescription; - } + Object.assign( + trackingDetails, + await getResponseFailureDetails(response), + ); } sendTracking( diff --git a/dotcom-rendering/src/lib/newsletterSignupFailureDetails.test.ts b/dotcom-rendering/src/lib/newsletterSignupFailureDetails.test.ts index 3f95f264738..0a002ea213a 100644 --- a/dotcom-rendering/src/lib/newsletterSignupFailureDetails.test.ts +++ b/dotcom-rendering/src/lib/newsletterSignupFailureDetails.test.ts @@ -1,47 +1,93 @@ import { getErrorType, - getResponseErrorDescription, + getResponseFailureDetails, } from './newsletterSignupFailureDetails'; -const mockResponse = (text: string): Response => +type MockHeaders = Partial>; + +const mockHeaders = (headers: MockHeaders) => ({ + get: jest.fn().mockImplementation((headerName: string) => { + return headers[headerName.toLowerCase()] ?? null; + }), +}); + +const mockResponse = ( + text: string, + additionalHeaders: MockHeaders = {}, +): Response => ({ text: jest.fn().mockResolvedValue(text), + headers: mockHeaders(additionalHeaders), }) as unknown as Response; describe('newsletterSignupFailureDetails', () => { - describe('getResponseErrorDescription', () => { + describe('getResponseFailureDetails', () => { it('returns undefined for an empty response body', async () => { - const response = mockResponse(' '); + const response = mockResponse(' ', { + 'content-type': 'text/plain', + }); - await expect(getResponseErrorDescription(response)).resolves.toBe( - undefined, + await expect(getResponseFailureDetails(response)).resolves.toEqual( + {}, ); }); it('trims and collapses whitespace in the response body', async () => { - const response = mockResponse(' too\n\n many\t spaces '); + const response = mockResponse(' too\n\n many\t spaces ', { + 'content-type': 'text/plain', + }); - await expect(getResponseErrorDescription(response)).resolves.toBe( - 'too many spaces', - ); + await expect(getResponseFailureDetails(response)).resolves.toEqual({ + errorDescription: 'too many spaces', + }); + }); + + it('truncates the response body to 60 characters', async () => { + const longMessage = 'a'.repeat(70); + const response = mockResponse(longMessage, { + 'content-type': 'text/plain', + }); + + await expect(getResponseFailureDetails(response)).resolves.toEqual({ + errorDescription: 'a'.repeat(60), + }); }); - it('truncates the response body to 120 characters', async () => { - const longMessage = 'a'.repeat(130); - const response = mockResponse(longMessage); + it('returns the error code header when present', async () => { + const textMock = jest.fn().mockResolvedValue('ignored body'); + const response = { + text: textMock, + headers: mockHeaders({ + 'content-type': 'text/plain', + 'email-signup-error-code': 'already-subscribed', + }), + } as unknown as Response; + + await expect(getResponseFailureDetails(response)).resolves.toEqual({ + errorCode: 'already-subscribed', + }); + expect(textMock).not.toHaveBeenCalled(); + }); + + it('returns undefined for html error pages based on content type', async () => { + const response = mockResponse( + 'ErrorOops', + { 'content-type': 'text/html; charset=utf-8' }, + ); - await expect(getResponseErrorDescription(response)).resolves.toBe( - 'a'.repeat(120), + await expect(getResponseFailureDetails(response)).resolves.toEqual( + {}, ); }); it('returns undefined when reading the response body throws', async () => { const response = { text: jest.fn().mockRejectedValue(new Error('read failed')), + headers: mockHeaders({ 'content-type': 'text/plain' }), } as unknown as Response; - await expect(getResponseErrorDescription(response)).resolves.toBe( - undefined, + await expect(getResponseFailureDetails(response)).resolves.toEqual( + {}, ); }); }); diff --git a/dotcom-rendering/src/lib/newsletterSignupFailureDetails.ts b/dotcom-rendering/src/lib/newsletterSignupFailureDetails.ts index 401641ae1d5..0aaf46ae3a9 100644 --- a/dotcom-rendering/src/lib/newsletterSignupFailureDetails.ts +++ b/dotcom-rendering/src/lib/newsletterSignupFailureDetails.ts @@ -1,12 +1,42 @@ -export const getResponseErrorDescription = async ( +const MAX_FAILURE_DETAIL_LENGTH = 60; +const ERROR_CODE_HEADER = 'Email-Signup-Error-Code'; + +export type NewsletterSignupFailureDetails = { + errorCode?: string; + errorDescription?: string; +}; + +export const getResponseFailureDetails = async ( response: Response, -): Promise => { +): Promise => { try { + const errorCode = response.headers + .get(ERROR_CODE_HEADER) + ?.trim() + .substring(0, MAX_FAILURE_DETAIL_LENGTH); + + if (errorCode !== undefined && errorCode !== '') { + return { errorCode }; + } + + const contentType = response.headers.get('content-type')?.toLowerCase(); + + if (contentType?.includes('text/html') === true) { + return {}; + } + const responseText = (await response.text()).trim(); - if (!responseText) return; - return responseText.replace(/\s+/g, ' ').substring(0, 120); + if (!responseText) return {}; + + const normalisedResponseText = responseText.replace(/\s+/g, ' '); + return { + errorDescription: normalisedResponseText.substring( + 0, + MAX_FAILURE_DETAIL_LENGTH, + ), + }; } catch { - return; + return {}; } }; diff --git a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts index 54a868bd8e5..cc48cda4915 100644 --- a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts +++ b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts @@ -12,7 +12,7 @@ import { } from './newsletter-marketing-opt-in'; import { getErrorType, - getResponseErrorDescription, + getResponseFailureDetails, } from './newsletterSignupFailureDetails'; import { EVENT_DESCRIPTION_TO_ACTION, @@ -379,11 +379,10 @@ export const useNewsletterSignupForm = ( if (!response.ok) { trackingDetails.responseStatus = response.status; - const errorDescription = - await getResponseErrorDescription(response); - if (errorDescription) { - trackingDetails.errorDescription = errorDescription; - } + Object.assign( + trackingDetails, + await getResponseFailureDetails(response), + ); } sendTracking(