import { act, 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 { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api'; import de from '@/messages/de.json'; // Statisch importiert (vi.mock wird darueber gehoben) — das Laden faellt in die // Sammelphase, nicht in die Frist des ersten Tests (quick-260924-m4n). import { ProxmoxWidget } from './proxmox-widget'; // Echter next-intl-Provider mit den deutschen Texten (Muster // proxmox-page-roles.test.tsx) — die Kachel braucht ICU-Plural und useLocale. function render(ui: ReactElement) { return rtlRender( {ui} , ); } // T-I8V-02: `pollServer` ist nur als Spion da — die Kachel darf ihn nie rufen. const mockListServers = vi.fn(); const mockPollServer = vi.fn(); vi.mock('@/lib/proxmox-api', () => ({ listServers: (...args: unknown[]) => mockListServers(...args), pollServer: (...args: unknown[]) => mockPollServer(...args), })); const mockAuthStore = vi.fn(); vi.mock('@/lib/stores/auth-store', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), })); vi.mock('next/link', () => ({ default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( {children} ), })); const mockUpdateWidgetConfig = vi.fn(); vi.mock('@/lib/dashboard-api', () => ({ updateWidgetConfig: (...args: unknown[]) => mockUpdateWidgetConfig(...args), })); function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) { mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) => selector({ user }), ); } type Health = 'ok' | 'warn' | 'down' | 'idle' | 'orphan'; const POLLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString(); function pveMetrics(overrides: Partial> = {}) { return { productType: 'pve' as const, nodeCount: 1, guestsRunning: 3, guestsStopped: 1, nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }], storages: [], ...overrides, }; } /** * Baukasten: ein Server mit Zwischenlager passend zum gewuenschten Zustand. * `warn` = PVE-Knoten bei 87 % Last. */ function makeServer( health: Health, overrides: Partial & { metrics?: ProxmoxMetrics | null } = {}, ): ProxmoxServer { const { metrics: metricsOverride, ...rest } = overrides; const id = rest.id ?? `srv-${health}`; const metrics: ProxmoxMetrics | null = metricsOverride !== undefined ? metricsOverride : health === 'warn' ? pveMetrics({ nodes: [{ node: 'n1', cpu: 0.87, maxcpu: 8, mem: 1, maxmem: 10 }] }) : pveMetrics(); return { id, tenantId: 't1', name: `server-${health}`, productType: 'pve', baseUrl: 'https://pve.intern', authMethod: 'token', tokenId: 'root@pam!x', username: null, tlsRejectUnauthorized: true, isActive: health !== 'orphan', pollIntervalMin: 5, position: 0, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', status: { id: `st-${id}`, serverId: id, lastPolledAt: health === 'idle' ? null : POLLED_AT, lastOkAt: health === 'down' ? null : POLLED_AT, reachable: health !== 'down' && health !== 'idle', errorKind: health === 'down' ? 'netz' : null, errorDetail: health === 'down' ? 'connect ECONNREFUSED 10.0.0.1:8006' : null, rawSample: null, updatedAt: POLLED_AT, metrics: health === 'down' || health === 'idle' ? null : metrics, }, ...rest, }; } /** * Rendert die Kachel und wartet INNERHALB von act() das Ende des ersten * Ladens ab (aufgeloestes listServers-Versprechen -> setServers/setLoadFailed/ * setNow). Ohne das liefen diese Zustandsaenderungen nach dem Rendern ins Leere * und React meldete je Test drei „not wrapped in act(...)“-Warnungen * (quick-260924-m4n). */ async function renderWidget( props: { config?: Record; isEditMode?: boolean } = {}, ) { let result!: ReturnType; await act(async () => { result = render( , ); }); return result; } beforeEach(() => { mockUser({ role: 'USER' }); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); describe('ProxmoxWidget: Balken und Zusammenfassung (quick-260924-i8v)', () => { it('alle Server in Ordnung: „Alles in Ordnung“ in der Ok-Farbe', async () => { mockListServers.mockResolvedValue([ makeServer('ok', { id: 'a', name: 'pve-a' }), makeServer('ok', { id: 'b', name: 'pve-b' }), ]); await renderWidget(); const summary = await screen.findByTestId('proxmox-summary'); expect(summary).toHaveTextContent('Alles in Ordnung'); expect(summary).toHaveClass('text-status-ok-fg'); }); it('je ein down, warn, ok: „1 nicht erreichbar, 1 mit Warnung“ in der Farbe von down', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('warn'), makeServer('down')]); await renderWidget(); const summary = await screen.findByTestId('proxmox-summary'); expect(summary).toHaveTextContent('1 nicht erreichbar, 1 mit Warnung'); expect(summary).toHaveClass('text-status-down-fg'); expect(summary).toHaveAttribute('title', '1 nicht erreichbar, 1 mit Warnung'); }); it('zeigt den kompakten Balken (6 px, aria-hidden) ohne Legende', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); await renderWidget(); const bar = await screen.findByTestId('health-bar'); expect(bar).toHaveAttribute('data-variant', 'compact'); expect(bar).toHaveClass('h-1.5'); expect(bar).toHaveAttribute('aria-hidden', 'true'); expect(bar.querySelectorAll('[data-segment]')).toHaveLength(2); // Keine Legende: kein Listeneintrag im Balken, kein role="img" expect(bar.querySelector('li')).toBeNull(); expect(screen.queryByRole('img')).toBeNull(); }); it('zeigt die Fehlerdetails eines nicht erreichbaren Servers nicht (T-I8V-05)', async () => { mockListServers.mockResolvedValue([makeServer('down')]); await renderWidget(); await screen.findByTestId('proxmox-summary'); expect(screen.queryByText(/ECONNREFUSED/)).toBeNull(); expect(screen.queryByText(/pve\.intern/)).toBeNull(); }); }); describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', () => { it('waehrend des Ladens: pulsierende Leiste und Vorlesetext', async () => { mockListServers.mockReturnValue(new Promise(() => {})); await renderWidget(); expect(screen.getByText('Lade Serverliste...')).toHaveClass('sr-only'); }); it('leere Liste: Satz; ein Admin sieht den Link zu den Einstellungen', async () => { mockUser({ role: 'ADMIN' }); mockListServers.mockResolvedValue([]); await renderWidget(); expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute( 'href', '/modules/proxmox/settings', ); }); it('leere Liste: ein Benutzer mit Rolle USER sieht keinen Link', async () => { mockListServers.mockResolvedValue([]); await renderWidget(); expect(await screen.findByText('Noch kein Proxmox-Server eingetragen.')).toBeInTheDocument(); expect(screen.queryByRole('link')).toBeNull(); expect(screen.queryByText('Zu den Einstellungen')).toBeNull(); }); it('leere Liste im Bearbeitungsmodus: der Hinweis fuer Admins steht ohne Link da', async () => { mockUser({ role: 'SUPER_ADMIN' }); mockListServers.mockResolvedValue([]); await renderWidget({ isEditMode: true }); expect(await screen.findByText('Zu den Einstellungen')).toBeInTheDocument(); expect(screen.queryByRole('link')).toBeNull(); }); it('Laden schlaegt fehl: „Die Serverliste konnte nicht geladen werden.“', async () => { mockListServers.mockRejectedValue(new Error('500')); await renderWidget(); expect( await screen.findByText('Die Serverliste konnte nicht geladen werden.'), ).toBeInTheDocument(); }); it('ruft nie die manuelle Abfrage auf (T-I8V-02)', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); await renderWidget(); await screen.findByTestId('proxmox-summary'); await waitFor(() => expect(mockListServers).toHaveBeenCalledTimes(1)); expect(mockPollServer).not.toHaveBeenCalled(); }); }); function rowNames(): string[] { return screen .getAllByTestId('proxmox-row') .map((row) => row.getAttribute('data-server-name') ?? ''); } describe('ProxmoxWidget: Serverliste mit Kennzahl (quick-260924-i8v, Aufgabe 2)', () => { it('Zeilen stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => { mockListServers.mockResolvedValue([ makeServer('orphan', { position: 0 }), makeServer('ok', { position: 1 }), makeServer('idle', { position: 2 }), makeServer('warn', { position: 3 }), makeServer('down', { position: 4 }), ]); await renderWidget(); await screen.findByTestId('proxmox-summary'); expect(rowNames()).toEqual([ 'server-down', 'server-warn', 'server-ok', 'server-idle', 'server-orphan', ]); }); it('zeigt je Zeile genau eine Kennzahl in Worten', async () => { const backupAt = Math.floor(Date.now() / 1000) - 5 * 3600; mockListServers.mockResolvedValue([ makeServer('ok', { id: 'pve', name: 'pve-ok' }), makeServer('warn', { id: 'pve-w', name: 'pve-warn' }), makeServer('ok', { id: 'pbs', name: 'pbs-ok', productType: 'pbs', metrics: { productType: 'pbs', datastores: [ { name: 'ds', total: 100, used: 10, free: 90, lastBackupAt: backupAt, lastVerifyState: 'ok', }, ], }, }), makeServer('ok', { id: 'pmg', name: 'pmg-ok', productType: 'pmg', metrics: { productType: 'pmg', countIn: 12904, countOut: 10, spamCount: 2, virusCount: 0, }, }), makeServer('down'), makeServer('orphan'), makeServer('idle'), ]); await renderWidget(); await screen.findByTestId('proxmox-summary'); const figure = (name: string) => within( screen .getAllByTestId('proxmox-row') .find((row) => row.getAttribute('data-server-name') === name) as HTMLElement, ).getByTestId('proxmox-key-figure'); expect(figure('pve-ok')).toHaveTextContent('3/4 Gäste laufen'); expect(figure('pve-warn').textContent).toMatch(/^Auslastung 87\s%$/); expect(figure('pve-warn')).toHaveClass('text-status-warn-fg'); expect(figure('pbs-ok')).toHaveTextContent('Sicherung vor 5 Std.'); expect(figure('pmg-ok')).toHaveTextContent('12.904 eingehend'); expect(figure('server-down')).toHaveTextContent('nicht erreichbar'); expect(figure('server-down')).toHaveClass('text-status-down-fg'); expect(figure('server-down')).toHaveAttribute('aria-hidden', 'true'); expect(figure('server-orphan')).toHaveTextContent('offline & verwaist'); expect(figure('server-idle')).toHaveTextContent('noch nicht abgefragt'); }); it('unbekannte Werte heissen „unbekannt“ und nie 0', async () => { mockListServers.mockResolvedValue([ makeServer('ok', { id: 'pve', name: 'pve-leer', metrics: null }), makeServer('ok', { id: 'pmg', name: 'pmg-leer', productType: 'pmg', metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null, }, }), ]); await renderWidget(); await screen.findByTestId('proxmox-summary'); const figures = screen.getAllByTestId('proxmox-key-figure'); expect(figures.map((f) => f.textContent)).toEqual(['unbekannt', 'unbekannt']); expect(screen.queryByText(/0 eingehend/)).toBeNull(); expect(screen.queryByText(/0\s%/)).toBeNull(); }); it('jede Zeile nennt den Zustand auch fuer Vorleser (nicht nur Farbe)', async () => { mockListServers.mockResolvedValue([makeServer('warn')]); await renderWidget(); await screen.findByTestId('proxmox-summary'); expect(screen.getByText('Warnung')).toHaveClass('sr-only'); }); it('Kennzahl, Liste und Name tragen die Klassen der Groessenstufen', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('orphan')]); await renderWidget(); await screen.findByTestId('proxmox-summary'); const list = screen.getByTestId('proxmox-list'); expect(list.tagName).toBe('UL'); expect(list).toHaveClass('[@container(max-height:7.5rem)]:hidden'); expect(list).toHaveClass('@max-[8rem]:hidden'); for (const figure of screen.getAllByTestId('proxmox-key-figure')) { expect(figure).toHaveClass('@max-[15rem]:hidden'); } // verwaist: nur der Name wird gedaempft expect(screen.getByText('server-orphan')).toHaveClass('opacity-70'); expect(screen.getByText('server-ok')).not.toHaveClass('opacity-70'); }); }); describe('ProxmoxWidget: Serverauswahl, Titel, Links (quick-260924-i8v, Aufgabe 2)', () => { it('config.serverIds beschraenkt Zeilen UND Balken/Zusammenfassung auf die Auswahl', async () => { mockListServers.mockResolvedValue([ makeServer('ok', { id: 'a', name: 'pve-a' }), makeServer('down', { id: 'b', name: 'pve-b' }), ]); await renderWidget({ config: { serverIds: ['a'] } }); const summary = await screen.findByTestId('proxmox-summary'); expect(summary).toHaveTextContent('Alles in Ordnung'); expect(rowNames()).toEqual(['pve-a']); expect(screen.getByTestId('health-bar').querySelectorAll('[data-segment]')).toHaveLength(1); }); it('nur geloeschte Kennungen ausgewaehlt: Satz statt Balken und Liste', async () => { mockListServers.mockResolvedValue([makeServer('ok', { id: 'a' })]); await renderWidget({ config: { serverIds: ['weg'] } }); expect( await screen.findByText( 'Die ausgewählten Server gibt es nicht mehr. Wählen Sie im Bearbeitungsmodus andere aus.', ), ).toBeInTheDocument(); expect(screen.queryByTestId('health-bar')).toBeNull(); expect(screen.queryByTestId('proxmox-list')).toBeNull(); }); it('Ansichtsmodus: jede Zeile ist ein Link auf /modules/proxmox', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); await renderWidget(); await screen.findByTestId('proxmox-summary'); const links = screen.getAllByRole('link'); expect(links).toHaveLength(2); for (const link of links) expect(link).toHaveAttribute('href', '/modules/proxmox'); }); it('Bearbeitungsmodus: keine Links in der Kachel', async () => { mockListServers.mockResolvedValue([makeServer('ok'), makeServer('down')]); await renderWidget({ isEditMode: true }); await screen.findByTestId('proxmox-summary'); expect(screen.getAllByTestId('proxmox-row')).toHaveLength(2); expect(screen.queryByRole('link')).toBeNull(); }); it('Titel gesetzt: Ueberschrift h2', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); await renderWidget({ config: { title: 'Rechenzentrum' } }); expect(await screen.findByRole('heading', { level: 2, name: 'Rechenzentrum' })).toBeVisible(); }); it('Titel leer: keine Kopfzeile', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); await renderWidget({ config: { title: ' ' } }); await screen.findByTestId('proxmox-summary'); expect(screen.queryByRole('heading')).toBeNull(); }); }); describe('ProxmoxWidget: Minutentakt (quick-260924-i8v, Aufgabe 2)', () => { let visibility: DocumentVisibilityState = 'visible'; beforeEach(() => { visibility = 'visible'; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility, }); // Nur die Intervall-Zeitgeber faelschen: die Warte-Helfer der Testing // Library laufen weiter ueber echte Zeitgeber. vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); }); afterEach(() => { vi.useRealTimers(); // Eigene Eigenschaft entfernen, damit wieder die von Document.prototype gilt. delete (document as unknown as Record).visibilityState; }); async function flush() { await act(async () => { await Promise.resolve(); }); } async function advance(ms: number) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } it('laedt nach 60 s erneut aus dem Zwischenlager', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); await renderWidget(); await flush(); expect(mockListServers).toHaveBeenCalledTimes(1); await advance(60_000); expect(mockListServers).toHaveBeenCalledTimes(2); expect(mockPollServer).not.toHaveBeenCalled(); }); it('pausiert bei verborgenem Tab und laedt sofort, sobald er wieder sichtbar ist', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); await renderWidget(); await flush(); expect(mockListServers).toHaveBeenCalledTimes(1); visibility = 'hidden'; await advance(120_000); expect(mockListServers).toHaveBeenCalledTimes(1); visibility = 'visible'; await act(async () => { document.dispatchEvent(new Event('visibilitychange')); }); expect(mockListServers).toHaveBeenCalledTimes(2); expect(mockPollServer).not.toHaveBeenCalled(); }); it('nach dem Aushaengen keine weiteren Abrufe', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); const { unmount } = await renderWidget(); await flush(); unmount(); await advance(180_000); document.dispatchEvent(new Event('visibilitychange')); expect(mockListServers).toHaveBeenCalledTimes(1); expect(mockPollServer).not.toHaveBeenCalled(); }); it('scheitert ein spaeteres Nachladen, bleibt die letzte Liste stehen', async () => { mockListServers.mockResolvedValueOnce([makeServer('down')]); await renderWidget(); await flush(); expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar'); mockListServers.mockRejectedValueOnce(new Error('500')); await advance(60_000); await flush(); expect(mockListServers).toHaveBeenCalledTimes(2); expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar'); expect(screen.queryByText('Die Serverliste konnte nicht geladen werden.')).toBeNull(); }); }); describe('ProxmoxWidget: Bearbeitungsmodus mit Titel und Serverauswahl (quick-260924-i8v, Aufgabe 3)', () => { beforeEach(() => { mockUpdateWidgetConfig.mockResolvedValue(undefined); }); it('Titelfeld speichert entprellt nach 1500 ms per updateWidgetConfig({ title })', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); await renderWidget({ isEditMode: true, config: { title: 'Alt' } }); await screen.findByTestId('proxmox-summary'); const input = screen.getByLabelText('Titel') as HTMLInputElement; expect(input.value).toBe('Alt'); expect(input).toHaveAttribute('placeholder', 'Titel (optional)'); expect(input).toHaveClass('widgetNoDrag'); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { fireEvent.change(input, { target: { value: 'N' } }); fireEvent.change(input, { target: { value: 'Neu' } }); vi.advanceTimersByTime(1499); expect(mockUpdateWidgetConfig).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1); expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { title: 'Neu' }); } finally { vi.useRealTimers(); } }); it('im Bearbeitungsmodus steht die Kopfzeile auch ohne Titel, mit Abstand zur Griffleiste', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); const { container } = await renderWidget({ isEditMode: true }); await screen.findByTestId('proxmox-summary'); expect(screen.getByLabelText('Titel')).toBeInTheDocument(); expect(screen.queryByRole('heading')).toBeNull(); expect(container.querySelector('[data-testid="proxmox-widget"]')).toHaveClass('pt-5'); }); it('„Server auswählen“ oeffnet die Auswahl an Stelle der Liste; eine Aenderung speichert sofort und filtert', async () => { mockListServers.mockResolvedValue([ makeServer('ok', { id: 'a', name: 'pve-a', position: 0 }), makeServer('down', { id: 'b', name: 'pve-b', position: 1 }), ]); await renderWidget({ isEditMode: true }); expect(await screen.findByTestId('proxmox-summary')).toHaveTextContent('1 nicht erreichbar'); const toggle = screen.getByRole('button', { name: 'Server auswählen' }); expect(toggle).toHaveAttribute('aria-expanded', 'false'); expect(toggle).toHaveAttribute('data-no-drag'); fireEvent.click(toggle); expect(toggle).toHaveAttribute('aria-expanded', 'true'); expect(screen.queryByTestId('proxmox-list')).toBeNull(); const group = screen.getByRole('group', { name: 'Angezeigte Server' }); expect(group.closest('.widgetNoDrag')).not.toBeNull(); fireEvent.click(screen.getByRole('checkbox', { name: /pve-a/ })); expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('w-1', { serverIds: ['a'] }); expect(screen.getByTestId('proxmox-summary')).toHaveTextContent('Alles in Ordnung'); // Kein zusaetzlicher Abruf fuer die Auswahl expect(mockListServers).toHaveBeenCalledTimes(1); expect(mockPollServer).not.toHaveBeenCalled(); }); it('die Auswahl bekommt die ungefilterte Liste', async () => { mockListServers.mockResolvedValue([ makeServer('ok', { id: 'a', name: 'pve-a' }), makeServer('ok', { id: 'b', name: 'pve-b' }), ]); await renderWidget({ isEditMode: true, config: { serverIds: ['a'] } }); await screen.findByTestId('proxmox-summary'); fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' })); expect(screen.getByRole('checkbox', { name: /pve-a/ })).toBeChecked(); expect(screen.getByRole('checkbox', { name: /pve-b/ })).not.toBeChecked(); }); it('Verlassen des Bearbeitungsmodus schliesst die Auswahl', async () => { mockListServers.mockResolvedValue([makeServer('ok')]); const ui = (isEditMode: boolean) => ( ); let rerender!: ReturnType['rerender']; await act(async () => { ({ rerender } = rtlRender(ui(true))); }); await screen.findByTestId('proxmox-summary'); fireEvent.click(screen.getByRole('button', { name: 'Server auswählen' })); expect(screen.getByRole('group', { name: 'Angezeigte Server' })).toBeInTheDocument(); rerender(ui(false)); expect(screen.queryByRole('group', { name: 'Angezeigte Server' })).toBeNull(); expect(screen.getByTestId('proxmox-list')).toBeInTheDocument(); rerender(ui(true)); expect(screen.getByRole('button', { name: 'Server auswählen' })).toHaveAttribute( 'aria-expanded', 'false', ); }); });