feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste

- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage
- MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung
- Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:10:06 +02:00
parent 709b41a007
commit 6879c756f2
21 changed files with 1038 additions and 21 deletions
+66
View File
@@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
expect(errorSpy).toHaveBeenCalled();
});
});
describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => {
const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit)
function make() {
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
}
it('sendet Betreff "Erinnerung: <Titel>" mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => {
const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', {
title: 'Zahnarzt',
description: 'Kartenlesegeraet mitnehmen',
dueAt,
});
expect(ok).toBe(true);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.to).toBe('alice@a.example.invalid');
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.subject).toBe('Erinnerung: Zahnarzt');
expect(sent.html).toBeUndefined();
expect(sent.text).toContain('14:30 Uhr');
expect(sent.text).toContain('Zahnarzt');
expect(sent.text).toContain('Kartenlesegeraet mitnehmen');
expect(sent.text).toContain('http://localhost:3000');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => {
await make().sendReminderEmail('t1', 'a@a.example.invalid', {
title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
description: '',
dueAt,
});
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.subject).not.toMatch(/[\r\n]/);
expect(sent.subject.length).toBe(150);
expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true);
});
it('laesst die Beschreibung weg, wenn sie leer ist', async () => {
await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt });
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.text.split('\n')).toEqual([
'Guten Tag,',
'',
expect.stringContaining('eine Erinnerung für'),
'',
'T',
'',
'http://localhost:3000',
]);
});
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('SMTP down');
});
const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', {
title: 'T',
description: '',
dueAt,
});
expect(ok).toBe(false);
expect(mockClose).toHaveBeenCalledTimes(1);
});
});