Relay transactional email through Salesforce instead of SMTP #9
@@ -49,7 +49,9 @@ export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipien
|
|||||||
try {
|
try {
|
||||||
const ics = await IcalService.convertToIcal([event]);
|
const ics = await IcalService.convertToIcal([event]);
|
||||||
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
|
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
|
// Non-fatal: the confirmation still goes out, just without the calendar file.
|
||||||
|
logger.warn('Confirmation email for event ' + recipient.eventId + ' sent without .ics attachment: ' + e?.message);
|
||||||
icsAttachment = undefined;
|
icsAttachment = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// salesforce.client caches the OAuth token at module scope, so every test
|
||||||
|
// resets the module registry for a clean cache and re-requires axios + the
|
||||||
|
// module under test after the reset (same approach as
|
||||||
|
// test/feedback/salesforce.service.test.ts).
|
||||||
|
|
||||||
|
export {}; // isolate module scope from other script-style test files
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
const freshImports = () => {
|
||||||
|
const axios = require('axios');
|
||||||
|
const {salesforceApexRestPost, salesforceEnabled} = require('../../src/common/salesforce.client');
|
||||||
|
return {axios, salesforceApexRestPost, salesforceEnabled};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = {...process.env};
|
||||||
|
const ENABLED_ENV = {
|
||||||
|
...ORIGINAL_ENV,
|
||||||
|
SALESFORCE_ENABLED: 'true',
|
||||||
|
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
|
||||||
|
SALESFORCE_CLIENT_ID: 'client-id',
|
||||||
|
SALESFORCE_CLIENT_SECRET: 'client-secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
process.env = {...ENABLED_ENV};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = {...ORIGINAL_ENV};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('salesforceEnabled', () => {
|
||||||
|
it('is true only when SALESFORCE_ENABLED === "true"', () => {
|
||||||
|
process.env.SALESFORCE_ENABLED = 'true';
|
||||||
|
expect(freshImports().salesforceEnabled()).toBe(true);
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
process.env.SALESFORCE_ENABLED = 'false';
|
||||||
|
expect(freshImports().salesforceEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('salesforceApexRestPost', () => {
|
||||||
|
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
|
||||||
|
const {axios, salesforceApexRestPost} = freshImports();
|
||||||
|
axios.post.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
|
return Promise.resolve({data: {ok: true}});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await salesforceApexRestPost('/services/apexrest/email/send', {to: 'x@example.com'});
|
||||||
|
|
||||||
|
expect(result).toEqual({ok: true});
|
||||||
|
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/email/send',
|
||||||
|
{to: 'x@example.com'},
|
||||||
|
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the cached token across calls instead of fetching twice', async () => {
|
||||||
|
const {axios, salesforceApexRestPost} = freshImports();
|
||||||
|
axios.post.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
|
return Promise.resolve({data: {}});
|
||||||
|
});
|
||||||
|
|
||||||
|
await salesforceApexRestPost('/services/apexrest/email/send', {});
|
||||||
|
await salesforceApexRestPost('/services/apexrest/email/send', {});
|
||||||
|
|
||||||
|
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, salesforceApexRestPost} = freshImports();
|
||||||
|
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 (tokenFetches === 1) {
|
||||||
|
const err: any = new Error('Unauthorized');
|
||||||
|
err.response = {status: 401};
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
return Promise.resolve({data: {ok: true}});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await salesforceApexRestPost('/services/apexrest/email/send', {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ok: true});
|
||||||
|
expect(tokenFetches).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry on a non-401 error and rethrows it', async () => {
|
||||||
|
const {axios, salesforceApexRestPost} = freshImports();
|
||||||
|
axios.post.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
|
const err: any = new Error('Server error');
|
||||||
|
err.response = {status: 500};
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('Server error');
|
||||||
|
const endpointCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/email/send'));
|
||||||
|
expect(endpointCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a clear error when client credentials are not configured', async () => {
|
||||||
|
process.env.SALESFORCE_CLIENT_ID = '';
|
||||||
|
const {salesforceApexRestPost} = freshImports();
|
||||||
|
|
||||||
|
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,12 +21,14 @@ jest.mock('../../src/middleware/logger', () => ({
|
|||||||
import * as EventsService from '../../src/models/calendar/events/events.service';
|
import * as EventsService from '../../src/models/calendar/events/events.service';
|
||||||
import * as IcalService from '../../src/models/calendar/events/icalgenerator.service';
|
import * as IcalService from '../../src/models/calendar/events/icalgenerator.service';
|
||||||
import {MailService} from '../../src/common/common.mail';
|
import {MailService} from '../../src/common/common.mail';
|
||||||
|
import logger from '../../src/middleware/logger';
|
||||||
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db';
|
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db';
|
||||||
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email';
|
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email';
|
||||||
|
|
||||||
const mockGetEvent = EventsService.getEventById as jest.Mock;
|
const mockGetEvent = EventsService.getEventById as jest.Mock;
|
||||||
const mockToIcal = IcalService.convertToIcal as jest.Mock;
|
const mockToIcal = IcalService.convertToIcal as jest.Mock;
|
||||||
const mockSendMail = MailService.sendMail as jest.Mock;
|
const mockSendMail = MailService.sendMail as jest.Mock;
|
||||||
|
const mockLogger = logger as unknown as {info: jest.Mock; warn: jest.Mock; error: jest.Mock};
|
||||||
const mockGetConnection = NachklangTicketsDB.getConnection as jest.Mock;
|
const mockGetConnection = NachklangTicketsDB.getConnection as jest.Mock;
|
||||||
|
|
||||||
const EVENT = {
|
const EVENT = {
|
||||||
@@ -68,13 +70,14 @@ describe('sendRedemptionConfirmation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still sends (without an attachment) when the .ics build fails', async () => {
|
it('still sends (without an attachment) and warns when the .ics build fails', async () => {
|
||||||
mockToIcal.mockRejectedValue(new Error('ical boom'));
|
mockToIcal.mockRejectedValue(new Error('ical boom'));
|
||||||
|
|
||||||
await sendRedemptionConfirmation(RECIPIENT);
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
const options = mockSendMail.mock.calls[0][3];
|
const options = mockSendMail.mock.calls[0][3];
|
||||||
expect(options.attachments).toBeUndefined();
|
expect(options.attachments).toBeUndefined();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('without .ics attachment'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false and does not send when the event no longer exists', async () => {
|
it('returns false and does not send when the event no longer exists', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user