// 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] ); }); });