feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung

- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -1,4 +1,11 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import {
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';
@@ -15,12 +22,29 @@ function render(ui: ReactElement) {
}
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = 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) };
return {
...actual,
listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...args),
};
});
let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: 'u1' } }),
}));
function makeInstance(
over: Partial<NextcloudInstance> & { id: string; customerName: string },
): NextcloudInstance {
@@ -101,6 +125,10 @@ const LIST: NextcloudList = {
describe('NextcloudStatusPage', () => {
beforeEach(() => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockCanManage = false;
window.localStorage.clear();
});
afterEach(() => cleanup());
@@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => {
const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument();
});
describe('Rechte und Sortierung', () => {
const tileNames = () =>
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
});
it('Verwalter sehen alle Knöpfe', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
});
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
});
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
});
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
expect(mockCheckAll).toHaveBeenCalledTimes(1);
});
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckOne.mockResolvedValue({
...LIST.instances[2],
customerName: 'Kunde Rot',
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
});
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
const red = tiles.find((t) =>
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
) as HTMLElement;
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
await waitFor(() =>
expect(
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
).toHaveAttribute('data-level', 'green'),
);
});
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
});