Files
tessera-ctl/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
T
schalli faed0d760f 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>
2026-10-02 15:23:34 +02:00

382 lines
15 KiB
TypeScript

import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance, NextcloudList } from '@/lib/nextcloud-status-api';
import de from '@/messages/de.json';
import NextcloudStatusPage from './page';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
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')>();
return {
...actual,
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,
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: 'u1' } }),
}));
function makeInstance(
over: Partial<NextcloudInstance> & { id: string; customerName: string },
): NextcloudInstance {
return {
baseUrl: `https://${over.id}.example.de`,
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: new Date().toISOString(),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
...over,
};
}
const LIST: NextcloudList = {
reference: { newestVersion: '35.0.1', fetchedAt: '2026-10-02T10:00:00.000Z' },
instances: [
makeInstance({ id: 'a', customerName: 'Kunde Grün' }),
makeInstance({
id: 'b',
customerName: 'Kunde Gelb',
status: {
checkedAt: new Date().toISOString(),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '34.0.3',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: {
level: 'yellow',
reason: 'update-available',
updateTo: '34.0.4',
eolDate: '2027-06-30',
cycle: 34,
},
}),
makeInstance({
id: 'c',
customerName: 'Kunde Rot',
status: {
checkedAt: new Date().toISOString(),
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
errorKind: 'timeout',
errorDetail: null,
},
rating: { level: 'red', reason: 'unreachable', updateTo: null, eolDate: null, cycle: null },
}),
makeInstance({
id: 'd',
customerName: 'Kunde Grau',
rating: {
level: 'unknown',
reason: 'no-reference',
updateTo: null,
eolDate: null,
cycle: 35,
},
}),
],
};
describe('NextcloudStatusPage', () => {
beforeEach(() => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockSubscribe.mockReset().mockResolvedValue(undefined);
mockUnsubscribe.mockReset().mockResolvedValue(undefined);
mockRequestPermission.mockReset();
mockCanManage = false;
window.localStorage.clear();
});
afterEach(() => cleanup());
it('zeigt je Cloud eine Kachel mit Name, Version, Klartext und Verweis', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
expect(tiles).toHaveLength(4);
const green = tiles.find((t) => t.textContent?.includes('Kunde Grün'));
expect(green).toBeDefined();
expect(within(green as HTMLElement).getByText('Aktuell')).toBeInTheDocument();
expect(green).toHaveAttribute('data-level', 'green');
expect(screen.getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument();
expect(screen.getByText('Nicht erreichbar')).toBeInTheDocument();
expect(screen.getByText('Bewertung nicht möglich')).toBeInTheDocument();
expect(screen.getByText('34.0.3')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'https://a.example.de' });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('href', 'https://a.example.de');
});
it('nennt die neueste Nextcloud-Version', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
'Neueste Nextcloud-Version: 35.0.1',
);
});
it('weist ohne Vergleichsdaten darauf hin', async () => {
mockListInstances.mockResolvedValue({
...LIST,
reference: { newestVersion: null, fetchedAt: null },
});
render(<NextcloudStatusPage />);
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
'Versionsdaten derzeit nicht verfügbar',
);
});
it('zeigt den Leerzustand ohne Clouds', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [] });
render(<NextcloudStatusPage />);
expect(await screen.findByText('Es sind noch keine Clouds eingetragen.')).toBeInTheDocument();
expect(screen.queryByTestId('cloud-tile')).toBeNull();
});
it('zeigt einen Hinweis, wenn das Laden fehlschlägt', async () => {
mockListInstances.mockRejectedValue(new Error('boom'));
render(<NextcloudStatusPage />);
await waitFor(() =>
expect(screen.getByText(/konnten nicht geladen werden/)).toBeInTheDocument(),
);
});
it('verwendet ohne Logo Initialen', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument();
});
describe('Rechte und Sortierung', () => {
const tileNames = () =>
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
});
it('Verwalter sehen alle Knöpfe', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
});
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
});
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
});
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
expect(mockCheckAll).toHaveBeenCalledTimes(1);
});
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckOne.mockResolvedValue({
...LIST.instances[2],
customerName: 'Kunde Rot',
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
});
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
const red = tiles.find((t) =>
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
) as HTMLElement;
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
await waitFor(() =>
expect(
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
).toHaveAttribute('data-level', 'green'),
);
});
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
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');
});
});
});