diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.test.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.test.tsx new file mode 100644 index 0000000..25141ae --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.test.tsx @@ -0,0 +1,171 @@ +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Group } from '../page'; + +/** + * GroupMembersModal.test — quick-260921-gof, Befunde 19/20. + * + * Mirrors the grants-matrix.test.tsx pattern for this admin surface: a + * global `fetch` stub keyed by URL, next-intl mocked with a flat + * key -> German-copy lookup table. Text is hand-written here, NOT derived + * from the component under test. + * + * Der entscheidende Fall ist der letzte: ein Wechsel der uebergebenen + * Gruppe (ohne Neuaufbau der Komponente — die Elternseite haengt diesen + * Dialog nicht per key neu ein) muss die Mitglieder der neuen Gruppe holen. + * Vor Befund 19/20 waere das die Falle: der Mount-Effekt haette leer + * ([]) als Abhaengigkeitsliste gefuehrt und beim Gruppenwechsel gar nicht + * neu geladen — der Dialog haette die Mitglieder der vorigen Gruppe + * gezeigt. + */ + +const messages: Record = { + 'members.title': 'Mitglieder', + 'members.noMembers': 'Keine Mitglieder', + 'members.addTitle': 'Mitglied hinzufügen', + 'members.searchPlaceholder': 'Benutzer suchen...', + 'members.remove': 'Entfernen', + 'members.ldapManaged': 'Wird per AD verwaltet', + 'members.sourceLdap': 'AD', + 'members.sourceManual': 'Manuell', + 'members.addError': 'Mitglied konnte nicht hinzugefügt werden', + loading: 'Laden...', + close: 'Schließen', +}; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => messages[key] ?? key, +})); + +import { GroupMembersModal } from './GroupMembersModal'; + +function group(overrides: Partial = {}): Group { + return { + id: 'g1', + tenantId: 't1', + name: 'Buchhaltung', + ldapDn: null, + internalName: null, + isDefault: false, + createdAt: '', + updatedAt: '', + memberCount: 1, + ...overrides, + }; +} + +const MEMBERS_G1 = [ + { + id: 'mem-1', + groupId: 'g1', + userId: 'u1', + source: 'MANUAL', + createdAt: '', + user: { id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' }, + }, +]; + +const MEMBERS_G2 = [ + { + id: 'mem-2', + groupId: 'g2', + userId: 'u2', + source: 'MANUAL', + createdAt: '', + user: { id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' }, + }, +]; + +const ALL_USERS = [ + { id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' }, + { id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' }, +]; + +function stubFetch() { + const fetchMock = vi.fn((url: string) => { + if (typeof url === 'string' && url.endsWith('/groups/g1/members')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G1) }); + } + if (typeof url === 'string' && url.endsWith('/groups/g2/members')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G2) }); + } + if (typeof url === 'string' && url.endsWith('/users')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(ALL_USERS) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('GroupMembersModal', () => { + it('einhaengen ergibt genau einen Mitglieder- und einen Benutzer-Abruf', async () => { + const fetchMock = stubFetch(); + + render( {}} onChanged={() => {}} />); + + await waitFor(() => { + expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + const memberCalls = fetchMock.mock.calls.filter(([url]) => + String(url).endsWith('/groups/g1/members'), + ); + const userCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/users')); + expect(memberCalls).toHaveLength(1); + expect(userCalls).toHaveLength(1); + }); + + it('erneutes Zeichnen mit derselben Gruppe loest KEINE weiteren Abrufe aus', async () => { + const fetchMock = stubFetch(); + + const { rerender } = render( + {}} onChanged={() => {}} />, + ); + + await waitFor(() => { + expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + const callCountBefore = fetchMock.mock.calls.length; + + rerender( {}} onChanged={() => {}} />); + + await waitFor(() => { + expect(fetchMock.mock.calls.length).toBe(callCountBefore); + }); + }); + + it('Befund 19/20: ein Wechsel der uebergebenen Gruppe (ohne Neuaufbau) loest genau einen weiteren Mitglieder-Abruf mit der neuen Kennung aus und zeigt die Mitglieder der neuen Gruppe', async () => { + stubFetch(); + + const { rerender } = render( + {}} onChanged={() => {}} />, + ); + + await waitFor(() => { + expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + // Gleiche Komponenteninstanz, neue Gruppe — genau der Fall, den die + // Elternseite (admin/groups/page.tsx) erzeugt: kein `key`-Prop, also + // kein Neuaufbau. + rerender( + {}} + onChanged={() => {}} + />, + ); + + await waitFor(() => { + expect(within(screen.getByRole('list')).getByText('Bernd Mueller')).toBeInTheDocument(); + }); + expect(within(screen.getByRole('list')).queryByText('Anna Schmidt')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx index b98ccb7..84912fa 100644 --- a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx @@ -86,12 +86,15 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod } }, []); + // Befunde 19/20 (quick-260921-gof): beide Ladefunktionen sind bereits + // stabil eingepackt (fetchMembers haengt nur an group.id, fetchAllUsers an + // nichts) — sie in die Liste aufzunehmen ist gefahrlos. Ohne sie zeigt der + // Dialog bei einem Gruppenwechsel ohne Neuaufbau (die Elternseite haengt + // diesen Dialog nicht per key neu ein) die Mitglieder der vorigen Gruppe. useEffect(() => { fetchMembers(); fetchAllUsers(); - // group.id is stable for the lifetime of this modal instance. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [fetchMembers, fetchAllUsers]); const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]); diff --git a/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx b/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx index ddd5dc1..1aad7df 100644 --- a/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx +++ b/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx @@ -64,7 +64,12 @@ export function ActivateModuleDialog({ return () => { cancelled = true; }; - }, [open, moduleId]); + // Befund 18 (quick-260921-gof): moduleId entfernt — der Effekt holt + // ohnehin nur die vom Modul unabhaengige Gruppenliste, und die + // Elternseite haengt diesen Dialog innerhalb einer Bedingung je + // gewaehltem Modul frisch ein (open dort immer fest true) — moduleId + // kann sich waehrend der Lebenszeit des Dialogs also nicht aendern. + }, [open]); if (!open) return null; diff --git a/apps/web/src/app/(portal)/admin/modules/grants/page.tsx b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx index 0891d57..78edfe0 100644 --- a/apps/web/src/app/(portal)/admin/modules/grants/page.tsx +++ b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx @@ -131,13 +131,18 @@ export default function AdminModuleGrantsPage() { }; const searchLower = search.trim().toLowerCase(); - const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower); // Both axes are searched independently, then combined with the following // semantics (WINDOWS #14): a term that hits only one axis must not empty // the other axis, or no clickable cell ever remains. See in // 260909-ab3-PLAN.md for the full truth table. + // + // Befund 21 (quick-260921-gof): `matches` lebt jetzt im Rumpf der Merkung + // selbst statt im Bauteil-Rumpf — sie las bereits nur searchLower, das + // schon in der Liste steht; verschoben wird nur, WO die Funktion steht. const { filteredModules, filteredGroups, noMatch } = useMemo(() => { + const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower); + if (searchLower === '') { return { filteredModules: modules, filteredGroups: groups, noMatch: false }; } @@ -158,7 +163,6 @@ export default function AdminModuleGrantsPage() { return { filteredModules: modules, filteredGroups: groupHits, noMatch: false }; } return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [modules, groups, searchLower]); const groupedModules = useMemo(() => { diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx index f76d2bd..53da372 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx @@ -78,6 +78,10 @@ export function InvoiceHistoryTable({ } }, [onItemsLoaded]); + // Befund 16 (quick-260921-gof): refreshKey ist der Auffrisch-Ausloeser der + // Elternseite nach "Jetzt pruefen" — ohne ihn bliebe die Historie auf dem + // alten Stand stehen. + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey ist der Auffrisch-Ausloeser der Elternseite nach "Jetzt pruefen" - ohne ihn bliebe die Historie auf dem alten Stand. useEffect(() => { load(1); }, [load, refreshKey]); diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx index 1b852ef..4f78d17 100644 --- a/apps/web/src/components/layout/sidebar.test.tsx +++ b/apps/web/src/components/layout/sidebar.test.tsx @@ -185,6 +185,29 @@ describe('Sidebar', () => { }); }); + it('quick-260921-gof (Befund 17): ein Bump von sidebarRefreshKey loest genau einen weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen', async () => { + const Sidebar = await importSidebar(); + const { rerender } = render(); + + await waitFor(() => { + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); + }); + + // Erneutes Zeichnen ohne Bump des Auffrisch-Zaehlers loest KEINEN + // weiteren Abruf aus. + rerender(); + await waitFor(() => { + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); + }); + + // Ein Bump loest genau einen weiteren Abruf aus. + mockRefreshKey = 1; + rerender(); + await waitFor(() => { + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(2); + }); + }); + it('renders the version badge below the navigation', async () => { const Sidebar = await importSidebar(); render(); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index f89e619..6940364 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -54,6 +54,11 @@ export function Sidebar() { } }, []); + // Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch- + // Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch + // aktiviertes Modul in der Seitenleiste erst nach einem Neuladen der + // Seite, die Navigation liefe also dem Berechtigungsstand hinterher. + // biome-ignore lint/correctness/useExhaustiveDependencies: sidebarRefreshKey ist der Auffrisch-Ausloeser aus dem Marketplace-Speicher - ohne ihn liefe die Navigation dem Berechtigungsstand hinterher. useEffect(() => { fetchActiveModules(); }, [fetchActiveModules, sidebarRefreshKey]);