Files
familienarchiv/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
2026-05-05 14:35:15 +02:00

279 lines
10 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const cancelMock = vi.hoisted(() => vi.fn());
vi.mock('$app/forms', () => ({
enhance: (form: HTMLFormElement, callback?: (args: { cancel: () => void }) => Promise<void>) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (callback) await callback({ cancel: cancelMock });
});
return () => {};
}
}));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
const groups = [
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const makeUser = (overrides = {}) => ({
id: 'u1',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
birthDate: '1985-03-22',
contact: 'Tel: 0123',
enabled: true,
groups: [{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }],
createdAt: '2024-01-01T00:00:00Z',
...overrides
});
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
editUser: makeUser(),
groups
};
type PageProps = { data: typeof baseData; form: Record<string, unknown> | null };
function renderPage(props: PageProps) {
const service = createConfirmService();
const result = render(Page, {
props,
context: new Map([[CONFIRM_KEY, service]])
});
return { ...result, service };
}
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page rendering', () => {
it('renders the heading with email', async () => {
renderPage({ data: baseData, form: null });
await expect
.element(page.getByText(/Benutzer bearbeiten: max@example.com/i))
.toBeInTheDocument();
});
it('pre-fills first name from editUser data', async () => {
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="firstName"]');
expect(input?.value).toBe('Max');
});
it('pre-fills last name from editUser data', async () => {
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="lastName"]');
expect(input?.value).toBe('Mustermann');
});
it('pre-fills email from editUser data', async () => {
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.value).toBe('max@example.com');
});
it('pre-fills birth date in German format (dd.mm.yyyy)', async () => {
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[placeholder="TT.MM.JJJJ"]');
expect(input?.value).toBe('22.03.1985');
});
it('pre-fills contact field', async () => {
renderPage({ data: baseData, form: null });
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="contact"]');
expect(textarea?.value).toBe('Tel: 0123');
});
it('renders group checkboxes', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('pre-selects the groups the user already belongs to', async () => {
renderPage({ data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g1"]'
);
expect(checkbox?.checked).toBe(true);
});
it('does not pre-select groups the user does not belong to', async () => {
renderPage({ data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g2"]'
);
expect(checkbox?.checked).toBe(false);
});
it('includes pre-selected group ids in FormData at submit time (guards against groupIds being empty)', async () => {
renderPage({ data: baseData, form: null });
const form = document.querySelector<HTMLFormElement>('form#edit-user-form')!;
const formData = new FormData(form);
expect(formData.getAll('groupIds')).toContain('g1');
expect(formData.getAll('groupIds')).not.toContain('g2');
});
it('password fields are empty by default', async () => {
renderPage({ data: baseData, form: null });
const passwordInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
passwordInputs.forEach((input) => {
expect(input.value).toBe('');
});
});
it('cancel link points to /admin/users', async () => {
renderPage({ data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin/users');
});
it('renders the save button', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument();
});
});
// ─── Feedback messages ────────────────────────────────────────────────────────
describe('Admin edit user page feedback', () => {
it('shows success message when form.success is true', async () => {
renderPage({ data: baseData, form: { success: true } });
await expect.element(page.getByText(/Änderungen gespeichert/i)).toBeInTheDocument();
});
it('shows error message when form.error is set', async () => {
renderPage({ data: baseData, form: { error: 'Ungültige Eingabe.' } });
await expect.element(page.getByText('Ungültige Eingabe.')).toBeInTheDocument();
});
it('does not show success message when form is null', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument();
});
});
// ─── Delete confirmation ──────────────────────────────────────────────────────
describe('Admin edit user page delete confirmation', () => {
beforeEach(() => cancelMock.mockClear());
it('delete button has type=submit', async () => {
renderPage({ data: baseData, form: null });
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
expect(deleteBtn.type).toBe('submit');
});
it('calls cancel() and does not submit when user cancels', async () => {
const { service } = renderPage({ data: baseData, form: null });
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(cancelMock).toHaveBeenCalledOnce();
});
it('does not call cancel() and allows submit when user confirms', async () => {
const { service } = renderPage({ data: baseData, form: null });
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(cancelMock).not.toHaveBeenCalled();
});
});
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
describe('Admin edit user page unsaved-changes guard', () => {
beforeEach(() => vi.clearAllMocks());
it('does not show unsaved warning initially', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows warning when form is dirty', async () => {
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="firstName"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users/u2') } });
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
});
it('does not cancel navigation when form is clean', async () => {
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users/u2') } });
expect(cancel).not.toHaveBeenCalled();
});
it('discard button calls goto with the target URL', async () => {
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="firstName"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users/u2') } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users/u2');
});
it('clears dirty state when form saves successfully', async () => {
const { rerender } = renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="firstName"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users/u2') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
await rerender({ data: baseData, form: { success: true } });
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users/u2') } });
expect(cancel).not.toHaveBeenCalled();
});
});