import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'de', })); vi.mock('@/lib/reminders-api', async () => { const actual = await vi.importActual('@/lib/reminders-api'); return { REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT, ReminderRequestError: actual.ReminderRequestError, listReminders: vi.fn(), createReminder: vi.fn(), updateReminder: vi.fn(), snoozeReminder: vi.fn(), deleteReminder: vi.fn(), getReminderEmailStatus: vi.fn(), }; }); import { createReminder, deleteReminder, getReminderEmailStatus, listReminders, ReminderRequestError, snoozeReminder, updateReminder, } from '@/lib/reminders-api'; import { ReminderWidget } from './reminder-widget'; const mockList = listReminders as ReturnType; const mockCreate = createReminder as ReturnType; const mockUpdate = updateReminder as ReturnType; const mockSnooze = snoozeReminder as ReturnType; const mockDelete = deleteReminder as ReturnType; const mockEmailStatus = getReminderEmailStatus as ReturnType; function reminder(id: string, title: string, dueAt: string, description = '') { return { id, title, description, dueAt, emailEnabled: false, createdAt: dueAt, updatedAt: dueAt, }; } const props = { instanceId: 'w1', config: {}, isEditMode: false }; let requestPermission: ReturnType; beforeEach(() => { window.localStorage.clear(); mockList.mockReset(); mockCreate.mockReset(); 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 }); vi.stubGlobal('Notification', ctor); }); afterEach(() => { vi.unstubAllGlobals(); }); async function openForm() { fireEvent.click(await screen.findByText('reminder.add')); } function fillAndSubmit(title: string) { fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' }, }); fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } }); fireEvent.click(screen.getByText('reminder.save')); } describe('ReminderWidget', () => { it('listet die Erinnerungen nach Faelligkeit sortiert', async () => { mockList.mockResolvedValue([ reminder('b', 'Spaeter', '2099-01-02T10:00:00.000Z'), reminder('a', 'Frueh', '2099-01-01T10:00:00.000Z', 'Beschreibung'), ]); render(); const rows = await screen.findAllByTestId('reminder-row'); expect(rows[0].textContent).toContain('Frueh'); expect(rows[0].textContent).toContain('Beschreibung'); expect(rows[1].textContent).toContain('Spaeter'); }); it('zeigt den Leerzustand', async () => { mockList.mockResolvedValue([]); render(); expect(await screen.findByText('reminder.empty')).toBeTruthy(); }); it('das Rendern fragt keine Benachrichtigungs-Erlaubnis (D-04)', async () => { mockList.mockResolvedValue([]); render(); await screen.findByText('reminder.empty'); expect(requestPermission).not.toHaveBeenCalled(); }); it('Anlegen sendet die aus den Ortszeit-Eingaben gebildete ISO-Zeit', async () => { mockList.mockResolvedValue([]); mockCreate.mockResolvedValue(reminder('n', 'Zahnarzt', '2099-03-04T13:30:00.000Z')); render(); await openForm(); fillAndSubmit('Zahnarzt'); await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); const arg = mockCreate.mock.calls[0][0]; expect(arg.title).toBe('Zahnarzt'); expect(arg.dueAt).toBe(new Date('2099-03-04T14:30').toISOString()); }); it('die erste Anlage fragt die Erlaubnis einmal, die zweite nicht mehr', async () => { mockList.mockResolvedValue([]); mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z')); render(); await openForm(); fillAndSubmit('Erste'); await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); expect(requestPermission).toHaveBeenCalledTimes(1); // Dialog ist nach dem Speichern zu; neu oeffnen await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); (Notification as unknown as { permission: string }).permission = 'default'; await openForm(); fillAndSubmit('Zweite'); await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2)); expect(requestPermission).toHaveBeenCalledTimes(1); }); it('lehnt eine Vergangenheit im Formular ab, ohne zu senden', async () => { mockList.mockResolvedValue([]); render(); await openForm(); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' }, }); fireEvent.click(screen.getByText('reminder.save')); expect(await screen.findByText('reminder.pastError')).toBeTruthy(); expect(mockCreate).not.toHaveBeenCalled(); }); it('meldet REMINDERS_CHANGED nach dem Anlegen', async () => { mockList.mockResolvedValue([]); mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z')); const handler = vi.fn(); window.addEventListener('tessera:reminders-changed', handler); render(); await openForm(); await act(async () => { fillAndSubmit('Meldung'); }); await waitFor(() => expect(handler).toHaveBeenCalled()); window.removeEventListener('tessera:reminders-changed', handler); }); }); const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString(); describe('ReminderWidget — faellig und Aktionen (D-03)', () => { it('eine faellige Zeile ist hervorgehoben mit Faellig, Erledigt und Spaeter erinnern, ohne Bearbeiten/Loeschen', async () => { mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); render(); const row = await screen.findByTestId('reminder-row'); expect(row.getAttribute('data-due')).toBe('true'); expect(row.className).toContain('border-status-warn'); expect(within(row).getByText('reminder.due')).toBeTruthy(); expect(within(row).getByText('reminder.done')).toBeTruthy(); expect(within(row).getByText('reminder.snooze')).toBeTruthy(); expect(within(row).queryByLabelText('reminder.edit')).toBeNull(); expect(within(row).queryByLabelText('reminder.delete')).toBeNull(); }); it('eine kuenftige Zeile bietet Bearbeiten und Loeschen, aber nicht Erledigt/Spaeter', async () => { mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]); render(); const row = await screen.findByTestId('reminder-row'); expect(row.getAttribute('data-due')).toBe('false'); expect(within(row).getByLabelText('reminder.edit')).toBeTruthy(); expect(within(row).getByLabelText('reminder.delete')).toBeTruthy(); expect(within(row).queryByText('reminder.done')).toBeNull(); expect(within(row).queryByText('reminder.snooze')).toBeNull(); }); it('Erledigt ruft deleteReminder und die Zeile verschwindet nach dem Neuladen', async () => { mockList.mockResolvedValueOnce([reminder('a', 'Faellig', iso(-60_000))]).mockResolvedValue([]); mockDelete.mockResolvedValue({ deleted: true }); render(); fireEvent.click(await screen.findByText('reminder.done')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a')); await waitFor(() => expect(screen.queryByTestId('reminder-row')).toBeNull()); }); it('jede Spaeter-Option ruft snoozeReminder mit dem Zeitpunkt aus snoozeTarget', async () => { const original = new Date(Date.now() - 5 * 60_000); mockList.mockResolvedValue([reminder('a', 'Faellig', original.toISOString())]); mockSnooze.mockResolvedValue({}); render(); for (const [label, expected] of [ ['reminder.snooze10m', () => Date.now() + 10 * 60_000], ['reminder.snooze1h', () => Date.now() + 60 * 60_000], ] as const) { mockSnooze.mockClear(); fireEvent.click(await screen.findByText('reminder.snooze')); fireEvent.click(await screen.findByText(label)); await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1)); const [id, dueAt] = mockSnooze.mock.calls[0]; expect(id).toBe('a'); expect(Math.abs(new Date(dueAt).getTime() - expected())).toBeLessThan(5_000); } mockSnooze.mockClear(); fireEvent.click(await screen.findByText('reminder.snooze')); fireEvent.click(await screen.findByText('reminder.snoozeTomorrow')); await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1)); const tomorrow = new Date(original); tomorrow.setDate(tomorrow.getDate() + 1); expect(mockSnooze.mock.calls[0][1]).toBe(tomorrow.toISOString()); }); it('Loeschen fragt zweistufig nach und ruft dann deleteReminder', async () => { mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]); mockDelete.mockResolvedValue({ deleted: true }); render(); fireEvent.click(await screen.findByLabelText('reminder.delete')); expect(mockDelete).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('reminder.yes')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a')); }); it('Bearbeiten oeffnet das Formular mit den Werten und speichert per updateReminder', async () => { const due = new Date(Date.now() + 3600_000); mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString(), 'Text')]); mockUpdate.mockResolvedValue({}); render(); fireEvent.click(await screen.findByLabelText('reminder.edit')); const titleInput = screen.getByLabelText('reminder.titleLabel') as HTMLInputElement; expect(titleInput.value).toBe('Alt'); fireEvent.change(titleInput, { target: { value: 'Neu' } }); fireEvent.click(screen.getByText('reminder.save')); await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); const [id, patch] = mockUpdate.mock.calls[0]; expect(id).toBe('a'); expect(patch.title).toBe('Neu'); expect(patch.description).toBe('Text'); // Bearbeiten fragt nie die Benachrichtigungs-Erlaubnis expect(requestPermission).not.toHaveBeenCalled(); }); it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => { // Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit. const due = new Date(Date.now() + 30_000); due.setSeconds(due.getSeconds(), 500); mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]); mockUpdate.mockResolvedValue({}); render(); fireEvent.click(await screen.findByLabelText('reminder.edit')); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } }); fireEvent.click(screen.getByText('reminder.save')); await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); expect(screen.queryByText('reminder.pastError')).toBeNull(); expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt'); expect(mockUpdate.mock.calls[0][1].title).toBe('Neu'); }); it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => { const due = new Date(Date.now() + 2 * 3600_000); mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]); mockUpdate.mockResolvedValue({}); render(); fireEvent.click(await screen.findByLabelText('reminder.edit')); const next = new Date(Date.now() + 26 * 3600_000); const pad = (n: number) => String(n).padStart(2, '0'); const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`; fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } }); fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } }); fireEvent.click(screen.getByText('reminder.save')); await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString()); }); it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => { mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); mockSnooze.mockRejectedValue(new ReminderRequestError(409)); render(); fireEvent.click(await screen.findByText('reminder.snooze')); fireEvent.click(await screen.findByText('reminder.snooze10m')); expect(await screen.findByText('reminder.notDue')).toBeTruthy(); }); it('zeigt bei blockierten Browser-Benachrichtigungen einen Hinweis, sonst nicht', async () => { mockList.mockResolvedValue([]); (Notification as unknown as { permission: string }).permission = 'denied'; const { unmount } = render(); expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy(); unmount(); (Notification as unknown as { permission: string }).permission = 'default'; render(); await screen.findByText('reminder.empty'); expect(screen.queryByTestId('reminder-permission-hint')).toBeNull(); }); it('zeigt in der Desktop-App keinen Hinweis', async () => { (window as unknown as Record).__TAURI_INTERNALS__ = { invoke: vi.fn() }; (Notification as unknown as { permission: string }).permission = 'denied'; mockList.mockResolvedValue([]); render(); await screen.findByText('reminder.empty'); expect(screen.queryByTestId('reminder-permission-hint')).toBeNull(); delete (window as unknown as Record).__TAURI_INTERNALS__; }); }); describe('ReminderWidget — Faelligkeit ohne Neuladen', () => { afterEach(() => { vi.useRealTimers(); }); it('eine Zeile wird durch den lokalen 10-s-Takt faellig', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z')); mockList.mockResolvedValue([reminder('a', 'Bald', '2026-09-29T12:00:30.000Z')]); render(); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('false'); await act(async () => { await vi.advanceTimersByTimeAsync(40_000); }); expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true'); }); }); describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => { afterEach(() => { vi.useRealTimers(); }); it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.setSystemTime(new Date(2026, 8, 30, 0, 10)); mockList.mockResolvedValue([ reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()), ]); mockSnooze.mockResolvedValue({}); render(); fireEvent.click(await screen.findByText('reminder.snooze')); expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull(); fireEvent.click(await screen.findByText('reminder.snoozeToday')); await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1)); expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString()); }); }); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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'); }); });