32441d77c7
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar), GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile, Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig). Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste: Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert apps/api/assets. Lokal per MailHog nachgewiesen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
744 lines
28 KiB
TypeScript
744 lines
28 KiB
TypeScript
import { cleanup, render, screen, waitFor, within } 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',
|
||
lastLogin: 'Letzte Anmeldung',
|
||
neverLoggedIn: 'Noch nie',
|
||
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.',
|
||
},
|
||
},
|
||
'admin.users.welcomeMail': {
|
||
button: 'Willkommensmail senden',
|
||
resend: 'Erneut senden',
|
||
sentAt: 'Willkommensmail gesendet am {date}',
|
||
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
|
||
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||
inactive: 'Das Konto ist deaktiviert.',
|
||
dialogTitle: 'Willkommensmail senden',
|
||
dialogText: 'Tessera schickt eine Willkommensmail an:',
|
||
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
|
||
send: 'Senden',
|
||
sending: 'Wird gesendet …',
|
||
success: 'Die Willkommensmail an {email} wurde gesendet.',
|
||
},
|
||
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',
|
||
close: 'Schließen',
|
||
},
|
||
};
|
||
|
||
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),
|
||
useLocale: () => 'de',
|
||
}));
|
||
|
||
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;
|
||
lastLoginAt?: string | null;
|
||
welcomeMailSentAt?: string | null;
|
||
}
|
||
|
||
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', role = 'ADMIN') {
|
||
mockAuthStore.mockImplementation(
|
||
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
||
selector({ user: { id, role, tenantId: 't1' } }),
|
||
);
|
||
}
|
||
|
||
const mockUsersWithSuperAdmin: User[] = [
|
||
{
|
||
id: 'u0',
|
||
username: 'super.null',
|
||
email: 'super.null@ctl.de',
|
||
displayName: 'Super Null',
|
||
role: 'SUPER_ADMIN',
|
||
isActive: true,
|
||
tenantId: 't1',
|
||
createdAt: '2026-01-01T00:00:00.000Z',
|
||
},
|
||
{
|
||
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',
|
||
},
|
||
];
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2)', () => {
|
||
it('zeigt den Servertext im offenen Formular, wenn das Speichern abgewiesen wird', 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 === 'PATCH') {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 403,
|
||
json: () => Promise.resolve({ message: 'Cannot modify 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 editButtons = screen.getAllByText('Bearbeiten');
|
||
await userEvent.click(editButtons[0]);
|
||
|
||
const usernameInput = await screen.findByDisplayValue('admin.eins');
|
||
|
||
const saveButton = screen.getByText('Speichern');
|
||
await userEvent.click(saveButton);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
|
||
).toBeInTheDocument();
|
||
});
|
||
// Formular bleibt offen.
|
||
expect(usernameInput).toBeInTheDocument();
|
||
});
|
||
|
||
it('verbindet Pruefmeldungen aus einem Feld mit Komma im Rahmensatz', 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 === 'PATCH') {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 400,
|
||
json: () =>
|
||
Promise.resolve({ message: ['username must be longer', 'email must be an email'] }),
|
||
});
|
||
}
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
render(<AdminUsersPage />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('user.zwei')).toBeInTheDocument();
|
||
});
|
||
|
||
const editButtons = screen.getAllByText('Bearbeiten');
|
||
await userEvent.click(editButtons[0]);
|
||
await screen.findByDisplayValue('admin.eins');
|
||
|
||
await userEvent.click(screen.getByText('Speichern'));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText(
|
||
'Der Server hat die Aktion abgelehnt: username must be longer, email must be an email',
|
||
),
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('zeigt die Netzmeldung im offenen Formular, wenn die Verbindung beim Speichern 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 === 'PATCH') {
|
||
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 editButtons = screen.getAllByText('Bearbeiten');
|
||
await userEvent.click(editButtons[0]);
|
||
const usernameInput = await screen.findByDisplayValue('admin.eins');
|
||
|
||
await userEvent.click(screen.getByText('Speichern'));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText('Der Server ist nicht erreichbar. Bitte erneut versuchen.'),
|
||
).toBeInTheDocument();
|
||
});
|
||
expect(usernameInput).toBeInTheDocument();
|
||
});
|
||
|
||
it('meldet ein gescheitertes Laden im Listenkopf statt faelschlich "Keine Benutzer gefunden" zu zeigen', 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: 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('Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.'),
|
||
).toBeInTheDocument();
|
||
});
|
||
expect(screen.queryByText('Keine Benutzer gefunden')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('laesst eine Meldung nicht das Schliessen und erneute Oeffnen des Formulars ueberdauern', 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 === 'PATCH') {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 403,
|
||
json: () => Promise.resolve({ message: 'Cannot modify 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 editButtons = screen.getAllByText('Bearbeiten');
|
||
await userEvent.click(editButtons[0]);
|
||
await screen.findByDisplayValue('admin.eins');
|
||
await userEvent.click(screen.getByText('Speichern'));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
await userEvent.click(screen.getByText('Abbrechen'));
|
||
await waitFor(() => {
|
||
expect(screen.queryByDisplayValue('admin.eins')).not.toBeInTheDocument();
|
||
});
|
||
|
||
const editButtonsAgain = screen.getAllByText('Bearbeiten');
|
||
await userEvent.click(editButtonsAgain[0]);
|
||
await screen.findByDisplayValue('admin.eins');
|
||
|
||
expect(
|
||
screen.queryByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
|
||
).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36, Aufgabe 3)', () => {
|
||
function stubList() {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsersWithSuperAdmin) })),
|
||
);
|
||
}
|
||
|
||
it('bietet einem ADMIN in der SUPER_ADMIN-Zeile weder Bearbeiten noch Loeschen an, in einer normalen Zeile beide', async () => {
|
||
stubAdmin('u1', 'ADMIN');
|
||
stubList();
|
||
|
||
render(<AdminUsersPage />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('super.null')).toBeInTheDocument();
|
||
});
|
||
|
||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||
expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
|
||
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
|
||
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
|
||
|
||
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
|
||
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
|
||
stubAdmin('u1', 'SUPER_ADMIN');
|
||
stubList();
|
||
|
||
render(<AdminUsersPage />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('super.null')).toBeInTheDocument();
|
||
});
|
||
|
||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
|
||
stubAdmin('u1', 'ADMIN');
|
||
stubList();
|
||
|
||
render(<AdminUsersPage />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('admin.eins')).toBeInTheDocument();
|
||
});
|
||
|
||
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
|
||
expect(ownDeleteButton).toBeInTheDocument();
|
||
expect(ownDeleteButton).toBeDisabled();
|
||
});
|
||
});
|
||
|
||
describe('AdminUsersPage — Willkommensmail', () => {
|
||
const base = {
|
||
role: 'USER' as const,
|
||
isActive: true,
|
||
tenantId: 't1',
|
||
createdAt: '2026-01-01T00:00:00.000Z',
|
||
welcomeMailSentAt: null,
|
||
};
|
||
const welcomeUsers: User[] = [
|
||
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
|
||
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
|
||
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
|
||
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||
];
|
||
|
||
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
|
||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||
if (url.endsWith('/users/welcome-mail/status')) {
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
|
||
}
|
||
if (url.endsWith('/users') && (!init || init.method === undefined)) {
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
|
||
}
|
||
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
|
||
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
|
||
}
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
return fetchMock;
|
||
}
|
||
|
||
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
|
||
stubAdmin();
|
||
stubFetch();
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
|
||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||
|
||
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
|
||
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||
|
||
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
|
||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
|
||
|
||
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
|
||
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
|
||
expect(btn).toBeDisabled();
|
||
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
|
||
});
|
||
|
||
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
|
||
stubAdmin();
|
||
stubFetch();
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
|
||
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
|
||
|
||
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||
new Date('2026-09-01T08:00:00.000Z'),
|
||
);
|
||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
|
||
});
|
||
|
||
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
|
||
stubAdmin();
|
||
stubFetch({ available: false });
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
|
||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
|
||
'title',
|
||
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||
);
|
||
});
|
||
|
||
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
|
||
stubAdmin();
|
||
const fetchMock = stubFetch();
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||
|
||
const dialog = await screen.findByRole('dialog');
|
||
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
|
||
await userEvent.click(within(dialog).getByText('Senden'));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
|
||
});
|
||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
|
||
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
|
||
});
|
||
|
||
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
|
||
stubAdmin();
|
||
stubFetch({
|
||
post: () =>
|
||
Promise.resolve({
|
||
ok: false,
|
||
status: 409,
|
||
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
|
||
}),
|
||
});
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||
const dialog = await screen.findByRole('dialog');
|
||
await userEvent.click(within(dialog).getByText('Senden'));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
|
||
).toBeInTheDocument();
|
||
});
|
||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||
});
|
||
|
||
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
|
||
stubAdmin();
|
||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
|
||
stubFetch();
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
|
||
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
|
||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
|
||
});
|
||
|
||
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
|
||
stubAdmin('u9', 'SUPER_ADMIN');
|
||
stubFetch();
|
||
render(<AdminUsersPage />);
|
||
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
|
||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
|
||
});
|
||
});
|