From b8efc26b7433848e3a3c37960a3521e93ee0f5cb Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 4 Sep 2026 08:38:57 +1000 Subject: [PATCH] fix: handle RecruitCRM edge errors --- __tests__/shared/services/recruitCRM.js | 109 ++++++++++++++++++++++++ src/shared/services/recruitCRM.js | 29 +++++-- 2 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 __tests__/shared/services/recruitCRM.js diff --git a/__tests__/shared/services/recruitCRM.js b/__tests__/shared/services/recruitCRM.js new file mode 100644 index 000000000..f46e63fe2 --- /dev/null +++ b/__tests__/shared/services/recruitCRM.js @@ -0,0 +1,109 @@ +/* eslint-env jest */ +import fetch from 'isomorphic-fetch'; +import { logger } from 'topcoder-react-lib'; +import Service from '../../../src/shared/services/recruitCRM'; + +jest.mock('isomorphic-fetch', () => jest.fn()); +jest.mock('topcoder-react-lib', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('RecruitCRM application service', () => { + const originalFormData = global.FormData; + const originalHeaders = global.Headers; + let formData; + + beforeEach(() => { + formData = { append: jest.fn() }; + global.FormData = jest.fn(() => formData); + global.Headers = jest.fn(headers => headers); + fetch.mockReset(); + logger.error.mockReset(); + }); + + afterAll(() => { + global.FormData = originalFormData; + global.Headers = originalHeaders; + }); + + it('returns the JSON application result for a successful proxy response', async () => { + const json = jest.fn().mockResolvedValue({ id: 'application-id' }); + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => 'application/json; charset=utf-8') }, + json, + }); + + const result = await new Service().applyForJob( + 'job-slug', + { resume: 'resume-file', first_name: 'Ada' }, + 'token-v3', + ); + + expect(result).toEqual({ id: 'application-id' }); + expect(formData.append).toHaveBeenNthCalledWith(1, 'resume', 'resume-file'); + expect(formData.append).toHaveBeenNthCalledWith(2, 'form', JSON.stringify({ first_name: 'Ada' })); + }); + + it('rejects an HTML edge error without trying to parse it as JSON', async () => { + const json = jest.fn(); + fetch.mockResolvedValue({ + ok: false, + status: 404, + headers: { get: jest.fn(() => 'text/html; charset=utf-8') }, + json, + }); + + await expect(new Service().applyForJob( + 'job-slug', + { resume: 'resume-file' }, + 'token-v3', + )).rejects.toMatchObject({ + message: "We couldn't submit your application. Please try again.", + status: 404, + }); + expect(json).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('rejects a successful response when its body is not JSON', async () => { + const json = jest.fn(); + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => 'text/html') }, + json, + }); + + await expect(new Service().applyForJob( + 'job-slug', + { resume: 'resume-file' }, + 'token-v3', + )).rejects.toMatchObject({ + message: "We couldn't submit your application. Please try again.", + status: 200, + }); + expect(json).not.toHaveBeenCalled(); + }); + + it('replaces JSON parser details with the safe application error', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => 'application/json') }, + json: jest.fn().mockRejectedValue(new SyntaxError("Unexpected token '<'")), + }); + + await expect(new Service().applyForJob( + 'job-slug', + { resume: 'resume-file' }, + 'token-v3', + )).rejects.toMatchObject({ + message: "We couldn't submit your application. Please try again.", + status: 200, + }); + }); +}); diff --git a/src/shared/services/recruitCRM.js b/src/shared/services/recruitCRM.js index ff54725a5..cdd0dd09c 100644 --- a/src/shared/services/recruitCRM.js +++ b/src/shared/services/recruitCRM.js @@ -72,10 +72,12 @@ export default class Service { } /** - * applyForJob for candidate - * @param {string} id The job id to apply to - * @param {object} payload The apply payload - * @param {string} tokenV3 User token + * Submits a candidate's application and optional resume through the RecruitCRM proxy. + * @param {string} id The job ID to apply to. + * @param {object} payload The normalized application payload. + * @param {string} tokenV3 The user's v3 authentication token. + * @returns {Promise} The parsed application response. + * @throws {Error} When the proxy rejects the request or returns a non-JSON or malformed response. */ async applyForJob(id, payload, tokenV3) { const { resume } = payload; @@ -90,11 +92,24 @@ export default class Service { }), credentials: 'omit', }); - if (!res.ok) { - const error = new Error('Failed to apply for job'); + const contentType = res.headers && res.headers.get + ? res.headers.get('content-type') : ''; + const isJson = typeof contentType === 'string' + && /^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(contentType.trim()); + if (!res.ok || !isJson) { + const error = new Error("We couldn't submit your application. Please try again."); + error.status = res.status; logger.error(error, res); + throw error; + } + try { + return await res.json(); + } catch (parseError) { + const error = new Error("We couldn't submit your application. Please try again."); + error.status = res.status; + logger.error(error, parseError); + throw error; } - return res.json(); } /**