Relay transactional email through Salesforce instead of SMTP (#9)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #9 Co-authored-by: Patrick Müller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Müller <patrick@mueller-patrick.tech>
This commit was merged in pull request #9.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// tickets.confirmation-email builds and sends the redemption confirmation
|
||||
// email, shared by the public redeem path and the admin resend action.
|
||||
|
||||
jest.mock('../../src/models/calendar/events/events.service', () => ({
|
||||
getEventById: jest.fn()
|
||||
}));
|
||||
jest.mock('../../src/models/calendar/events/icalgenerator.service', () => ({
|
||||
convertToIcal: jest.fn()
|
||||
}));
|
||||
jest.mock('../../src/common/common.mail', () => ({
|
||||
MailService: {sendMail: jest.fn()}
|
||||
}));
|
||||
jest.mock('../../src/models/tickets/Tickets.db', () => ({
|
||||
NachklangTicketsDB: {getConnection: jest.fn()}
|
||||
}));
|
||||
jest.mock('../../src/middleware/logger', () => ({
|
||||
__esModule: true,
|
||||
default: {info: jest.fn(), warn: jest.fn(), error: jest.fn()}
|
||||
}));
|
||||
|
||||
import * as EventsService from '../../src/models/calendar/events/events.service';
|
||||
import * as IcalService from '../../src/models/calendar/events/icalgenerator.service';
|
||||
import {MailService} from '../../src/common/common.mail';
|
||||
import logger from '../../src/middleware/logger';
|
||||
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db';
|
||||
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email';
|
||||
|
||||
const mockGetEvent = EventsService.getEventById as jest.Mock;
|
||||
const mockToIcal = IcalService.convertToIcal 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 EVENT = {
|
||||
eventId: 42,
|
||||
name: 'Sommerkonzert 2026',
|
||||
startDateTime: new Date('2026-07-01T19:00:00Z'),
|
||||
location: 'Christuskirche',
|
||||
status: 'PUBLISHED'
|
||||
};
|
||||
|
||||
const RECIPIENT = {
|
||||
eventId: 42,
|
||||
contactName: 'Erika Mustermann',
|
||||
contactEmail: 'erika@example.com',
|
||||
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetEvent.mockResolvedValue(EVENT);
|
||||
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
||||
mockSendMail.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('sendRedemptionConfirmation', () => {
|
||||
it('sends the German confirmation with the event details, guest list and .ics attachment', async () => {
|
||||
const result = await sendRedemptionConfirmation(RECIPIENT);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
const [to, subject, body, options] = mockSendMail.mock.calls[0];
|
||||
expect(to).toBe('erika@example.com');
|
||||
expect(subject).toBe('Bestätigung: Sommerkonzert 2026');
|
||||
expect(body).toContain('Hallo Erika Mustermann,');
|
||||
expect(body).toContain('"Sommerkonzert 2026"');
|
||||
expect(body).toContain('- Hans Mustermann');
|
||||
expect(options.attachments).toEqual([
|
||||
{filename: 'konzert.ics', content: 'BEGIN:VCALENDAR\nEND:VCALENDAR', contentType: 'text/calendar'}
|
||||
]);
|
||||
});
|
||||
|
||||
it('still sends (without an attachment) and warns when the .ics build fails', async () => {
|
||||
mockToIcal.mockRejectedValue(new Error('ical boom'));
|
||||
|
||||
await sendRedemptionConfirmation(RECIPIENT);
|
||||
|
||||
const options = mockSendMail.mock.calls[0][3];
|
||||
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 () => {
|
||||
mockGetEvent.mockResolvedValue(null);
|
||||
|
||||
const result = await sendRedemptionConfirmation(RECIPIENT);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates the relay result', async () => {
|
||||
mockSendMail.mockResolvedValue(false);
|
||||
|
||||
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordConfirmationEmailResult', () => {
|
||||
const makeConn = () => ({query: jest.fn().mockResolvedValue(undefined), end: jest.fn().mockResolvedValue(undefined)});
|
||||
|
||||
it('writes SENT when the mail was accepted', async () => {
|
||||
const conn = makeConn();
|
||||
mockGetConnection.mockResolvedValue(conn);
|
||||
|
||||
await recordConfirmationEmailResult(7, true);
|
||||
|
||||
expect(conn.query).toHaveBeenCalledWith(
|
||||
'UPDATE redemptions SET confirmation_email_status = ? WHERE redemption_id = ?',
|
||||
['SENT', 7]
|
||||
);
|
||||
expect(conn.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes FAILED when the mail was not accepted', async () => {
|
||||
const conn = makeConn();
|
||||
mockGetConnection.mockResolvedValue(conn);
|
||||
|
||||
await recordConfirmationEmailResult(7, false);
|
||||
|
||||
expect(conn.query).toHaveBeenCalledWith(expect.any(String), ['FAILED', 7]);
|
||||
});
|
||||
|
||||
it('swallows a DB error rather than throwing', async () => {
|
||||
const conn = {query: jest.fn().mockRejectedValue(new Error('db down')), end: jest.fn().mockResolvedValue(undefined)};
|
||||
mockGetConnection.mockResolvedValue(conn);
|
||||
|
||||
await expect(recordConfirmationEmailResult(7, true)).resolves.toBeUndefined();
|
||||
expect(conn.end).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// resendRedemptionConfirmation rebuilds the confirmation email from stored
|
||||
// redemption data and records the new outcome. Only the resend path is
|
||||
// exercised here; the shared send/record logic is covered by
|
||||
// confirmation-email.test.ts.
|
||||
|
||||
jest.mock('../../src/models/tickets/Tickets.db', () => ({
|
||||
NachklangTicketsDB: {getConnection: jest.fn()}
|
||||
}));
|
||||
jest.mock('../../src/models/tickets/tickets.confirmation-email', () => ({
|
||||
sendRedemptionConfirmation: jest.fn(),
|
||||
recordConfirmationEmailResult: jest.fn()
|
||||
}));
|
||||
|
||||
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db';
|
||||
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email';
|
||||
import {resendRedemptionConfirmation} from '../../src/models/tickets/admin/redemptions.admin.service';
|
||||
|
||||
const mockGetConnection = NachklangTicketsDB.getConnection as jest.Mock;
|
||||
const mockSend = sendRedemptionConfirmation as jest.Mock;
|
||||
const mockRecord = recordConfirmationEmailResult as jest.Mock;
|
||||
|
||||
const ACTIVE_ROW = {
|
||||
redemption_id: 5,
|
||||
code: 'ABC123',
|
||||
event_id: 42,
|
||||
status: 'ACTIVE',
|
||||
contact_name: 'Erika Mustermann',
|
||||
contact_email: 'erika@example.com',
|
||||
contact_address: null,
|
||||
guest_count: 2,
|
||||
redeemed_at: new Date('2026-06-01T10:00:00Z'),
|
||||
confirmation_email_status: 'FAILED'
|
||||
};
|
||||
|
||||
// getRedemption issues: 1) SELECT redemptions, 2) SELECT redemption_guests
|
||||
const connFor = (redemptionRows: any[], guestRows: any[] = []) => ({
|
||||
query: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(redemptionRows)
|
||||
.mockResolvedValueOnce(guestRows),
|
||||
end: jest.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSend.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('resendRedemptionConfirmation', () => {
|
||||
it('returns NOT_FOUND when the redemption does not exist', async () => {
|
||||
mockGetConnection.mockResolvedValue(connFor([]));
|
||||
|
||||
expect(await resendRedemptionConfirmation(5)).toBe('NOT_FOUND');
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns NOT_ACTIVE for an undone redemption', async () => {
|
||||
mockGetConnection.mockResolvedValue(connFor([{...ACTIVE_ROW, status: 'UNDONE'}]));
|
||||
|
||||
expect(await resendRedemptionConfirmation(5)).toBe('NOT_ACTIVE');
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resends from the stored data and records SENT on success', async () => {
|
||||
mockGetConnection.mockResolvedValue(connFor([ACTIVE_ROW], [{name: 'Erika Mustermann'}, {name: 'Hans Mustermann'}]));
|
||||
|
||||
const result = await resendRedemptionConfirmation(5);
|
||||
|
||||
expect(result).toBe('SENT');
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
eventId: 42,
|
||||
contactName: 'Erika Mustermann',
|
||||
contactEmail: 'erika@example.com',
|
||||
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
||||
});
|
||||
expect(mockRecord).toHaveBeenCalledWith(5, true);
|
||||
});
|
||||
|
||||
it('records FAILED and returns FAILED when the relay rejects the send', async () => {
|
||||
mockGetConnection.mockResolvedValue(connFor([ACTIVE_ROW], [{name: 'Erika Mustermann'}]));
|
||||
mockSend.mockResolvedValue(false);
|
||||
|
||||
const result = await resendRedemptionConfirmation(5);
|
||||
|
||||
expect(result).toBe('FAILED');
|
||||
expect(mockRecord).toHaveBeenCalledWith(5, false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user