feat(260924-i8v): Proxmox-Kachel als Durchstich mit Balken und Zusammenfassung
- Typ proxmox am Ende von WIDGET_TYPES, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }
- gemeinsame Statusteile (proxmox-status, HealthBar, status-styles) nach components/proxmox/
- HealthBar variant compact: 6 px, ohne Legende, aria-hidden
- Registry 3/4/8/8, Katalog-/Registry-/API-Tests auf die erste Modul-Kachel umgestellt
- ProxmoxWidget liest nur listServers, zeigt Lade-, Leer-, Fehlerzustand
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { cleanup, render as rtlRender, screen, waitFor } 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';
|
||||
|
||||
// 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(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }) => (
|
||||
<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 }),
|
||||
);
|
||||
}
|
||||
|
||||
type Health = 'ok' | 'warn' | 'down' | 'idle' | 'orphan';
|
||||
|
||||
const POLLED_AT = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
||||
|
||||
function pveMetrics(overrides: Partial<Extract<ProxmoxMetrics, { productType: 'pve' }>> = {}) {
|
||||
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<ProxmoxServer> & { 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,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderWidget(
|
||||
props: { config?: Record<string, unknown>; isEditMode?: boolean } = {},
|
||||
) {
|
||||
const { ProxmoxWidget } = await import('./proxmox-widget');
|
||||
return render(
|
||||
<ProxmoxWidget
|
||||
instanceId="w-1"
|
||||
config={props.config ?? {}}
|
||||
isEditMode={props.isEditMode ?? false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user