fix(web): Loeschweg der Benutzerverwaltung meldet abgewiesene Server-Antworten

WINDOWS #36, Aufgabe 1/3: handleDelete verschluckte einen 403 bisher
komplett (nur res.ok geprueft, Fang-Zweig ohne Wirkung). readApiMessage
liest jetzt gezielt das Feld message aus dem Antwortrumpf; der
Loeschdialog zeigt den Servertext, eine uebersetzte Ersatzmeldung ohne
verwertbaren Rumpf oder bei Verbindungsfehler — und bleibt in allen drei
Faellen offen. Neue Texte unter admin.users.errors in de.json/en.json,
Umlaut-Waechter gruen.

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 07:29:51 +02:00
parent 24f51e932d
commit 38d2586466
4 changed files with 339 additions and 2 deletions
@@ -0,0 +1,287 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Namespace-aware next-intl mock — same convention as
// admin/groups/groups-page.test.tsx and admin/users/user-access-modal.test.tsx.
// {param} placeholders are substituted plainly.
const messages: Record<string, Record<string, unknown>> = {
'admin.users': {
title: 'Benutzerverwaltung',
create: 'Benutzer erstellen',
edit: 'Benutzer bearbeiten',
delete: 'Benutzer löschen',
deleteConfirm: 'Möchten Sie diesen Benutzer wirklich löschen?',
name: 'Name',
username: 'Benutzername',
email: 'E-Mail',
displayName: 'Anzeigename',
role: 'Rolle',
status: 'Status',
actions: 'Aktionen',
password: 'Passwort',
noUsers: 'Keine Benutzer gefunden',
grants: {
detailsButton: 'Details',
},
errors: {
serverRejected: 'Der Server hat die Aktion abgelehnt: {detail}',
generic: 'Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.',
network: 'Der Server ist nicht erreichbar. Bitte erneut versuchen.',
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
},
},
header: {
role: {
SUPER_ADMIN: 'Super-Admin',
ADMIN: 'Admin',
USER: 'Benutzer',
},
},
common: {
loading: 'Laden...',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
accessDenied: 'Zugriff verweigert',
active: 'Aktiv',
inactive: 'Inaktiv',
},
};
function resolve(ns: string, key: string, params?: Record<string, unknown>): string {
const parts = key.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let val: any = messages[ns] ?? {};
for (const part of parts) {
val = val?.[part];
}
if (typeof val !== 'string') return key;
if (params) {
for (const [k, v] of Object.entries(params)) {
val = val.replace(`{${k}}`, String(v));
}
}
return val;
}
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
resolve(ns, key, params),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
// UserAccessModal is not exercised by these tests — stub it out so a click
// on "Details" doesn't need its own fetch fixtures.
vi.mock('./components/UserAccessModal', () => ({
UserAccessModal: () => null,
}));
import AdminUsersPage from './page';
interface User {
id: string;
username: string;
email: string | null;
displayName: string | null;
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
isActive: boolean;
tenantId: string;
createdAt: string;
}
const mockUsers: User[] = [
{
id: 'u1',
username: 'admin.eins',
email: 'admin.eins@ctl.de',
displayName: 'Admin Eins',
role: 'ADMIN',
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'u2',
username: 'user.zwei',
email: 'user.zwei@ctl.de',
displayName: 'User Zwei',
role: 'USER',
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
},
];
function stubAdmin(id = 'u1') {
mockAuthStore.mockImplementation(
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
selector({ user: { id, role: 'ADMIN', tenantId: 't1' } }),
);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('AdminUsersPage — Loeschweg (WINDOWS #36, Aufgabe 1)', () => {
it('zeigt den Servertext im offenen Loeschdialog bei einer 403-Ablehnung', async () => {
stubAdmin();
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
}
if (typeof url === 'string' && init?.method === 'DELETE') {
return Promise.resolve({
ok: false,
status: 403,
json: () =>
Promise.resolve({ statusCode: 403, message: 'Cannot delete a SUPER_ADMIN user' }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('user.zwei')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByText('Löschen');
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
await waitFor(() => {
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
});
const confirmButtons = screen.getAllByText('Löschen');
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot delete a SUPER_ADMIN user'),
).toBeInTheDocument();
});
// Dialog bleibt offen.
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
});
it('zeigt die Ersatzmeldung, wenn die Antwort keinen verwertbaren Rumpf traegt', async () => {
stubAdmin();
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
}
if (typeof url === 'string' && init?.method === 'DELETE') {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.reject(new Error('not json')),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('user.zwei')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByText('Löschen');
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
const confirmButtons = screen.getAllByText('Löschen');
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
screen.getByText('Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.'),
).toBeInTheDocument();
});
expect(screen.queryByText(/Der Server hat die Aktion abgelehnt/)).not.toBeInTheDocument();
});
it('zeigt die Netzmeldung, wenn die Verbindung beim Loeschen scheitert', async () => {
stubAdmin();
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
}
if (typeof url === 'string' && init?.method === 'DELETE') {
return Promise.reject(new Error('network down'));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('user.zwei')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByText('Löschen');
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
const confirmButtons = screen.getAllByText('Löschen');
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
screen.getByText('Der Server ist nicht erreichbar. Bitte erneut versuchen.'),
).toBeInTheDocument();
});
});
it('schliesst den Dialog und laedt die Liste neu, wenn das Loeschen gelingt (Erfolgsfall unveraendert)', async () => {
stubAdmin();
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
}
if (typeof url === 'string' && init?.method === 'DELETE') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('user.zwei')).toBeInTheDocument();
});
const deleteButtons = screen.getAllByText('Löschen');
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
await waitFor(() => {
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
});
const confirmButtons = screen.getAllByText('Löschen');
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
screen.queryByText('Möchten Sie diesen Benutzer wirklich löschen?'),
).not.toBeInTheDocument();
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
const usersCalls = fetchMock.mock.calls.filter(
(call) =>
typeof call[0] === 'string' &&
call[0].endsWith('/users') &&
(!call[1] || (call[1] as RequestInit).method === undefined),
);
// Erst-Laden + Neu-Laden nach erfolgreichem Loeschen.
expect(usersCalls.length).toBeGreaterThanOrEqual(2);
});
});