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
@@ -46,12 +46,18 @@ function makeFakePrisma() {
).length;
}),
};
return { reminder, rows };
const user = {
findFirst: vi.fn(async ({ where }: { where: any }) => ({
email: where.id === 'u-ohne-mail' ? null : `${where.id}@example.invalid`,
})),
};
return { reminder, user, rows };
}
function setup() {
function setup(smtp: unknown = { host: 'smtp.example.invalid' }) {
const prisma = makeFakePrisma();
return { prisma, service: new RemindersService(prisma as any) };
const settings = { getSmtpConfig: vi.fn(async (_tenantId: string) => smtp) };
return { prisma, settings, service: new RemindersService(prisma as any, settings as any) };
}
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
@@ -244,3 +250,67 @@ describe('RemindersService — loeschen (E-02)', () => {
expect(prisma.rows.size).toBe(0);
});
});
describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
it('getEmailAvailability: SMTP eingerichtet und Adresse vorhanden', async () => {
const { service, settings } = setup();
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
smtpConfigured: true,
hasEmail: true,
});
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t1');
});
it('getEmailAvailability: ohne SmtpConfig-Zeile ist der Versand nicht eingerichtet', async () => {
const { service } = setup(null);
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
smtpConfigured: false,
hasEmail: true,
});
});
it('getEmailAvailability: ohne Adresse im Konto hasEmail false, gelesen ueber den gebundenen Klienten', async () => {
const { prisma, service } = setup();
await expect(service.getEmailAvailability('t1', 'u-ohne-mail')).resolves.toEqual({
smtpConfigured: true,
hasEmail: false,
});
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
});
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
const { prisma, service } = setup();
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true });
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(true);
});
it('anlegen ohne emailEnabled speichert false', async () => {
const { prisma, service } = setup();
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(false);
});
it('anlegen mit emailEnabled ergibt 400, wenn SMTP fehlt oder die Adresse fehlt', async () => {
const noSmtp = setup(null);
await expect(
noSmtp.service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup();
await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0);
});
it('aendern mit emailEnabled true ergibt 400 ohne SMTP; emailEnabled false geht immer', async () => {
const { prisma, service } = setup(null);
seed(prisma, 'x', future());
await expect(service.update('t1', 'u1', 'x', { emailEnabled: true })).rejects.toBeInstanceOf(
BadRequestException,
);
await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
});
});