feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung

- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
@@ -24,6 +24,9 @@ function render(ui: ReactElement) {
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
const mockSubscribe = vi.fn();
const mockUnsubscribe = vi.fn();
const mockRequestPermission = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
@@ -32,9 +35,15 @@ vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...args),
subscribe: (...args: unknown[]) => mockSubscribe(...args),
unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args),
};
});
vi.mock('@/lib/reminder-notify', () => ({
requestBrowserPermissionOnce: () => mockRequestPermission(),
}));
let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
@@ -127,6 +136,9 @@ describe('NextcloudStatusPage', () => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockSubscribe.mockReset().mockResolvedValue(undefined);
mockUnsubscribe.mockReset().mockResolvedValue(undefined);
mockRequestPermission.mockReset();
mockCanManage = false;
window.localStorage.clear();
});
@@ -285,4 +297,85 @@ describe('NextcloudStatusPage', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
describe('Glocke „Benachrichtigen“ (quick-261002-kxc)', () => {
const tileOf = (name: string) =>
screen
.getAllByTestId('cloud-tile')
.find((t) => t.getAttribute('aria-label')?.startsWith(name)) as HTMLElement;
const withBells = (subscribedIds: string[]): NextcloudList => ({
...LIST,
instances: LIST.instances.map((i) => ({ ...i, subscribed: subscribedIds.includes(i.id) })),
});
it('ist auf der Benutzen-Ebene an jeder Kachel sichtbar und zeigt den gemerkten Stand', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(withBells(['b']));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getAllByRole('button', { name: 'Benachrichtigen' })).toHaveLength(4);
expect(
within(tileOf('Kunde Gelb')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'true');
expect(
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'false');
});
it('Klick schaltet ein (subscribe, Browser-Erlaubnis einmalig angefragt) und wieder aus (unsubscribe)', async () => {
mockListInstances.mockResolvedValue(withBells([]));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
const bell = () =>
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' });
fireEvent.click(bell());
await waitFor(() => expect(mockSubscribe).toHaveBeenCalledWith('a'));
await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'true'));
expect(mockRequestPermission).toHaveBeenCalledTimes(1);
expect(mockUnsubscribe).not.toHaveBeenCalled();
fireEvent.click(bell());
await waitFor(() => expect(mockUnsubscribe).toHaveBeenCalledWith('a'));
await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'false'));
expect(mockRequestPermission).toHaveBeenCalledTimes(1);
});
it('bei einem Fehler wird zurückgenommen und der Hinweis gezeigt', async () => {
mockListInstances.mockResolvedValue(withBells([]));
mockSubscribe.mockRejectedValue(new Error('nein'));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
const bell = () =>
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' });
fireEvent.click(bell());
expect(await screen.findByRole('alert')).toHaveTextContent(
'Die Benachrichtigung konnte nicht geändert werden.',
);
expect(bell()).toHaveAttribute('aria-pressed', 'false');
expect(mockRequestPermission).not.toHaveBeenCalled();
});
it('die Prüfung einer einzelnen Kachel behält den Stand der Glocke', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(withBells(['c']));
// Einzelantwort ohne `subscribed`
mockCheckOne.mockResolvedValue({ ...LIST.instances[2] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }),
);
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() =>
expect(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }),
).not.toBeDisabled(),
);
expect(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'true');
});
});
});