feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste

- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
@@ -1,6 +1,7 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
let mockPathname = '/';
vi.mock('next/navigation', () => ({
@@ -95,6 +96,7 @@ let mockCustomModules: {
name: string;
url: string;
category: string;
sortOrder?: number | null;
createdAt: string;
updatedAt: string;
}[] = [];
@@ -102,10 +104,41 @@ vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
// Kategorien (quick-261003-387): eigener Client; Standard = die Kategorien der
// Testdaten, „Eigene Module“ zuletzt. Der Kategorienspeicher ist echt.
interface MockCategory {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
function categoriesFor(keys: string[], names: Record<string, string> = {}): MockCategory[] {
return keys.map((key, index) => ({
id: `c-${key}`,
key,
name: names[key] ?? null,
sortOrder: index,
isSystem: key === 'custom-modules',
}));
}
const DEFAULT_CATEGORY_KEYS = ['Domain-Tools', 'Utilities', 'infrastructure', 'fleet', 'custom-modules'];
let mockCategories: MockCategory[] = categoriesFor(DEFAULT_CATEGORY_KEYS);
vi.mock('@/lib/module-categories-api', () => ({
listModuleCategories: vi.fn(() => Promise.resolve(mockCategories)),
}));
let mockActiveModules: {
id: string;
slug: string;
name: string;
category: string;
sortOrder?: number | null;
}[] = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
];
const DEFAULT_ACTIVE_MODULES = [...mockActiveModules];
function stubFetch() {
vi.stubGlobal(
@@ -121,6 +154,9 @@ afterEach(() => {
mockRefreshKey = 0;
mockCustomModules = [];
mockCollapsed = false;
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS);
mockActiveModules = [...DEFAULT_ACTIVE_MODULES];
useModuleCategoryStore.setState({ categories: [], loaded: false });
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
@@ -442,6 +478,67 @@ describe('Sidebar', () => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('Gruppen folgen der Reihenfolge aus dem Kategorienspeicher', async () => {
mockCategories = categoriesFor(['Utilities', 'Domain-Tools', 'custom-modules']);
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const groups = () =>
Array.from(
screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent);
await waitFor(() => {
expect(groups()).toEqual(['Utilities', 'Domain-Tools']);
});
});
it('eine umbenannte Kategorie zeigt den gespeicherten Namen', async () => {
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS, { 'Domain-Tools': 'Meine Domains' });
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Meine Domains')).toBeInTheDocument();
});
expect(screen.queryByText('Domain-Tools')).not.toBeInTheDocument();
// Eine nicht umbenannte Kategorie behaelt ihre Beschriftung.
expect(screen.getByText('Utilities')).toBeInTheDocument();
});
it('Eintraege innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem', async () => {
mockActiveModules = [
{ id: 'a', slug: 'aaa', name: 'Aaa', category: 'fleet', sortOrder: null },
{ id: 'b', slug: 'bbb', name: 'Bbb', category: 'fleet', sortOrder: 2 },
{ id: 'c', slug: 'ccc', name: 'Ccc', category: 'fleet', sortOrder: 1 },
];
mockCustomModules = [
customModule({ id: 'cm-1', category: 'fleet', name: 'Eigenes A', sortOrder: 2 }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Eigenes B', sortOrder: null }),
];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Eigenes B')).toBeInTheDocument();
});
const items = screen
.getByText('fleet')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Ccc',
'Bbb',
'Eigenes A',
'Aaa',
'Eigenes B',
]);
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);