Files
tessera-ctl/apps/web/src/app/(portal)/admin/users/users-page.test.tsx
T
schalli 32441d77c7
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
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>
2026-09-30 15:28:52 +02:00

744 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});