57c338fe53
- Statusfarben als OKLCH-Tokens (Flaeche + kontrastgepruefte Textvariante) und Well-Token - Gesundheitsbalken mit Legende und vorgelesener Zusammenfassung - Karten nach Zustand sortiert, Statusleiste, getoenter Schatten, Statuspille - Messwerte in eingelassenen Feldern: PVE-Knoten mit Balken, PBS-Fuellstand/Sicherung/Pruefung, PMG-Zahlfelder - offline & verwaist: gestrichelt, gedaempft, keine alten Messwerte - Skelett-Karten beim Laden, Leerzustand als Well, relative Zeitangaben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
9.4 KiB
TypeScript
280 lines
9.4 KiB
TypeScript
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
|
import { NextIntlClientProvider } from 'next-intl';
|
|
import type { ReactElement } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
|
import de from '@/messages/de.json';
|
|
|
|
// Echter next-intl-Provider mit den deutschen Texten (quick-260924-h7x) —
|
|
// ServerCard braucht `useLocale` und ICU-Plural.
|
|
function render(ui: ReactElement) {
|
|
return rtlRender(
|
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
|
{ui}
|
|
</NextIntlClientProvider>,
|
|
);
|
|
}
|
|
|
|
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
|
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
|
const mockListServers = vi.fn();
|
|
const mockPollServer = vi.fn();
|
|
|
|
vi.mock('@/lib/proxmox-api', () => ({
|
|
listServers: (...args: unknown[]) => mockListServers(...args),
|
|
pollServer: (...args: unknown[]) => mockPollServer(...args),
|
|
}));
|
|
|
|
// Mock the auth store — mirrors settings-roles.test.tsx (selector-passthrough).
|
|
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 }) => (
|
|
<a href={href} {...rest}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}));
|
|
|
|
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
|
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
|
selector({ user }),
|
|
);
|
|
}
|
|
|
|
// Ein nie abgefragter Server (wie im Befund-2-Test von ServerCard.test.tsx):
|
|
// leere Zwischenlagerzeile direkt nach `createServer`.
|
|
function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
|
return {
|
|
id: 'srv-1',
|
|
tenantId: 't1',
|
|
name: 'pve-1',
|
|
productType: 'pve',
|
|
baseUrl: 'https://pve.intern',
|
|
authMethod: 'token',
|
|
tokenId: 'root@pam!x',
|
|
username: null,
|
|
tlsRejectUnauthorized: true,
|
|
isActive: true,
|
|
pollIntervalMin: 5,
|
|
position: 0,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
status: {
|
|
id: 's1',
|
|
serverId: 'srv-1',
|
|
lastPolledAt: null,
|
|
lastOkAt: null,
|
|
reachable: false,
|
|
errorKind: null,
|
|
errorDetail: null,
|
|
rawSample: null,
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
metrics: null,
|
|
},
|
|
...overrides,
|
|
} as ProxmoxServer;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mockListServers.mockReset();
|
|
mockPollServer.mockReset();
|
|
mockAuthStore.mockReset();
|
|
});
|
|
|
|
describe('ProxmoxPage role gating (260923-le6)', () => {
|
|
it('Rolle USER: kein Knopf "Jetzt aktualisieren", Karten-Hinweis ist der automatische Text', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
|
|
await screen.findByText('pve-1');
|
|
|
|
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(
|
|
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
|
),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
|
|
mockUser(null);
|
|
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
|
|
await screen.findByText('pve-1');
|
|
|
|
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Rolle ADMIN: Knopf sichtbar, Karten-Hinweis ist der Admin-Text mit Knopfverweis', async () => {
|
|
mockUser({ role: 'ADMIN' });
|
|
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
|
|
await screen.findByText('pve-1');
|
|
|
|
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('Rolle SUPER_ADMIN: Knopf sichtbar', async () => {
|
|
mockUser({ role: 'SUPER_ADMIN' });
|
|
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
|
|
await screen.findByText('pve-1');
|
|
|
|
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('leere Serverliste bei ADMIN: weiterhin kein Knopf', async () => {
|
|
mockUser({ role: 'ADMIN' });
|
|
mockListServers.mockResolvedValue([]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(
|
|
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
|
|
),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// Ein erreichbarer, abgefragter Server; Zustand ueber die Ueberschreibungen.
|
|
function makePolledServer(
|
|
overrides: Partial<ProxmoxServer> = {},
|
|
statusOverrides: Record<string, unknown> = {},
|
|
) {
|
|
const base = makeUnpolledServer(overrides);
|
|
return {
|
|
...base,
|
|
status: {
|
|
...base.status,
|
|
lastPolledAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
|
lastOkAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
|
reachable: true,
|
|
...statusOverrides,
|
|
},
|
|
} as ProxmoxServer;
|
|
}
|
|
|
|
describe('ProxmoxPage — Gesundheitsbalken und Sortierung (quick-260924-h7x)', () => {
|
|
const servers = [
|
|
makePolledServer({ id: 'a', name: 'ok-server', position: 0 }),
|
|
makePolledServer(
|
|
{ id: 'b', name: 'down-server', position: 1 },
|
|
{ reachable: false, errorKind: 'netz' },
|
|
),
|
|
makePolledServer({ id: 'c', name: 'orphan-server', position: 2, isActive: false }),
|
|
makePolledServer(
|
|
{ id: 'd', name: 'warn-server', position: 3, productType: 'pmg' },
|
|
{ metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 0, virusCount: 2 } },
|
|
),
|
|
makeUnpolledServer({ id: 'e', name: 'idle-server', position: 4 }),
|
|
];
|
|
|
|
it('Karten stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockResolvedValue(servers);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
await screen.findByText('ok-server');
|
|
|
|
const order = screen.getAllByTestId('server-card').map((c) => c.getAttribute('data-health'));
|
|
expect(order).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
|
|
});
|
|
|
|
it('der Balken liest die Zusammenfassung vor und hat je belegtem Zustand ein Segment', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockResolvedValue(servers);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
await screen.findByText('ok-server');
|
|
|
|
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
|
expect(bar).toHaveAccessibleName(
|
|
'Zustand der Server: 1 nicht erreichbar, 1 mit Warnung, 1 in Ordnung, 1 noch nicht abgefragt, 1 offline & verwaist',
|
|
);
|
|
expect(Array.from(bar.children).map((s) => s.getAttribute('data-segment'))).toEqual([
|
|
'down',
|
|
'warn',
|
|
'ok',
|
|
'idle',
|
|
'orphan',
|
|
]);
|
|
});
|
|
|
|
it('leere Zustaende entfallen in Balken und Legende', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockResolvedValue([
|
|
makePolledServer({ id: 'a', name: 'eins', position: 0 }),
|
|
makePolledServer({ id: 'b', name: 'zwei', position: 1 }),
|
|
]);
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
await screen.findByText('eins');
|
|
|
|
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
|
expect(bar).toHaveAccessibleName('Zustand der Server: 2 in Ordnung');
|
|
expect(bar.children).toHaveLength(1);
|
|
const legend = within(screen.getByTestId('health-bar')).getByRole('list', { hidden: true });
|
|
expect(legend.children).toHaveLength(1);
|
|
});
|
|
|
|
it('waehrend des Ladens erscheinen Skelett-Karten ohne Daueranimation bei reduzierter Bewegung', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockReturnValue(new Promise(() => undefined));
|
|
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
const { container } = render(<ProxmoxPage />);
|
|
|
|
const busy = container.querySelector('[aria-busy="true"]');
|
|
expect(busy).not.toBeNull();
|
|
expect(busy).toHaveClass('animate-pulse', 'motion-reduce:animate-none');
|
|
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Einstellungen-Verweis nur fuer Admins', async () => {
|
|
mockUser({ role: 'USER' });
|
|
mockListServers.mockResolvedValue([makePolledServer()]);
|
|
const { default: ProxmoxPage } = await import('./page');
|
|
render(<ProxmoxPage />);
|
|
await screen.findByText('pve-1');
|
|
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
|
|
cleanup();
|
|
|
|
mockUser({ role: 'ADMIN' });
|
|
render(<ProxmoxPage />);
|
|
await screen.findByText('pve-1');
|
|
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
|
|
'href',
|
|
'/modules/proxmox/settings',
|
|
);
|
|
});
|
|
});
|