feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen
- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404 - Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen - Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
@@ -13,14 +13,27 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
ReminderRequestError: actual.ReminderRequestError,
|
||||
listReminders: vi.fn(),
|
||||
createReminder: vi.fn(),
|
||||
updateReminder: vi.fn(),
|
||||
snoozeReminder: vi.fn(),
|
||||
deleteReminder: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createReminder, listReminders } from '@/lib/reminders-api';
|
||||
import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import { ReminderWidget } from './reminder-widget';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
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>;
|
||||
|
||||
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||
return {
|
||||
@@ -42,6 +55,9 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockList.mockReset();
|
||||
mockCreate.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockSnooze.mockReset();
|
||||
mockDelete.mockReset();
|
||||
requestPermission = vi.fn(async () => 'granted');
|
||||
const ctor = vi.fn();
|
||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||
@@ -145,3 +161,149 @@ describe('ReminderWidget', () => {
|
||||
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(<ReminderWidget {...props} />);
|
||||
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(<ReminderWidget {...props} />);
|
||||
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(<ReminderWidget {...props} />);
|
||||
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(<ReminderWidget {...props} />);
|
||||
|
||||
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(<ReminderWidget {...props} />);
|
||||
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(<ReminderWidget {...props} />);
|
||||
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('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
||||
render(<ReminderWidget {...props} />);
|
||||
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(<ReminderWidget {...props} />);
|
||||
expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy();
|
||||
unmount();
|
||||
(Notification as unknown as { permission: string }).permission = 'default';
|
||||
render(<ReminderWidget {...props} />);
|
||||
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<string, unknown>).__TAURI_INTERNALS__ = { invoke: vi.fn() };
|
||||
(Notification as unknown as { permission: string }).permission = 'denied';
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
|
||||
delete (window as unknown as Record<string, unknown>).__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(<ReminderWidget {...props} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user