feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht

- NextcloudInstance mit Zeilenschutz, Migration 20261002150000
- Bewertung (Ampel) als reine Funktion, status.php-Abruf mit Grenzen, endoflife.date-Zwischenspeicher
- API: Liste mit Bewertung, Anlegen und Einzelprüfung nur mit Verwalten
- Modulseite mit Kacheln, Registrierung in Seitenleiste, Marktplatz und Symbolen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:49:02 +02:00
parent 7188733f70
commit 5ef7b0c6cc
31 changed files with 2513 additions and 2 deletions
@@ -0,0 +1,170 @@
import { 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';
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(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockListInstances = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
});
function makeInstance(
over: Partial<NextcloudInstance> & { 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();
});
afterEach(() => cleanup());
it('zeigt je Cloud eine Kachel mit Name, Version, Klartext und Verweis', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
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(<NextcloudStatusPage />);
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(<NextcloudStatusPage />);
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
'Versionsdaten derzeit nicht verfügbar',
);
});
it('zeigt den Leerzustand ohne Clouds', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [] });
render(<NextcloudStatusPage />);
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(<NextcloudStatusPage />);
await waitFor(() =>
expect(screen.getByText(/konnten nicht geladen werden/)).toBeInTheDocument(),
);
});
it('verwendet ohne Logo Initialen', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument();
});
});