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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user