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( {ui} , ); } 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(); 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 & { 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(); 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(); 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(); expect(await screen.findByTestId('newest-version')).toHaveTextContent( 'Versionsdaten derzeit nicht verfügbar', ); }); it('zeigt den Leerzustand ohne Clouds', async () => { mockListInstances.mockResolvedValue({ ...LIST, instances: [] }); render(); 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(); await waitFor(() => expect(screen.getByText(/konnten nicht geladen werden/)).toBeInTheDocument(), ); }); it('verwendet ohne Logo Initialen', async () => { mockListInstances.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] }); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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'); }); }); });