e7d76f40de
New event_ticket_settings.tickets_mailed column (default false, since no event mails physical tickets today). When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse under their name instead. Read directly by sendRedemptionConfirmation from event_ticket_settings, so both send paths (redeem flow, admin resend) pick it up without either caller changing. Also fixes docker/init/03-tickets-schema.sql, found missing the SOURCE line for migration 003 while adding 004 - local tickets dev databases have been silently missing confirmation_email_status. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
// tickets.confirmation-email builds and sends the redemption confirmation
|
|
// email, shared by the public redeem path and the admin resend action.
|
|
|
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
|
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
|
|
getEventById: vi.fn()
|
|
}));
|
|
vi.mock('../../src/models/calendar/events/icalgenerator.service.js', () => ({
|
|
convertToIcal: vi.fn()
|
|
}));
|
|
vi.mock('../../src/common/common.mail.js', () => ({
|
|
MailService: {sendMail: vi.fn()}
|
|
}));
|
|
vi.mock('../../src/models/tickets/Tickets.db.js', () => ({
|
|
NachklangTicketsDB: {getConnection: vi.fn()}
|
|
}));
|
|
vi.mock('../../src/middleware/logger.js', () => ({
|
|
__esModule: true,
|
|
default: {info: vi.fn(), warn: vi.fn(), error: vi.fn()}
|
|
}));
|
|
|
|
import * as EventsService from '../../src/models/calendar/events/events.service.js';
|
|
import * as IcalService from '../../src/models/calendar/events/icalgenerator.service.js';
|
|
import {MailService} from '../../src/common/common.mail.js';
|
|
import logger from '../../src/middleware/logger.js';
|
|
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db.js';
|
|
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email.js';
|
|
|
|
const mockGetEvent = EventsService.getEventById as Mock;
|
|
const mockToIcal = IcalService.convertToIcal as Mock;
|
|
const mockSendMail = MailService.sendMail as Mock;
|
|
const mockLogger = logger as unknown as {info: Mock; warn: Mock; error: Mock};
|
|
const mockGetConnection = NachklangTicketsDB.getConnection as 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']
|
|
};
|
|
|
|
// Default: no event_ticket_settings row, same "absence over sentinels" case
|
|
// as everywhere else - ticketsAreMailed reads this as false (not mailed).
|
|
const makeSettingsConn = (ticketsMailed?: boolean) => ({
|
|
query: vi.fn().mockResolvedValue(ticketsMailed === undefined ? [] : [{tickets_mailed: ticketsMailed ? 1 : 0}]),
|
|
end: vi.fn().mockResolvedValue(undefined)
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockGetEvent.mockResolvedValue(EVENT);
|
|
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
|
mockSendMail.mockResolvedValue(true);
|
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('adds the Abendkasse pickup notice when the event does not mail tickets', async () => {
|
|
mockGetConnection.mockResolvedValue(makeSettingsConn(false));
|
|
|
|
await sendRedemptionConfirmation(RECIPIENT);
|
|
|
|
const body = mockSendMail.mock.calls[0][2];
|
|
expect(body).toContain('Abendkasse');
|
|
expect(body).toContain('Erika Mustermann');
|
|
});
|
|
|
|
it('adds the pickup notice when there is no ticket-shop settings row at all', async () => {
|
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
|
|
|
await sendRedemptionConfirmation(RECIPIENT);
|
|
|
|
expect(mockSendMail.mock.calls[0][2]).toContain('Abendkasse');
|
|
});
|
|
|
|
it('omits the pickup notice when the event mails tickets', async () => {
|
|
mockGetConnection.mockResolvedValue(makeSettingsConn(true));
|
|
|
|
await sendRedemptionConfirmation(RECIPIENT);
|
|
|
|
expect(mockSendMail.mock.calls[0][2]).not.toContain('Abendkasse');
|
|
});
|
|
});
|
|
|
|
describe('recordConfirmationEmailResult', () => {
|
|
const makeConn = () => ({query: vi.fn().mockResolvedValue(undefined), end: vi.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: vi.fn().mockRejectedValue(new Error('db down')), end: vi.fn().mockResolvedValue(undefined)};
|
|
mockGetConnection.mockResolvedValue(conn);
|
|
|
|
await expect(recordConfirmationEmailResult(7, true)).resolves.toBeUndefined();
|
|
expect(conn.end).toHaveBeenCalled();
|
|
});
|
|
});
|