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
@@ -16,6 +16,7 @@ vi.mock('@/lib/reminders-api', async () => {
updateReminder: vi.fn(),
snoozeReminder: vi.fn(),
deleteReminder: vi.fn(),
getReminderEmailStatus: vi.fn(),
};
});
@@ -23,6 +24,7 @@ import {
ReminderRequestError,
createReminder,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
updateReminder,
@@ -34,6 +36,7 @@ const mockCreate = createReminder as ReturnType<typeof vi.fn>;
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
const mockEmailStatus = getReminderEmailStatus as ReturnType<typeof vi.fn>;
function reminder(id: string, title: string, dueAt: string, description = '') {
return {
@@ -58,6 +61,8 @@ beforeEach(() => {
mockUpdate.mockReset();
mockSnooze.mockReset();
mockDelete.mockReset();
mockEmailStatus.mockReset();
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
requestPermission = vi.fn(async () => 'granted');
const ctor = vi.fn();
Object.assign(ctor, { permission: 'default', requestPermission });
@@ -307,3 +312,87 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() {
await openForm();
// Der Status wird beim Einhaengen der Kachel geladen
await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1));
return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
}
it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
await waitFor(() => expect(box.disabled).toBe(false));
fireEvent.click(box);
fillAndSubmit('Mit Mail');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true);
});
it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
await openFormAndWaitForStatus();
fillAndSubmit('Ohne Mail');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled');
});
it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy();
expect(box.disabled).toBe(true);
});
it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false });
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy();
expect(box.disabled).toBe(true);
});
it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => {
mockEmailStatus.mockRejectedValue(new Error('offline'));
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(box.disabled).toBe(true);
});
it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => {
mockList.mockResolvedValue([
{ ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true },
reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'),
]);
render(<ReminderWidget {...props} />);
const rows = await screen.findAllByTestId('reminder-row');
expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy();
expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull();
});
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
expect(box.checked).toBe(true);
expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled');
});
});