faed0d760f
- 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>
382 lines
15 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|