feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)

- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
@@ -0,0 +1,139 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) =>
values?.title ? `${key}:${values.title}` : key,
useLocale: () => 'de',
}));
vi.mock('@/lib/reminders-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
return {
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
ReminderRequestError: actual.ReminderRequestError,
listReminders: vi.fn(),
};
});
vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
});
import { showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>;
const mockShow = showReminderNotification as ReturnType<typeof vi.fn>;
const NOW = new Date('2026-09-29T12:00:00.000Z');
function reminder(over: Partial<{ id: string; dueAt: string; description: string }> = {}) {
return {
id: 'r1',
title: 'Zahnarzt',
description: '',
dueAt: new Date(NOW.getTime() + 30_000).toISOString(),
emailEnabled: false,
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
...over,
};
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
window.localStorage.clear();
mockList.mockReset();
mockShow.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ReminderNotifier', () => {
it('meldet eine faellige Erinnerung genau einmal ueber mehrere Takte', async () => {
mockList.mockResolvedValue([reminder()]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow).not.toHaveBeenCalled(); // noch nicht faellig (D-02)
await advance(40_000); // ueber dueAt (+30 s) hinweg
await advance(30_000);
expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockShow.mock.calls[0][0]).toMatchObject({
title: 'reminder.notificationTitle:Zahnarzt',
tag: `reminder-r1-${reminder().dueAt}`,
});
});
it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
render(<ReminderNotifier />);
await advance(30_000);
expect(mockShow).not.toHaveBeenCalled();
});
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow.mock.calls[0][0].body).toBe('x'.repeat(200));
});
it('meldet nach geaendertem dueAt (Verschieben) erneut', async () => {
const first = reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString() });
mockList.mockResolvedValue([first]);
render(<ReminderNotifier />);
await advance(0);
expect(mockShow).toHaveBeenCalledTimes(1);
// Verschoben auf +10 s (in der Zukunft), dann faellig
mockList.mockResolvedValue([{ ...first, dueAt: new Date(Date.now() + 10_000).toISOString() }]);
await act(async () => {
window.dispatchEvent(new Event('tessera:reminders-changed'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockShow).toHaveBeenCalledTimes(1);
await advance(20_000);
expect(mockShow).toHaveBeenCalledTimes(2);
});
it('das Aenderungsereignis laedt die Liste neu', async () => {
mockList.mockResolvedValue([]);
render(<ReminderNotifier />);
await advance(0);
const before = mockList.mock.calls.length;
await act(async () => {
window.dispatchEvent(new Event('tessera:reminders-changed'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockList.mock.calls.length).toBe(before + 1);
});
it('pausiert bei 401 bis zum naechsten Fokus', async () => {
mockList.mockRejectedValue(new ReminderRequestError(401));
render(<ReminderNotifier />);
await advance(0);
const after401 = mockList.mock.calls.length;
await advance(180_000);
expect(mockList.mock.calls.length).toBe(after401);
mockList.mockResolvedValue([]);
await act(async () => {
window.dispatchEvent(new Event('focus'));
await vi.advanceTimersByTimeAsync(0);
});
expect(mockList.mock.calls.length).toBe(after401 + 1);
});
});