diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts new file mode 100644 index 0000000..46a93a7 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest'; +import type { ProxmoxMetrics, ProxmoxServer } from '@/lib/proxmox-api'; +import { + healthSummary, + resolveProxmoxWidgetConfig, + selectServers, + widgetKeyFigure, +} from './proxmox-widget-model'; + +const NOW = Date.parse('2026-09-24T12:00:00.000Z'); +const NOW_S = NOW / 1000; +const POLLED_AT = '2026-09-24T11:58:00.000Z'; + +function server( + overrides: Partial & { + metrics?: ProxmoxMetrics | null; + reachable?: boolean; + polled?: boolean; + } = {}, +): ProxmoxServer { + const { metrics = null, reachable = true, polled = true, ...rest } = overrides; + const id = rest.id ?? 'srv'; + return { + id, + tenantId: 't1', + name: id, + productType: metrics?.productType ?? 'pve', + baseUrl: 'https://x.intern', + authMethod: 'token', + tokenId: 'a@pam!b', + username: null, + tlsRejectUnauthorized: true, + isActive: true, + pollIntervalMin: 5, + position: 0, + createdAt: POLLED_AT, + updatedAt: POLLED_AT, + status: { + id: `st-${id}`, + serverId: id, + lastPolledAt: polled ? POLLED_AT : null, + lastOkAt: reachable ? POLLED_AT : null, + reachable, + errorKind: reachable ? null : 'netz', + errorDetail: null, + rawSample: null, + updatedAt: POLLED_AT, + metrics, + }, + ...rest, + }; +} + +function pve( + overrides: Partial> = {}, +): ProxmoxMetrics { + return { + productType: 'pve', + nodeCount: 1, + guestsRunning: 3, + guestsStopped: 1, + nodes: [{ node: 'n1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 10 }], + storages: [{ storage: 'local', node: 'n1', disk: 1, maxdisk: 10 }], + ...overrides, + }; +} + +function pbsDatastore(lastBackupAt: number | null) { + return { + name: `ds-${lastBackupAt}`, + total: 100, + used: 10, + free: 90, + lastBackupAt, + lastVerifyState: 'ok', + }; +} + +describe('healthSummary', () => { + it('alle ok -> allOk, keine Eintraege, worst ok', () => { + expect(healthSummary({ down: 0, warn: 0, ok: 3, idle: 0, orphan: 0 })).toEqual({ + allOk: true, + entries: [], + worst: 'ok', + }); + }); + + it('Abweichungen in HEALTH_ORDER, ok wird nicht genannt, worst = schlimmster', () => { + expect(healthSummary({ down: 1, warn: 2, ok: 5, idle: 0, orphan: 1 })).toEqual({ + allOk: false, + entries: [ + { health: 'down', count: 1 }, + { health: 'warn', count: 2 }, + { health: 'orphan', count: 1 }, + ], + worst: 'down', + }); + }); + + it('keine Server -> nicht allOk, worst null', () => { + expect(healthSummary({ down: 0, warn: 0, ok: 0, idle: 0, orphan: 0 })).toEqual({ + allOk: false, + entries: [], + worst: null, + }); + }); +}); + +describe('resolveProxmoxWidgetConfig', () => { + it('leere Konfiguration -> leerer Titel, keine Auswahl', () => { + expect(resolveProxmoxWidgetConfig({})).toEqual({ title: '', serverIds: [] }); + }); + + it('Titel, der kein String ist, wird leer', () => { + expect(resolveProxmoxWidgetConfig({ title: 42 }).title).toBe(''); + expect(resolveProxmoxWidgetConfig({ title: null }).title).toBe(''); + }); + + it('Titel als String bleibt erhalten', () => { + expect(resolveProxmoxWidgetConfig({ title: 'Rechenzentrum' }).title).toBe('Rechenzentrum'); + }); + + it('serverIds, das kein Array ist, wird leer', () => { + expect(resolveProxmoxWidgetConfig({ serverIds: 'a' }).serverIds).toEqual([]); + expect(resolveProxmoxWidgetConfig({ serverIds: { a: 1 } }).serverIds).toEqual([]); + }); + + it('Nicht-Strings und leere Strings fallen weg, doppelte Kennungen einmal', () => { + expect( + resolveProxmoxWidgetConfig({ serverIds: ['a', 1, '', null, 'b', 'a', { id: 'c' }] }) + .serverIds, + ).toEqual(['a', 'b']); + }); +}); + +describe('selectServers', () => { + const list = [server({ id: 'a' }), server({ id: 'b' }), server({ id: 'c' })]; + + it('leere Auswahl liefert alle Server', () => { + expect(selectServers(list, [])).toEqual({ servers: list, selectionGone: false }); + }); + + it('Auswahl filtert nach Kennung', () => { + const result = selectServers(list, ['c', 'a']); + expect(result.servers.map((s) => s.id)).toEqual(['a', 'c']); + expect(result.selectionGone).toBe(false); + }); + + it('nur noch geloeschte Kennungen ausgewaehlt -> leere Liste, selectionGone', () => { + expect(selectServers(list, ['weg', 'auch-weg'])).toEqual({ + servers: [], + selectionGone: true, + }); + }); + + it('eine geloeschte und eine vorhandene Kennung -> nur die vorhandene', () => { + const result = selectServers(list, ['weg', 'b']); + expect(result.servers.map((s) => s.id)).toEqual(['b']); + expect(result.selectionGone).toBe(false); + }); +}); + +describe('widgetKeyFigure', () => { + it('down, idle, orphan liefern den Zustand, auch mit alten Messwerten', () => { + expect(widgetKeyFigure(server({ reachable: false, metrics: pve() }), NOW)).toEqual({ + kind: 'status', + health: 'down', + }); + expect(widgetKeyFigure(server({ polled: false }), NOW)).toEqual({ + kind: 'status', + health: 'idle', + }); + expect(widgetKeyFigure(server({ isActive: false, metrics: pve() }), NOW)).toEqual({ + kind: 'status', + health: 'orphan', + }); + }); + + it('erreichbarer Server ohne Messwerte -> unknown', () => { + expect(widgetKeyFigure(server({ metrics: null }), NOW)).toEqual({ kind: 'unknown' }); + }); + + it('PVE ok -> laufende Gaeste, total = laufend + gestoppt', () => { + expect(widgetKeyFigure(server({ metrics: pve() }), NOW)).toEqual({ + kind: 'guests', + running: 3, + total: 4, + }); + }); + + it('PVE ok ohne Gaeste -> noGuests', () => { + expect( + widgetKeyFigure(server({ metrics: pve({ guestsRunning: 0, guestsStopped: 0 }) }), NOW), + ).toEqual({ kind: 'noGuests' }); + }); + + it('PVE mit unbekannter Gaestezahl -> unknown, nie 0', () => { + const metrics = pve({ + guestsRunning: null as unknown as number, + guestsStopped: 1, + }); + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' }); + }); + + it('PVE warn -> hoechster bekannter Anteil aus CPU, RAM und Speicher', () => { + const metrics = pve({ + nodes: [ + { node: 'n1', cpu: 0.5, maxcpu: 8, mem: 87, maxmem: 100 }, + { node: 'n2', cpu: null, maxcpu: 8, mem: null, maxmem: 100 }, + ], + storages: [{ storage: 's', node: 'n1', disk: 70, maxdisk: 100 }], + }); + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ + kind: 'load', + fraction: 0.87, + level: 'warn', + }); + }); + + it('PVE warn durch Speicher -> Anteil des Speichers mit Stufe crit', () => { + const metrics = pve({ + nodes: [{ node: 'n1', cpu: 0.2, maxcpu: 8, mem: 1, maxmem: 10 }], + storages: [{ storage: 's', node: 'n1', disk: 95, maxdisk: 100 }], + }); + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ + kind: 'load', + fraction: 0.95, + level: 'crit', + }); + }); + + it('PBS -> aelteste bekannte letzte Sicherung samt stale-Flag', () => { + const old = NOW_S - 30 * 3600; + const fresh = NOW_S - 2 * 3600; + const metrics: ProxmoxMetrics = { + productType: 'pbs', + datastores: [pbsDatastore(fresh), pbsDatastore(null), pbsDatastore(old)], + }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ + kind: 'backup', + at: old, + stale: true, + }); + }); + + it('PBS mit frischer Sicherung -> nicht stale', () => { + const fresh = NOW_S - 5 * 3600; + const metrics: ProxmoxMetrics = { productType: 'pbs', datastores: [pbsDatastore(fresh)] }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ + kind: 'backup', + at: fresh, + stale: false, + }); + }); + + it('PBS: Datenspeicher ohne jede Sicherung -> noBackup', () => { + const metrics: ProxmoxMetrics = { + productType: 'pbs', + datastores: [pbsDatastore(null), pbsDatastore(null)], + }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'noBackup' }); + }); + + it('PBS ohne Datenspeicher -> unknown', () => { + const metrics: ProxmoxMetrics = { productType: 'pbs', datastores: [] }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' }); + }); + + it('PMG -> eingehende Mails aus countIn', () => { + const metrics: ProxmoxMetrics = { + productType: 'pmg', + countIn: 12904, + countOut: 5, + spamCount: 1, + virusCount: 0, + }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'mailIn', count: 12904 }); + }); + + it('PMG mit countIn null -> unknown, nie 0', () => { + const metrics: ProxmoxMetrics = { + productType: 'pmg', + countIn: null, + countOut: null, + spamCount: null, + virusCount: null, + }; + expect(widgetKeyFigure(server({ metrics }), NOW)).toEqual({ kind: 'unknown' }); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx index 8f77af2..fc724f8 100644 --- a/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, 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'; @@ -231,3 +231,293 @@ describe('ProxmoxWidget: Lade-, Leer- und Fehlerzustand (quick-260924-i8v)', () 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(); + }); +}); diff --git a/apps/web/src/components/proxmox/proxmox-status.test.ts b/apps/web/src/components/proxmox/proxmox-status.test.ts index 18dbe31b..92854e8 100644 --- a/apps/web/src/components/proxmox/proxmox-status.test.ts +++ b/apps/web/src/components/proxmox/proxmox-status.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { ProxmoxMetrics, ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api'; import { formatAge, + formatCount, + formatPercent, HEALTH_ORDER, isBackupStale, meterLevel, @@ -307,3 +309,14 @@ describe('formatAge', () => { expect(formatAge('2026-09-24T11:57:00.000Z', NOW, 'en')).toBe('3 min. ago'); }); }); + +// quick-260924-i8v: Zahlformat EINMAL, gemeinsam fuer ServerCard und Kachel. +describe('formatPercent / formatCount', () => { + it('deutsches Prozentformat mit geschuetztem Leerzeichen', () => { + expect(formatPercent(0.87, 'de')).toMatch(/^87\s%$/); + }); + + it('deutsches Tausendertrennzeichen', () => { + expect(formatCount(12904, 'de')).toBe('12.904'); + }); +});