fix(web): Absicht/Ballast der letzten sechs useExhaustiveDependencies-Befunde entschieden

Befunde 16-21 (quick-260921-gof), Regel-Gesamtstand jetzt 0/446 (vorher
21/467), pnpm lint 5/5, pnpm type-check 4/4:

- InvoiceHistoryTable.tsx (16) und sidebar.tsx (17): refreshKey /
  sidebarRefreshKey bleiben als begruendete Auffrisch-Ausloeser stehen
  (biome-ignore mit deutschem Grund) - ohne sie zeigt die DKV-Historie
  nach "Jetzt pruefen" bzw. die Seitenleiste nach einer Modul-
  Aktivierung den alten Stand.
- ActivateModuleDialog.tsx (18): moduleId aus der Abhaengigkeitsliste
  entfernt - reiner Ballast, der Effekt holt ohnehin nur die vom Modul
  unabhaengige Gruppenliste und die Elternseite haengt den Dialog je
  Modul frisch ein.
- GroupMembersModal.tsx (19/20): fetchMembers/fetchAllUsers in die
  Liste aufgenommen (echter Defekt) - ohne sie zeigt der Dialog bei
  einem Gruppenwechsel ohne Neuaufbau die Mitglieder der vorigen
  Gruppe. Neue Testdatei nach dem grants-matrix.test.tsx-Muster belegt
  genau diesen Fall.
- grants/page.tsx (21): die Suchabgleich-Hilfsfunktion `matches` in den
  Merkungs-Rumpf verschoben statt im Bauteil-Rumpf zu bleiben - reiner
  Ballast, die bestehenden Suchfaelle bleiben unveraendert gruen.
- sidebar.test.tsx um eine Zaehlprobe erweitert: Bump loest genau einen
  weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen.
- Alle drei begruendeten biome-ignore-Zeilen sind jetzt gesetzt (erste
  Verwendung dieses Mechanismus im Projekt), alle wirkungslosen
  eslint-disable-Zeilen fuer diese Regel sind aus apps/web/src
  verschwunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 12:34:55 +02:00
parent e2c508cff5
commit e780b2cc69
7 changed files with 221 additions and 6 deletions
@@ -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<string, string> = {
'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> = {}): 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(<GroupMembersModal group={group()} onClose={() => {}} 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(
<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />,
);
await waitFor(() => {
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
});
const callCountBefore = fetchMock.mock.calls.length;
rerender(<GroupMembersModal group={group()} onClose={() => {}} 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(
<GroupMembersModal group={group()} onClose={() => {}} 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(
<GroupMembersModal
group={group({ id: 'g2', name: 'Vertrieb' })}
onClose={() => {}}
onChanged={() => {}}
/>,
);
await waitFor(() => {
expect(within(screen.getByRole('list')).getByText('Bernd Mueller')).toBeInTheDocument();
});
expect(within(screen.getByRole('list')).queryByText('Anna Schmidt')).not.toBeInTheDocument();
});
});
@@ -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(() => { useEffect(() => {
fetchMembers(); fetchMembers();
fetchAllUsers(); fetchAllUsers();
// group.id is stable for the lifetime of this modal instance. }, [fetchMembers, fetchAllUsers]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]); const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]);
@@ -64,7 +64,12 @@ export function ActivateModuleDialog({
return () => { return () => {
cancelled = true; 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; if (!open) return null;
@@ -131,13 +131,18 @@ export default function AdminModuleGrantsPage() {
}; };
const searchLower = search.trim().toLowerCase(); const searchLower = search.trim().toLowerCase();
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
// Both axes are searched independently, then combined with the following // Both axes are searched independently, then combined with the following
// semantics (WINDOWS #14): a term that hits only one axis must not empty // semantics (WINDOWS #14): a term that hits only one axis must not empty
// the other axis, or no clickable cell ever remains. See <behavior> in // the other axis, or no clickable cell ever remains. See <behavior> in
// 260909-ab3-PLAN.md for the full truth table. // 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 { filteredModules, filteredGroups, noMatch } = useMemo(() => {
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
if (searchLower === '') { if (searchLower === '') {
return { filteredModules: modules, filteredGroups: groups, noMatch: false }; return { filteredModules: modules, filteredGroups: groups, noMatch: false };
} }
@@ -158,7 +163,6 @@ export default function AdminModuleGrantsPage() {
return { filteredModules: modules, filteredGroups: groupHits, noMatch: false }; return { filteredModules: modules, filteredGroups: groupHits, noMatch: false };
} }
return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false }; return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modules, groups, searchLower]); }, [modules, groups, searchLower]);
const groupedModules = useMemo(() => { const groupedModules = useMemo(() => {
@@ -78,6 +78,10 @@ export function InvoiceHistoryTable({
} }
}, [onItemsLoaded]); }, [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(() => { useEffect(() => {
load(1); load(1);
}, [load, refreshKey]); }, [load, refreshKey]);
@@ -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(<Sidebar />);
await waitFor(() => {
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
// Erneutes Zeichnen ohne Bump des Auffrisch-Zaehlers loest KEINEN
// weiteren Abruf aus.
rerender(<Sidebar />);
await waitFor(() => {
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
// Ein Bump loest genau einen weiteren Abruf aus.
mockRefreshKey = 1;
rerender(<Sidebar />);
await waitFor(() => {
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
});
});
it('renders the version badge below the navigation', async () => { it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
render(<Sidebar />); render(<Sidebar />);
@@ -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(() => { useEffect(() => {
fetchActiveModules(); fetchActiveModules();
}, [fetchActiveModules, sidebarRefreshKey]); }, [fetchActiveModules, sidebarRefreshKey]);