From 080b987914befc753e31d3bed471048848c4fc02 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Sat, 15 Aug 2026 17:45:06 +0200 Subject: [PATCH] Add unit tests for the Salesforce integration Covers integrations/salesforce.service.ts: disabled-mode logging-only path, signup-not-found, success (token fetch + POST + mark SENT), token reuse across calls, retry-once-on-401, failure marks FAILED with the error message, and a missing-client-credentials configuration error. 100% statement coverage on the file. Co-Authored-By: Claude Sonnet 5 --- test/feedback/salesforce.service.test.ts | 198 +++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 test/feedback/salesforce.service.test.ts diff --git a/test/feedback/salesforce.service.test.ts b/test/feedback/salesforce.service.test.ts new file mode 100644 index 0000000..a604ff9 --- /dev/null +++ b/test/feedback/salesforce.service.test.ts @@ -0,0 +1,198 @@ +// The module under test caches its OAuth token at module scope (see +// salesforce.service.ts's `cachedToken`), so every test resets the module +// registry for a clean cache. That also invalidates any jest.mock() factory +// instance captured before the reset, so every mocked dependency (axios, +// Feedback.db, the logger) is re-required fresh after each reset rather +// than referenced from a top-level import. + +jest.mock('axios'); +jest.mock('../../src/models/feedback/Feedback.db', () => ({ + NachklangFeedbackDB: {getConnection: jest.fn()} +})); +jest.mock('../../src/middleware/logger', () => ({ + __esModule: true, + default: {info: jest.fn(), error: jest.fn()} +})); + +const SIGNUP_ROW = { + signup_id: 7, + first_name: 'Erika', + last_name: 'Mustermann', + email: 'erika@example.com', + event_name: 'Sommerkonzert 2026' +}; + +const makeConn = (rows: any[]) => ({ + query: jest.fn().mockResolvedValue(rows), + end: jest.fn().mockResolvedValue(undefined) +}); + +// Re-requires every mocked dependency fresh (see the note above) and +// returns the live references plus the service under test. +const freshImports = () => { + const axios = require('axios'); + const {NachklangFeedbackDB} = require('../../src/models/feedback/Feedback.db'); + const logger = require('../../src/middleware/logger').default; + const {syncNewsletterSignup} = require('../../src/models/feedback/integrations/salesforce.service'); + return {axios, mockGetConnection: NachklangFeedbackDB.getConnection as jest.Mock, logger, syncNewsletterSignup}; +}; + +const ORIGINAL_ENV = {...process.env}; + +describe('syncNewsletterSignup - disabled mode', () => { + beforeEach(() => { + jest.resetModules(); + process.env = {...ORIGINAL_ENV, SALESFORCE_ENABLED: 'false'}; + }); + + it('logs the payload it would send and does not touch the network or write to the DB', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + const conn = makeConn([SIGNUP_ROW]); + mockGetConnection.mockResolvedValue(conn); + + await syncNewsletterSignup(7); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('would have sent'), + expect.objectContaining({ + signupId: 7, + payload: {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'} + }) + ); + expect(axios.post).not.toHaveBeenCalled(); + // One read connection only - no UPDATE issued, since the row's + // sync_status is already 'SKIPPED' from the insert. + expect(mockGetConnection).toHaveBeenCalledTimes(1); + }); + + it('logs and returns without calling the network when the signup row does not exist', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + mockGetConnection.mockResolvedValue(makeConn([])); + + await syncNewsletterSignup(999); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'), {signupId: 999}); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); + +describe('syncNewsletterSignup - enabled mode', () => { + beforeEach(() => { + jest.resetModules(); + process.env = { + ...ORIGINAL_ENV, + SALESFORCE_ENABLED: 'true', + SALESFORCE_API_URL: 'https://example.my.salesforce.com', + SALESFORCE_CLIENT_ID: 'client-id', + SALESFORCE_CLIENT_SECRET: 'client-secret' + }; + }); + + it('fetches a token, posts the signup, and marks the row SENT with the returned record id', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + if (url.endsWith('/services/apexrest/newsletter/signup')) { + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}}); + } + throw new Error(`unexpected url ${url}`); + }); + + await syncNewsletterSignup(7); + + expect(axios.post).toHaveBeenCalledWith( + 'https://example.my.salesforce.com/services/oauth2/token', + expect.any(String), + expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}}) + ); + expect(axios.post).toHaveBeenCalledWith( + 'https://example.my.salesforce.com/services/apexrest/newsletter/signup', + {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'}, + expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}}) + ); + expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q1234', 7]); + }); + + it('reuses the cached token across two calls instead of fetching twice', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + mockGetConnection + .mockResolvedValueOnce(makeConn([SIGNUP_ROW])) + .mockResolvedValueOnce(makeConn([])) + .mockResolvedValueOnce(makeConn([SIGNUP_ROW])) + .mockResolvedValueOnce(makeConn([])); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}}); + }); + + await syncNewsletterSignup(7); + await syncNewsletterSignup(7); + + const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token')); + expect(tokenCalls).toHaveLength(1); + }); + + it('retries once with a fresh token on a 401, then succeeds', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + + let tokenFetches = 0; + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) { + tokenFetches += 1; + return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}}); + } + if (url.endsWith('/services/apexrest/newsletter/signup')) { + if (tokenFetches === 1) { + const err: any = new Error('Unauthorized'); + err.response = {status: 401, data: {message: 'Session expired'}}; + return Promise.reject(err); + } + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q9999', created: true}}); + } + throw new Error(`unexpected url ${url}`); + }); + + await syncNewsletterSignup(7); + + expect(tokenFetches).toBe(2); + expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q9999', 7]); + }); + + it('marks the row FAILED with the error message on a non-401 error, without throwing', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + const err: any = new Error('Internal error'); + err.response = {status: 500, data: {message: 'The newsletter signup could not be processed.'}}; + return Promise.reject(err); + }); + + await expect(syncNewsletterSignup(7)).resolves.toBeUndefined(); + + expect(updateConn.query).toHaveBeenCalledWith( + expect.stringContaining("sync_status = 'FAILED'"), + ['The newsletter signup could not be processed.', 7] + ); + expect(logger.error).toHaveBeenCalledWith('syncNewsletterSignup failed', expect.objectContaining({signupId: 7})); + }); + + it('marks the row FAILED with a clear message when client credentials are not configured', async () => { + process.env.SALESFORCE_CLIENT_ID = ''; + const {mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + + await syncNewsletterSignup(7); + + expect(updateConn.query).toHaveBeenCalledWith( + expect.stringContaining("sync_status = 'FAILED'"), + [expect.stringContaining('SALESFORCE_CLIENT_ID'), 7] + ); + }); +});