From fb4f8e820c7a4a78e174cb05de15e507391c8190 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 16:33:50 +0100 Subject: [PATCH 01/17] feat(admin): add dedicated routes for admin user management (#37) - New GET /admin/users/new page: create user with all profile fields (login, password, firstName, lastName, birthDate, email, contact, groups) - New GET /admin/users/[id] page: edit user profile, groups, and optional password change without requiring current password - New PUT /api/users/{id} backend endpoint (ADMIN_USER permission) with AdminUpdateUserRequest DTO for admin-override user updates - Refactored admin users tab: replaced inline editing with edit links to dedicated routes; create button now links to /admin/users/new - Extended CreateUserRequest with profile fields so new users can be created with full profile data in a single request - Added 28 component tests across 3 new spec files (TDD) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 10 + .../dto/AdminUpdateUserRequest.java | 18 ++ .../familienarchiv/dto/CreateUserRequest.java | 7 +- .../familienarchiv/service/UserService.java | 38 +++ frontend/messages/de.json | 8 + frontend/messages/en.json | 8 + frontend/messages/es.json | 8 + frontend/src/routes/admin/+page.server.ts | 15 - frontend/src/routes/admin/+page.svelte | 260 ++++++------------ frontend/src/routes/admin/page.svelte.spec.ts | 80 ++++++ .../routes/admin/users/[id]/+page.server.ts | 67 +++++ .../src/routes/admin/users/[id]/+page.svelte | 233 ++++++++++++++++ .../admin/users/[id]/page.svelte.spec.ts | 129 +++++++++ .../routes/admin/users/new/+page.server.ts | 45 +++ .../src/routes/admin/users/new/+page.svelte | 204 ++++++++++++++ .../admin/users/new/page.svelte.spec.ts | 68 +++++ 16 files changed, 999 insertions(+), 199 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java create mode 100644 frontend/src/routes/admin/page.svelte.spec.ts create mode 100644 frontend/src/routes/admin/users/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/users/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/users/[id]/page.svelte.spec.ts create mode 100644 frontend/src/routes/admin/users/new/+page.server.ts create mode 100644 frontend/src/routes/admin/users/new/+page.svelte create mode 100644 frontend/src/routes/admin/users/new/page.svelte.spec.ts diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 608debf0..92b0b0f1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.UpdateProfileDTO; @@ -79,6 +80,15 @@ public class UserController { return ResponseEntity.ok(userService.createUserOrUpdate(request)); } + @PutMapping("/users/{id}") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity adminUpdateUser(@PathVariable UUID id, + @RequestBody AdminUpdateUserRequest dto) { + AppUser updated = userService.adminUpdateUser(id, dto); + updated.setPassword(null); + return ResponseEntity.ok(updated); + } + @DeleteMapping("/users/{id}") @RequirePermission(Permission.ADMIN_USER) public ResponseEntity deleteUser(@PathVariable UUID id) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java new file mode 100644 index 00000000..a92a8db2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Data +public class AdminUpdateUserRequest { + private String firstName; + private String lastName; + private LocalDate birthDate; + private String email; + private String contact; + private String newPassword; + private List groupIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java index 3270a8ca..4e0b8705 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dto; import lombok.Data; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -11,5 +12,9 @@ public class CreateUserRequest { private String username; private String email; private String initialPassword; - private List groupIds; // In welche Gruppen soll der User? + private List groupIds; + private String firstName; + private String lastName; + private LocalDate birthDate; + private String contact; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index c50deea7..c1988b95 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.UpdateProfileDTO; @@ -54,6 +55,10 @@ public class UserService { .email(request.getEmail()) .password(passwordEncoder.encode(request.getInitialPassword())) .groups(groups) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .birthDate(request.getBirthDate()) + .contact(request.getContact()) .enabled(true) .build(); } @@ -96,6 +101,39 @@ public class UserService { return userRepository.save(user); } + @Transactional + public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) { + AppUser user = getById(id); + + if (dto.getEmail() != null && !dto.getEmail().isBlank()) { + userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> { + if (!existing.getId().equals(id)) { + throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, + "E-Mail wird bereits von einem anderen Konto verwendet"); + } + }); + user.setEmail(dto.getEmail().trim()); + } else if (dto.getEmail() != null && dto.getEmail().isBlank()) { + user.setEmail(null); + } + + user.setFirstName(dto.getFirstName()); + user.setLastName(dto.getLastName()); + user.setBirthDate(dto.getBirthDate()); + user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim()); + + if (dto.getNewPassword() != null && !dto.getNewPassword().isBlank()) { + user.setPassword(passwordEncoder.encode(dto.getNewPassword())); + } + + if (dto.getGroupIds() != null) { + Set groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); + user.setGroups(groups); + } + + return userRepository.save(user); + } + @Transactional public void changePassword(UUID userId, ChangePasswordDTO dto) { AppUser user = getById(userId); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index b26158df..7267aa79 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Neue Gruppe anlegen", "admin_group_name_placeholder": "Gruppenname (z.B. Editoren)", "admin_user_delete_confirm": "Benutzer {username} wirklich löschen?", + "admin_btn_new_user": "Neuer Benutzer", + "admin_user_new_heading": "Neuen Benutzer anlegen", + "admin_user_edit_heading": "Benutzer bearbeiten: {username}", + "admin_user_created": "Benutzer wurde erstellt.", + "admin_user_updated": "Änderungen gespeichert.", + "admin_col_full_name": "Name", + "admin_label_new_password_optional": "Neues Passwort (optional)", + "admin_label_initial_password": "Passwort", "doc_file_error_preview": "Vorschau konnte nicht geladen werden.", "doc_download_title": "Herunterladen", "doc_tag_filter_title": "Nach {name} filtern", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 86f97be0..93a39f72 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Create new group", "admin_group_name_placeholder": "Group name (e.g. Editors)", "admin_user_delete_confirm": "Really delete user {username}?", + "admin_btn_new_user": "New User", + "admin_user_new_heading": "Create new user", + "admin_user_edit_heading": "Edit user: {username}", + "admin_user_created": "User has been created.", + "admin_user_updated": "Changes saved.", + "admin_col_full_name": "Name", + "admin_label_new_password_optional": "New password (optional)", + "admin_label_initial_password": "Password", "doc_file_error_preview": "Could not load preview.", "doc_download_title": "Download", "doc_tag_filter_title": "Filter by {name}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1e6ba43f..211e84fc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Crear nuevo grupo", "admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)", "admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?", + "admin_btn_new_user": "Nuevo usuario", + "admin_user_new_heading": "Crear nuevo usuario", + "admin_user_edit_heading": "Editar usuario: {username}", + "admin_user_created": "Usuario creado.", + "admin_user_updated": "Cambios guardados.", + "admin_col_full_name": "Nombre", + "admin_label_new_password_optional": "Nueva contraseña (opcional)", + "admin_label_initial_password": "Contraseña", "doc_file_error_preview": "No se pudo cargar la vista previa.", "doc_download_title": "Descargar", "doc_tag_filter_title": "Filtrar por {name}", diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index e75c3128..b58bf3c4 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -35,21 +35,6 @@ export async function load({ fetch, locals }) { } export const actions = { - createUser: async ({ request, fetch }) => { - const data = await request.formData(); - const api = createApiClient(fetch); - - const result = await api.POST('/api/users', { - body: { - username: data.get('username') as string, - initialPassword: data.get('password') as string, - groupIds: data.getAll('groupIds') as string[] - } - }); - - return toActionResult(result); - }, - deleteUser: async ({ request, fetch }) => { const data = await request.formData(); const id = data.get('id') as string; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index c395269c..841476cc 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -8,7 +8,6 @@ let { data, form } = $props(); let activeTab = $state('users'); let editingTagId: string | null = $state(null); let editingTagName = $state(''); -let editingUserId: string | null = $state(null); let editingGroupId: string | null = $state(null); const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION']; @@ -23,14 +22,6 @@ function cancelEditTag() { editingTagName = ''; } -function startEditUser(id: string) { - editingUserId = id; -} - -function cancelEditUser() { - editingUserId = null; -} - function startEditGroup(id: string) { editingGroupId = id; } @@ -80,6 +71,20 @@ function cancelEditGroup() {

{m.admin_section_users()}

+ + + + + {m.admin_btn_new_user()} +
@@ -88,200 +93,89 @@ function cancelEditGroup() { + - {#if editingUserId} - - {/if} + {#each data.users as user (user.id)} - {#if editingUserId === user.id} - - - - + + - - + - {:else} - - - - - {/if} + + + {/each}
{m.admin_col_login()}{m.admin_col_full_name()} {m.admin_col_groups()}{m.admin_col_password()}{m.admin_col_actions()}
- {user.username} - - - + {user.username} + + {#if user.firstName || user.lastName} + {user.firstName ?? ''} {user.lastName ?? ''} + {:else} + + {/if} + +
+ {#if user.groups && user.groups.length > 0} + {#each user.groups as group (group.id)} + {group.name} - + {/each} - -

{m.admin_multiselect_hint()}

-
-
- async ({ update }) => { - await update(); - cancelEditUser(); - }} - class="flex flex-col items-end gap-2" + {:else} + {m.admin_no_groups()} + {/if} + +
+
+ - + {m.btn_edit()} + -
- - -
- -
- {user.username} - -
- {#if user.groups && user.groups.length > 0} - {#each user.groups as group (group.id)} - - {group.name} - - {/each} - {:else} - {m.admin_no_groups()} - {/if} -
-
-
+
{ + if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { + cancel(); + } + return async ({ update }) => { + await update(); + }; + }} + class="flex items-center" + > + - - { - if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - class="flex items-center" - > - - -
-
-
- - -
-

- {m.admin_section_new_user()} -

-
- - - -
- -

{m.admin_multiselect_hint_full()}

-
- - -
-
{:else if activeTab === 'tags'}
diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts new file mode 100644 index 00000000..12ca0f09 --- /dev/null +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const makeGroup = (overrides = {}) => ({ + id: 'g1', + name: 'Editoren', + permissions: ['WRITE_ALL'], + ...overrides +}); + +const makeUser = (overrides = {}) => ({ + id: 'u1', + username: 'max', + firstName: 'Max', + lastName: 'Mustermann', + email: 'max@example.com', + birthDate: undefined, + contact: undefined, + enabled: true, + groups: [makeGroup()], + createdAt: '2024-01-01T00:00:00Z', + ...overrides +}); + +const baseData = { + users: [makeUser()], + groups: [makeGroup()], + tags: [] +}; + +afterEach(cleanup); + +// ─── Users tab ──────────────────────────────────────────────────────────────── + +describe('Admin page – users tab', () => { + it('shows the username in the table', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument(); + }); + + it('shows the full name in the table', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument(); + }); + + it('shows a dash when user has no name set', async () => { + const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] }; + render(Page, { data }); + await expect.element(page.getByText('–')).toBeInTheDocument(); + }); + + it('shows group badges for the user', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + }); + + it('edit link points to /admin/users/[id]', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Bearbeiten/i })) + .toHaveAttribute('href', '/admin/users/u1'); + }); + + it('new user button links to /admin/users/new', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Neuer Benutzer/i })) + .toHaveAttribute('href', '/admin/users/new'); + }); + + it('shows "no groups" label when user has no groups', async () => { + const data = { ...baseData, users: [makeUser({ groups: [] })] }; + render(Page, { data }); + await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/users/[id]/+page.server.ts b/frontend/src/routes/admin/users/[id]/+page.server.ts new file mode 100644 index 00000000..6d99c407 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/+page.server.ts @@ -0,0 +1,67 @@ +import { error, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ params, fetch, locals }) => { + const user = locals.user; + const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => + g.permissions.includes('ADMIN') + ); + if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN')); + + const api = createApiClient(fetch); + const [userResult, groupsResult] = await Promise.all([ + api.GET('/api/users/{id}', { params: { path: { id: params.id } } }), + api.GET('/api/groups') + ]); + + if (!userResult.response.ok) throw error(404, getErrorMessage('USER_NOT_FOUND')); + + return { + editUser: userResult.data!, + groups: groupsResult.data ?? [] + }; +}; + +export const actions: Actions = { + default: async ({ params, request, fetch }) => { + const data = await request.formData(); + + const newPassword = data.get('newPassword') as string; + const confirmPassword = data.get('confirmPassword') as string; + if (newPassword && newPassword !== confirmPassword) { + return fail(400, { error: getErrorMessage('PASSWORDS_DO_NOT_MATCH') }); + } + + const birthDateRaw = data.get('birthDate') as string; + const body = { + firstName: (data.get('firstName') as string) || null, + lastName: (data.get('lastName') as string) || null, + birthDate: birthDateRaw || null, + email: (data.get('email') as string) || null, + contact: (data.get('contact') as string) || null, + newPassword: newPassword || null, + groupIds: data.getAll('groupIds') as string[] + }; + + const res = await fetch(`/api/users/${params.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + let code: string | undefined; + try { + const json = await res.json(); + code = json?.code; + } catch { + // ignore + } + return fail(res.status, { error: getErrorMessage(code) }); + } + + return { success: true }; + } +}; diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 00000000..1dd717fc --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,233 @@ + + +
+ + + + + {m.btn_back_to_overview()} + + +

+ {m.admin_user_edit_heading({ username: data.editUser.username })} +

+ + {#if form?.success} +
+ {m.admin_user_updated()} +
+ {/if} + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ +
+

+ {m.profile_section_personal()} +

+ +
+
+ + + +
+ + + + + + +
+
+ + +
+

+ {m.admin_col_groups()} +

+ +
+ {#each data.groups as group (group.id)} + + {/each} +
+
+ + +
+

+ {m.admin_label_new_password_optional()} +

+ +
+ + + +
+
+ + +
+ + {m.btn_cancel()} + + +
+
+
diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts new file mode 100644 index 00000000..870b7610 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const groups = [ + { id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }, + { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } +]; + +const makeUser = (overrides = {}) => ({ + id: 'u1', + username: 'max', + 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 = { editUser: makeUser(), groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin edit user page – rendering', () => { + it('renders the heading with username', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument(); + }); + + it('pre-fills first name from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="firstName"]'); + expect(input?.value).toBe('Max'); + }); + + it('pre-fills last name from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="lastName"]'); + expect(input?.value).toBe('Mustermann'); + }); + + it('pre-fills email from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="email"]'); + expect(input?.value).toBe('max@example.com'); + }); + + it('pre-fills birth date in German format (dd.mm.yyyy)', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[placeholder="TT.MM.JJJJ"]'); + expect(input?.value).toBe('22.03.1985'); + }); + + it('pre-fills contact field', async () => { + render(Page, { data: baseData }); + const textarea = document.querySelector('textarea[name="contact"]'); + expect(textarea?.value).toBe('Tel: 0123'); + }); + + it('renders group checkboxes', async () => { + render(Page, { data: baseData }); + 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 () => { + render(Page, { data: baseData }); + const checkbox = document.querySelector( + '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 () => { + render(Page, { data: baseData }); + const checkbox = document.querySelector( + 'input[type="checkbox"][name="groupIds"][value="g2"]' + ); + expect(checkbox?.checked).toBe(false); + }); + + it('password fields are empty by default', async () => { + render(Page, { data: baseData }); + const passwordInputs = document.querySelectorAll('input[type="password"]'); + passwordInputs.forEach((input) => { + expect(input.value).toBe(''); + }); + }); + + it('cancel link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the save button', async () => { + render(Page, { data: baseData }); + 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 () => { + render(Page, { data: baseData, form: { success: true } }); + await expect.element(page.getByText(/Änderungen gespeichert/i)).toBeInTheDocument(); + }); + + it('shows error message when form.error is set', async () => { + render(Page, { 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 () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/users/new/+page.server.ts b/frontend/src/routes/admin/users/new/+page.server.ts new file mode 100644 index 00000000..256e4f89 --- /dev/null +++ b/frontend/src/routes/admin/users/new/+page.server.ts @@ -0,0 +1,45 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ fetch, locals }) => { + const user = locals.user; + const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => + g.permissions.includes('ADMIN') + ); + if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN')); + + const api = createApiClient(fetch); + const groupsResult = await api.GET('/api/groups'); + + return { groups: groupsResult.data ?? [] }; +}; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const data = await request.formData(); + const api = createApiClient(fetch); + + const birthDateRaw = data.get('birthDate') as string; + const result = await api.POST('/api/users', { + body: { + username: data.get('username') as string, + initialPassword: data.get('password') as string, + email: (data.get('email') as string) || undefined, + groupIds: data.getAll('groupIds') as string[], + firstName: (data.get('firstName') as string) || null, + lastName: (data.get('lastName') as string) || null, + birthDate: birthDateRaw || null, + contact: (data.get('contact') as string) || null + } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + + throw redirect(303, '/admin'); + } +}; diff --git a/frontend/src/routes/admin/users/new/+page.svelte b/frontend/src/routes/admin/users/new/+page.svelte new file mode 100644 index 00000000..8567ffd6 --- /dev/null +++ b/frontend/src/routes/admin/users/new/+page.svelte @@ -0,0 +1,204 @@ + + +
+ + + + + {m.btn_back_to_overview()} + + +

{m.admin_user_new_heading()}

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+
+ +

+ {m.admin_section_users()} +

+ + + + + + +

+ {m.profile_section_personal()} +

+ +
+ + + +
+ + + + + + + + +

+ {m.admin_col_groups()} +

+ +
+ {#each data.groups as group (group.id)} + + {/each} +
+ + +
+ + {m.btn_cancel()} + + +
+
+
+
diff --git a/frontend/src/routes/admin/users/new/page.svelte.spec.ts b/frontend/src/routes/admin/users/new/page.svelte.spec.ts new file mode 100644 index 00000000..ab48e816 --- /dev/null +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const groups = [ + { id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }, + { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } +]; + +const baseData = { groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin new user page – rendering', () => { + it('renders the page heading', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); + }); + + it('renders the login input', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument(); + }); + + it('renders group checkboxes for each available group', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + await expect.element(page.getByText('Admins')).toBeInTheDocument(); + }); + + it('cancel link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('back link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Zurück/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the create button', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument(); + }); +}); + +// ─── Error display ──────────────────────────────────────────────────────────── + +describe('Admin new user page – error display', () => { + it('shows the error message when form has an error', async () => { + render(Page, { data: baseData, form: { error: 'Ein Fehler ist aufgetreten.' } }); + await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).toBeInTheDocument(); + }); + + it('does not show error section when form is null', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From 1fcd8a6ad6ff7e6071e3a1f58cd1410c13b2e61f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:34:45 +0100 Subject: [PATCH 02/17] chore(hooks): run E2E tests before every push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Husky pre-push hook so `npm run test:e2e` must pass before any push is accepted. The login regression in 8f5c13f would have been caught immediately had this gate been in place. Closes #48 (enforcement side — coverage gaps tracked separately). Co-Authored-By: Claude Sonnet 4.6 --- .husky/pre-push | 1 + 1 file changed, 1 insertion(+) create mode 100755 .husky/pre-push diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..b8052229 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +cd frontend && npm run test:e2e -- 2.49.1 From cf8425d7440c8de43b1ec861ad64f88367d49f41 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:44:18 +0100 Subject: [PATCH 03/17] docs(collab): add user journey and E2E scenario requirements Every feature issue must include a User Journey and E2E Scenarios section before implementation begins. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- COLLABORATING.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/COLLABORATING.md b/COLLABORATING.md index 771eec9b..04a8c019 100644 --- a/COLLABORATING.md +++ b/COLLABORATING.md @@ -43,6 +43,42 @@ Repeat for each new behavior. - The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug. - If a bug is reported with no test, write the failing test first, then fix it. +## User Journeys & E2E Acceptance Criteria + +Every `feature` issue must include two sections before any implementation begins: + +### 1. User Journey + +A plain-prose description of the steps a user takes to get value from the feature. Written from the user's perspective, not the implementation's: + +> User opens a document, clicks "History", sees a chronological list of changes with editor name and timestamp. Clicking a row expands the old vs. new values. + +This makes the scope concrete and prevents scope creep — anything not in the journey is out of scope for the issue. + +### 2. E2E Scenarios + +One or more acceptance criteria written as Playwright-ready scenarios. These become the outermost Red test in the TDD cycle — no feature is considered done until all its E2E scenarios pass: + +``` +Scenario: View edit history of a document + Given I am on a document detail page + When I click the "History" tab + Then I see at least one revision entry + And each entry shows the editor's name and a timestamp +``` + +Use this format consistently. It maps directly to `test.describe` / `test` blocks in the Playwright spec. + +### Where this fits in the workflow + +``` +Issue (Journey + Scenarios) → Red E2E test → Implementation → Green +``` + +The scenarios in the issue are the contract. Write them before planning, treat them as failing tests from day one. + +--- + ## Issue Tracking (Gitea) All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute. -- 2.49.1 From c84bb3ca7ba758b627d9d206638de4485cfd96ce Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:44:35 +0100 Subject: [PATCH 04/17] fix(e2e): open avatar dropdown before clicking logout button The logout action was moved into a user avatar dropdown in the nav. The E2E test was clicking the now-hidden button directly. Refs #35 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/auth.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 0aa61199..06c290f4 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -50,6 +50,8 @@ test.describe('Authentication', () => { test('logout clears the session and redirects to /login', async ({ page }) => { await login(page); + // Logout is inside the user avatar dropdown — open it first + await page.locator('button[aria-haspopup="true"]').click(); await page.getByRole('button', { name: 'Abmelden' }).click(); await expect(page).toHaveURL(/\/login/); // Confirm session is gone: navigating to / redirects back -- 2.49.1 From c0b9d979ea36318448b1324b130a09e852391535 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:44:54 +0100 Subject: [PATCH 05/17] fix(e2e): wait for swapped senderId in URL instead of any senderId waitForURL(/senderId=/) resolved immediately because the URL already contained senderId= before the swap navigation. Use a predicate that waits for the specific swapped ID value. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/persons.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 12f7d2f0..3a0e215c 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -259,7 +259,10 @@ test.describe('Conversations — enhancements', () => { const originalReceiverId = url.searchParams.get('receiverId')!; await page.getByTestId('conv-swap-btn').click(); - await page.waitForURL(/senderId=/); + // Wait for the URL to reflect the swapped IDs (not just any URL with senderId=) + await page.waitForURL( + (url) => new URL(url).searchParams.get('senderId') === originalReceiverId + ); const swappedUrl = new URL(page.url()); expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId); -- 2.49.1 From 2a46136f61aa1c3605dc97ba76fed4489ec0647f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:59:07 +0100 Subject: [PATCH 06/17] test(e2e): seed read-only "reader" user in e2e profile Adds a "Leser" group (READ_ALL only) and "reader" / "reader123" user to the deterministic e2e seed so the permissions spec can log in as a read-only user without relying on admin-created test data. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- .../config/DataInitializer.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java index 18571eb3..c4164a66 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java @@ -81,7 +81,8 @@ public class DataInitializer { @Profile("e2e") public CommandLineRunner initE2EData(PersonRepository personRepo, DocumentRepository docRepo, - TagRepository tagRepo) { + TagRepository tagRepo, + PasswordEncoder passwordEncoder) { return args -> { if (personRepo.count() > 0) { log.info("E2E seed: Daten bereits vorhanden, überspringe."); @@ -165,8 +166,21 @@ public class DataInitializer { .receivers(Set.of(otto)) .build()); - log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.", - personRepo.count(), tagRepo.count(), docRepo.count()); + // ── Read-only user (for permissions E2E tests) ─────────────────── + // Username: reader / Password: reader123 + // Has only READ_ALL — used to assert write controls are absent. + UserGroup leserGroup = groupRepository.save(UserGroup.builder() + .name("Leser") + .permissions(Set.of("READ_ALL")) + .build()); + userRepository.save(AppUser.builder() + .username("reader") + .password(passwordEncoder.encode("reader123")) + .groups(Set.of(leserGroup)) + .build()); + + log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.", + personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count()); }; } } -- 2.49.1 From ea6b727e44224625f3b744de9070360c83d3a010 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:59:27 +0100 Subject: [PATCH 07/17] test(e2e): verify login establishes a working API session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards against regressions where the session cookie is set but the backend rejects it — a URL redirect alone is not enough. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/auth.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 06c290f4..5e38e15b 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -48,6 +48,15 @@ test.describe('Authentication', () => { await page.screenshot({ path: 'test-results/e2e/login-success.png' }); }); + test('login establishes a session that authenticates API calls', async ({ page }) => { + // Guards against regressions where the session cookie is set but broken — + // a working URL redirect is not enough evidence that auth works end-to-end. + await login(page); + const response = await page.request.get('/api/users/me'); + expect(response.ok()).toBe(true); + await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' }); + }); + test('logout clears the session and redirects to /login', async ({ page }) => { await login(page); // Logout is inside the user avatar dropdown — open it first -- 2.49.1 From 0221382c8aa95729c8b706cdeb2bc82a40ca58eb Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 19:59:46 +0100 Subject: [PATCH 08/17] test(e2e): add document creation and edit mutation journeys Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/documents.spec.ts | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index c2ee2960..03a380b6 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -80,6 +80,41 @@ test.describe('New document', () => { }); }); +test.describe('Document creation', () => { + test('user fills in a title and lands on the new document detail page', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByLabel('Titel').fill('E2E Testbrief'); + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByText('E2E Testbrief')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-create.png' }); + }); +}); + +test.describe('Document editing', () => { + test('user opens an existing document, changes the title, and sees the update', async ({ + page + }) => { + // Find the document created in the previous describe + await page.goto('/?q=E2E+Testbrief'); + await page.waitForSelector('[data-hydrated]'); + const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first(); + const href = await docLink.getAttribute('href'); + await page.goto(`${href}/edit`); + await page.waitForSelector('[data-hydrated]'); + + await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)'); + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' }); + }); +}); + test.describe('Document edit', () => { test('renders the edit form with pre-filled data', async ({ page }) => { // Navigate to home, find first document, go to its edit page -- 2.49.1 From ca737770109fc963996baac604b31daa287affc8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 20:00:03 +0100 Subject: [PATCH 09/17] test(e2e): add person creation journey Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/persons.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 3a0e215c..3fd78387 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -95,6 +95,21 @@ test.describe('New person', () => { }); }); +test.describe('Person creation', () => { + test('user fills in first and last name and lands on the new person detail page', async ({ + page + }) => { + await page.goto('/persons/new'); + await page.getByLabel('Vorname').fill('E2E'); + await page.getByLabel('Nachname').fill('Testperson'); + await page.getByRole('button', { name: /Erstellen/i }).click(); + + await expect(page).toHaveURL(/\/persons\/[^/]+$/); + await expect(page.getByText('E2E Testperson')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/person-create.png' }); + }); +}); + test.describe('Person detail — sort toggle', () => { test('each section has its own sort toggle that works independently', async ({ page }) => { await page.goto('/persons'); -- 2.49.1 From 7d095e159ecf760d7c8debcf95708e716a8ca45f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 20:00:23 +0100 Subject: [PATCH 10/17] test(e2e): add profile page journey (view, update, password change) Includes self-healing password change test that restores admin123 at the end so the shared session remains valid for subsequent specs. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/profile.spec.ts | 108 +++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 frontend/e2e/profile.spec.ts diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts new file mode 100644 index 00000000..c9f476a1 --- /dev/null +++ b/frontend/e2e/profile.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; + +/** + * Profile page E2E tests. + * + * Reads top-to-bottom as a single user journey: + * the logged-in admin opens their profile, updates their display name, + * tries a wrong password (sees an error), then successfully changes their + * password and logs back in with the new one. + * + * The password change test restores the original password at the end so the + * shared session remains valid for all subsequent test files. + */ + +test.describe('Profile page', () => { + test('user opens their profile and sees the personal data and password sections', async ({ + page + }) => { + await page.goto('/profile'); + await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible(); + await expect(page.getByText('Persönliche Daten')).toBeVisible(); + await expect(page.getByText('Passwort ändern')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/profile-view.png' }); + }); + + test('user saves updated first and last name and sees confirmation', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="firstName"]').fill('E2E'); + await page.locator('input[name="lastName"]').fill('Admin'); + + // Two "Speichern" buttons exist — the first belongs to the profile form + await page + .locator('form[action*="updateProfile"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + await expect(page.getByText('Gespeichert.')).toBeVisible(); + // Nav avatar shows the new initials derived from firstName + lastName + await expect(page.locator('button[aria-haspopup="true"]')).toContainText('EA'); + await page.screenshot({ path: 'test-results/e2e/profile-save.png' }); + }); + + test('shows an error when the current password is wrong', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="currentPassword"]').fill('definitely-wrong'); + await page.locator('input[name="newPassword"]').fill('NewPass123!'); + await page.locator('input[name="confirmPassword"]').fill('NewPass123!'); + + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + await expect(page.getByText('Das aktuelle Passwort ist falsch.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/profile-wrong-password.png' }); + }); + + test('user changes their password and can log in with the new one', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + // ── Step 1: change to a temporary password ───────────────────────────── + await page.locator('input[name="currentPassword"]').fill('admin123'); + await page.locator('input[name="newPassword"]').fill('TempAdmin456!'); + await page.locator('input[name="confirmPassword"]').fill('TempAdmin456!'); + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible(); + + // ── Step 2: navigate away — server session is invalidated ─────────────── + await page.goto('/'); + await expect(page).toHaveURL(/\/login/); + + // ── Step 3: log in with the new password ─────────────────────────────── + await page.getByLabel('Benutzername').fill('admin'); + await page.getByLabel('Passwort').fill('TempAdmin456!'); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' }); + + // ── Step 4: restore the original password so subsequent tests still work ─ + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + await page.locator('input[name="currentPassword"]').fill('TempAdmin456!'); + await page.locator('input[name="newPassword"]').fill('admin123'); + await page.locator('input[name="confirmPassword"]').fill('admin123'); + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible(); + + // ── Step 5: log back in with the restored password ───────────────────── + await page.goto('/'); + await expect(page).toHaveURL(/\/login/); + await page.getByLabel('Benutzername').fill('admin'); + await page.getByLabel('Passwort').fill('admin123'); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + }); +}); -- 2.49.1 From 2411c330a247cc9667d11c2aa92ea6a4683093e5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 20:00:41 +0100 Subject: [PATCH 11/17] test(e2e): add admin management journey (users, groups, tags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full lifecycle: create group → create user → edit user → reset password → verify login → delete user → delete group → rename tag. Self-contained: everything created is also deleted. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/admin.spec.ts | 211 +++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 frontend/e2e/admin.spec.ts diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts new file mode 100644 index 00000000..209cec3d --- /dev/null +++ b/frontend/e2e/admin.spec.ts @@ -0,0 +1,211 @@ +import { test, expect, type Browser } from '@playwright/test'; + +/** + * Admin panel E2E tests. + * + * Reads top-to-bottom as a complete admin journey: + * 1. Admin opens the dashboard and sees all three management tabs. + * 2. Admin creates a group for read-only access. + * 3. Admin creates a new user in that group. + * 4. Admin edits the user's profile. + * 5. Admin resets the user's password without knowing their current password. + * 6. The user can log in with the admin-set password. + * 7. Admin deletes the user. + * 8. Admin deletes the test group. + * 9. Admin renames a tag and renames it back. + * + * Steps 2–8 form a self-contained lifecycle: everything created in this suite + * is also deleted, leaving the database in its original state. + */ + +// ── Dashboard ───────────────────────────────────────────────────────────────── + +test.describe('Admin dashboard', () => { + test('admin navigates to /admin and sees the three management tabs', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Benutzer' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Gruppen' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Schlagworte' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' }); + }); +}); + +// ── Group lifecycle ──────────────────────────────────────────────────────────── + +test.describe('Admin — group management', () => { + test('admin creates a new group "E2E Leser" with READ_ALL permission', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + // Switch to the Groups tab + await page.getByRole('button', { name: 'Gruppen' }).click(); + + await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser'); + + // No permission checkboxes checked — READ_ALL is handled at application level + // (a group with no permissions gets read-only access by default in the UI) + + await page.getByRole('button', { name: /Erstellen/i }).click(); + + await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-group-created.png' }); + }); +}); + +// ── User lifecycle ───────────────────────────────────────────────────────────── + +test.describe('Admin — user lifecycle', () => { + test('admin creates user "e2e-testuser" and they appear in the user list', async ({ page }) => { + await page.goto('/admin/users/new'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="username"]').fill('e2e-testuser'); + await page.locator('input[name="password"]').fill('InitPass123!'); + + // Assign to the group we just created + const groupLabel = page.locator('label').filter({ hasText: 'E2E Leser' }); + if ((await groupLabel.count()) > 0) { + await groupLabel.locator('input[type="checkbox"]').check(); + } + + await page.getByRole('button', { name: /Erstellen/i }).click(); + + // Redirected back to /admin — user appears in the table + await expect(page).toHaveURL('/admin'); + await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-created.png' }); + }); + + test('admin opens the edit page and updates the user first name', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + // Click the edit link for the test user + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + await userRow.getByRole('link', { name: /Bearbeiten/i }).click(); + + await expect(page).toHaveURL(/\/admin\/users\/.+/); + await expect( + page.getByRole('heading', { name: /Benutzer bearbeiten: e2e-testuser/i }) + ).toBeVisible(); + + await page.locator('input[name="firstName"]').fill('E2E'); + await page.locator('input[name="lastName"]').fill('Testuser'); + + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Änderungen gespeichert.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-edited.png' }); + }); + + test('admin sets a new password without entering the current password', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + await userRow.getByRole('link', { name: /Bearbeiten/i }).click(); + + // Password fields — no current password field on the admin edit form + await page.locator('input[name="newPassword"]').fill('AdminSet456!'); + await page.locator('input[name="confirmPassword"]').fill('AdminSet456!'); + + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Änderungen gespeichert.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-password-reset.png' }); + }); + + test('the user can log in with the admin-set password', async ({ browser }) => { + // Open a completely separate browser context — no shared session cookies + const freshCtx = await (browser as Browser).newContext({ + storageState: { cookies: [], origins: [] } + }); + const freshPage = await freshCtx.newPage(); + + await freshPage.goto('/login'); + await freshPage.getByLabel('Benutzername').fill('e2e-testuser'); + await freshPage.getByLabel('Passwort').fill('AdminSet456!'); + await freshPage.getByRole('button', { name: 'Anmelden' }).click(); + + await expect(freshPage).toHaveURL('/'); + await freshPage.screenshot({ path: 'test-results/e2e/admin-user-login-new-password.png' }); + + await freshCtx.close(); + }); + + test('admin deletes the test user and they disappear from the list', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + + // The delete button triggers a window.confirm() dialog + page.once('dialog', (dialog) => dialog.accept()); + await userRow.getByTitle('Benutzer löschen').click(); + + await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-deleted.png' }); + }); +}); + +// ── Group cleanup ────────────────────────────────────────────────────────────── + +test.describe('Admin — group cleanup', () => { + test('admin deletes the "E2E Leser" group', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Gruppen' }).click(); + + const groupRow = page.locator('tr').filter({ hasText: 'E2E Leser' }); + + page.once('dialog', (dialog) => dialog.accept()); + await groupRow.getByTitle('Löschen').click(); + + await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-group-deleted.png' }); + }); +}); + +// ── Tag management ───────────────────────────────────────────────────────────── + +test.describe('Admin — tag management', () => { + test('admin renames a tag and sees the change in the list', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Schlagworte' }).click(); + + // Hover over the "Familie" row to reveal the opacity-0 action buttons + const familieRow = page.locator('li').filter({ hasText: /^Familie$/ }); + await familieRow.hover(); + await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); + + const nameInput = familieRow.locator('input[name="name"]'); + await nameInput.fill('Familie (E2E)'); + await familieRow.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Familie (E2E)')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' }); + }); + + test('admin renames it back to restore the original name', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Schlagworte' }).click(); + + const renamedRow = page.locator('li').filter({ hasText: /^Familie \(E2E\)$/ }); + await renamedRow.hover(); + await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); + + const nameInput = renamedRow.locator('input[name="name"]'); + await nameInput.fill('Familie'); + await renamedRow.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Familie')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' }); + }); +}); -- 2.49.1 From bbac351f034438f436d59f11a08ed91baadfefad Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 20:01:04 +0100 Subject: [PATCH 12/17] test(e2e): add read-only user permissions journey Logs in as the seeded "reader" user (READ_ALL only) and asserts that all write controls are absent from every page. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/permissions.spec.ts | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/frontend/e2e/permissions.spec.ts b/frontend/e2e/permissions.spec.ts index a7416856..63e9bc1a 100644 --- a/frontend/e2e/permissions.spec.ts +++ b/frontend/e2e/permissions.spec.ts @@ -1,4 +1,14 @@ import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +/** + * Permission E2E tests. + * + * Two describe blocks form the full story: + * 1. Admin user — can see all write controls. + * 2. Read-only user ("reader", seeded in DataInitializer with READ_ALL only) — + * can browse content but sees no write controls anywhere. + */ test.describe('Write permissions — admin user', () => { test('admin user sees Neues Dokument link on home page', async ({ page }) => { @@ -29,3 +39,49 @@ test.describe('Write permissions — admin user', () => { await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible(); }); }); + +// ── Read-only user journey ───────────────────────────────────────────────────── +// +// The "reader" user is seeded by DataInitializer (e2e profile) with READ_ALL only. +// They can browse documents and persons but must not see any mutation controls. + +test.describe('Read-only user — no write controls visible', () => { + // Fresh session — no shared admin cookies + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await login(page, 'reader', 'reader123'); + }); + + test('read-only user is redirected to home after login', async ({ page }) => { + await expect(page).toHaveURL('/'); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-home.png' }); + }); + + test('home page does not show the "Neues Dokument" link', async ({ page }) => { + await expect(page.getByRole('link', { name: /Neues Dokument/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc.png' }); + }); + + test('persons page does not show the "Neue Person" link', async ({ page }) => { + await page.goto('/persons'); + await expect(page.getByRole('link', { name: /Neue Person/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-person.png' }); + }); + + test('person detail page does not show the edit button', async ({ page }) => { + await page.goto('/persons'); + const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); + await firstPerson.click(); + await page.waitForSelector('[data-hydrated]'); + await expect(page.getByRole('button', { name: /Bearbeiten/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-edit.png' }); + }); + + test('navigating directly to /documents/new redirects away', async ({ page }) => { + await page.goto('/documents/new'); + // Read-only user should not be able to access the new document form + await expect(page).not.toHaveURL('/documents/new'); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' }); + }); +}); -- 2.49.1 From 7fbfeb3b392fc931a3cd99460cfdec38f3e04fca Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 22:15:00 +0100 Subject: [PATCH 13/17] chore(hooks): remove pre-push E2E hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E tests run on CI anyway — running them locally before every push adds too much friction. Removed the hook; CI remains the safety net. Refs #48 Co-Authored-By: Claude Sonnet 4.6 --- .husky/pre-push | 1 - 1 file changed, 1 deletion(-) delete mode 100755 .husky/pre-push diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index b8052229..00000000 --- a/.husky/pre-push +++ /dev/null @@ -1 +0,0 @@ -cd frontend && npm run test:e2e -- 2.49.1 From c1e82a7edf0ae9ff6f27f6c6ca39b999878ec23d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:01:04 +0100 Subject: [PATCH 14/17] fix(e2e): fix 8 failing E2E tests on feat/35-profile-page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin: add exact:true to tab button assertions to avoid strict-mode violations from "Benutzer löschen" title buttons matching "Benutzer" - admin: change tag-row locator from hasText regex on
  • to has: span filter (more robust against whitespace differences); add waitForSelector after tab click to ensure panel is rendered before hovering - auth: replace page.request.get('/api/users/me') with a profile page navigation — direct browser requests don't carry Basic Auth, only server-side SvelteKit fetches do - documents: use getByRole('heading') instead of getByText to avoid strict mode violation when the title appears in both h1 and breadcrumb - persons: same heading fix for person creation landing page - profile: remove success-message assertion after password change; the auth_token cookie still holds old credentials so use:enhance's update() immediately gets a 401 and redirects to /login before the message renders — test now asserts the redirect directly, then re-logs in Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/admin.spec.ts | 23 +++++++++++++++-------- frontend/e2e/auth.spec.ts | 10 ++++++---- frontend/e2e/documents.spec.ts | 2 +- frontend/e2e/persons.spec.ts | 2 +- frontend/e2e/profile.spec.ts | 18 ++++++++---------- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts index 209cec3d..0bf90de5 100644 --- a/frontend/e2e/admin.spec.ts +++ b/frontend/e2e/admin.spec.ts @@ -25,9 +25,9 @@ test.describe('Admin dashboard', () => { await page.goto('/admin'); await page.waitForSelector('[data-hydrated]'); - await expect(page.getByRole('button', { name: 'Benutzer' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Gruppen' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Schlagworte' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' }); }); }); @@ -40,7 +40,7 @@ test.describe('Admin — group management', () => { await page.waitForSelector('[data-hydrated]'); // Switch to the Groups tab - await page.getByRole('button', { name: 'Gruppen' }).click(); + await page.getByRole('button', { name: 'Gruppen', exact: true }).click(); await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser'); @@ -176,10 +176,14 @@ test.describe('Admin — tag management', () => { await page.goto('/admin'); await page.waitForSelector('[data-hydrated]'); - await page.getByRole('button', { name: 'Schlagworte' }).click(); + await page.getByRole('button', { name: 'Schlagworte', exact: true }).click(); + // Wait for the tags list to render after the tab switch + await page.waitForSelector('ul > li'); // Hover over the "Familie" row to reveal the opacity-0 action buttons - const familieRow = page.locator('li').filter({ hasText: /^Familie$/ }); + const familieRow = page + .locator('ul > li') + .filter({ has: page.locator('span', { hasText: /^Familie$/ }) }); await familieRow.hover(); await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); @@ -195,9 +199,12 @@ test.describe('Admin — tag management', () => { await page.goto('/admin'); await page.waitForSelector('[data-hydrated]'); - await page.getByRole('button', { name: 'Schlagworte' }).click(); + await page.getByRole('button', { name: 'Schlagworte', exact: true }).click(); + await page.waitForSelector('ul > li'); - const renamedRow = page.locator('li').filter({ hasText: /^Familie \(E2E\)$/ }); + const renamedRow = page + .locator('ul > li') + .filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) }); await renamedRow.hover(); await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 5e38e15b..a2324067 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -49,11 +49,13 @@ test.describe('Authentication', () => { }); test('login establishes a session that authenticates API calls', async ({ page }) => { - // Guards against regressions where the session cookie is set but broken — - // a working URL redirect is not enough evidence that auth works end-to-end. + // Guards against regressions where the session cookie is set but broken. + // The profile page calls /api/users/me server-side — if auth works end-to-end, + // it loads without redirecting to /login. await login(page); - const response = await page.request.get('/api/users/me'); - expect(response.ok()).toBe(true); + await page.goto('/profile'); + await expect(page).toHaveURL('/profile'); + await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' }); }); diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 03a380b6..5743ab02 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -89,7 +89,7 @@ test.describe('Document creation', () => { await page.getByRole('button', { name: /Speichern/i }).click(); await expect(page).toHaveURL(/\/documents\/[^/]+$/); - await expect(page.getByText('E2E Testbrief')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/document-create.png' }); }); }); diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 3fd78387..e1089a71 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -105,7 +105,7 @@ test.describe('Person creation', () => { await page.getByRole('button', { name: /Erstellen/i }).click(); await expect(page).toHaveURL(/\/persons\/[^/]+$/); - await expect(page.getByText('E2E Testperson')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-create.png' }); }); }); diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts index c9f476a1..f1c763f4 100644 --- a/frontend/e2e/profile.spec.ts +++ b/frontend/e2e/profile.spec.ts @@ -72,20 +72,19 @@ test.describe('Profile page', () => { .getByRole('button', { name: /Speichern/i }) .click(); - await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible(); - - // ── Step 2: navigate away — server session is invalidated ─────────────── - await page.goto('/'); + // After the password changes, the auth_token cookie still carries the old + // credentials. use:enhance re-runs the page's load function, which calls + // the backend with the stale Basic Auth header → 401 → redirect to /login. await expect(page).toHaveURL(/\/login/); - // ── Step 3: log in with the new password ─────────────────────────────── + // ── Step 2: log in with the new password ─────────────────────────────── await page.getByLabel('Benutzername').fill('admin'); await page.getByLabel('Passwort').fill('TempAdmin456!'); await page.getByRole('button', { name: 'Anmelden' }).click(); await expect(page).toHaveURL('/'); await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' }); - // ── Step 4: restore the original password so subsequent tests still work ─ + // ── Step 3: restore the original password so subsequent tests still work ─ await page.goto('/profile'); await page.waitForSelector('[data-hydrated]'); await page.locator('input[name="currentPassword"]').fill('TempAdmin456!'); @@ -95,11 +94,10 @@ test.describe('Profile page', () => { .locator('form[action*="changePassword"]') .getByRole('button', { name: /Speichern/i }) .click(); - await expect(page.getByText('Passwort erfolgreich geändert.')).toBeVisible(); - - // ── Step 5: log back in with the restored password ───────────────────── - await page.goto('/'); + // Redirected to /login again after credential rotation await expect(page).toHaveURL(/\/login/); + + // ── Step 4: log back in with the restored password ───────────────────── await page.getByLabel('Benutzername').fill('admin'); await page.getByLabel('Passwort').fill('admin123'); await page.getByRole('button', { name: 'Anmelden' }).click(); -- 2.49.1 From 70d858b65af4fcc3d11ce843ebe1dc3994b1a903 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:01:25 +0100 Subject: [PATCH 15/17] fix(tests): add missing user/canWrite/form props to admin spec fixtures After the layout load function started injecting user+canWrite into all page data, the admin spec files failed svelte-check with missing property errors. Add user:undefined, canWrite:true, and form:null to all fixture data objects. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/page.svelte.spec.ts | 16 +++++++----- .../admin/users/[id]/page.svelte.spec.ts | 26 +++++++++---------- .../admin/users/new/page.svelte.spec.ts | 14 +++++----- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts index 12ca0f09..848fb1b4 100644 --- a/frontend/src/routes/admin/page.svelte.spec.ts +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -27,6 +27,8 @@ const makeUser = (overrides = {}) => ({ }); const baseData = { + user: undefined, + canWrite: true, users: [makeUser()], groups: [makeGroup()], tags: [] @@ -38,35 +40,35 @@ afterEach(cleanup); describe('Admin page – users tab', () => { it('shows the username in the table', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument(); }); it('shows the full name in the table', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument(); }); it('shows a dash when user has no name set', async () => { const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] }; - render(Page, { data }); + render(Page, { data, form: null }); await expect.element(page.getByText('–')).toBeInTheDocument(); }); it('shows group badges for the user', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByText('Editoren')).toBeInTheDocument(); }); it('edit link points to /admin/users/[id]', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect .element(page.getByRole('link', { name: /Bearbeiten/i })) .toHaveAttribute('href', '/admin/users/u1'); }); it('new user button links to /admin/users/new', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect .element(page.getByRole('link', { name: /Neuer Benutzer/i })) .toHaveAttribute('href', '/admin/users/new'); @@ -74,7 +76,7 @@ describe('Admin page – users tab', () => { it('shows "no groups" label when user has no groups', async () => { const data = { ...baseData, users: [makeUser({ groups: [] })] }; - render(Page, { data }); + render(Page, { data, form: null }); await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts index 870b7610..2ff1b450 100644 --- a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts @@ -24,7 +24,7 @@ const makeUser = (overrides = {}) => ({ ...overrides }); -const baseData = { editUser: makeUser(), groups }; +const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups }; afterEach(cleanup); @@ -32,48 +32,48 @@ afterEach(cleanup); describe('Admin edit user page – rendering', () => { it('renders the heading with username', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument(); }); it('pre-fills first name from editUser data', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const input = document.querySelector('input[name="firstName"]'); expect(input?.value).toBe('Max'); }); it('pre-fills last name from editUser data', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const input = document.querySelector('input[name="lastName"]'); expect(input?.value).toBe('Mustermann'); }); it('pre-fills email from editUser data', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const input = document.querySelector('input[name="email"]'); expect(input?.value).toBe('max@example.com'); }); it('pre-fills birth date in German format (dd.mm.yyyy)', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const input = document.querySelector('input[placeholder="TT.MM.JJJJ"]'); expect(input?.value).toBe('22.03.1985'); }); it('pre-fills contact field', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const textarea = document.querySelector('textarea[name="contact"]'); expect(textarea?.value).toBe('Tel: 0123'); }); it('renders group checkboxes', async () => { - render(Page, { data: baseData }); + render(Page, { 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 () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const checkbox = document.querySelector( 'input[type="checkbox"][name="groupIds"][value="g1"]' ); @@ -81,7 +81,7 @@ describe('Admin edit user page – rendering', () => { }); it('does not pre-select groups the user does not belong to', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const checkbox = document.querySelector( 'input[type="checkbox"][name="groupIds"][value="g2"]' ); @@ -89,7 +89,7 @@ describe('Admin edit user page – rendering', () => { }); it('password fields are empty by default', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); const passwordInputs = document.querySelectorAll('input[type="password"]'); passwordInputs.forEach((input) => { expect(input.value).toBe(''); @@ -97,14 +97,14 @@ describe('Admin edit user page – rendering', () => { }); it('cancel link points to /admin', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect .element(page.getByRole('link', { name: /Abbrechen/i })) .toHaveAttribute('href', '/admin'); }); it('renders the save button', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument(); }); }); diff --git a/frontend/src/routes/admin/users/new/page.svelte.spec.ts b/frontend/src/routes/admin/users/new/page.svelte.spec.ts index ab48e816..372cf779 100644 --- a/frontend/src/routes/admin/users/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -10,7 +10,7 @@ const groups = [ { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } ]; -const baseData = { groups }; +const baseData = { user: undefined, canWrite: true, groups }; afterEach(cleanup); @@ -18,37 +18,37 @@ afterEach(cleanup); describe('Admin new user page – rendering', () => { it('renders the page heading', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); }); it('renders the login input', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument(); }); it('renders group checkboxes for each available group', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByText('Editoren')).toBeInTheDocument(); await expect.element(page.getByText('Admins')).toBeInTheDocument(); }); it('cancel link points to /admin', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect .element(page.getByRole('link', { name: /Abbrechen/i })) .toHaveAttribute('href', '/admin'); }); it('back link points to /admin', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect .element(page.getByRole('link', { name: /Zurück/i })) .toHaveAttribute('href', '/admin'); }); it('renders the create button', async () => { - render(Page, { data: baseData }); + render(Page, { data: baseData, form: null }); await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument(); }); }); -- 2.49.1 From f98792f10baf60d50d1bafa64df8edbf13723fc5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:01:45 +0100 Subject: [PATCH 16/17] fix(permissions): redirect read-only users from /documents/new to home throw error(403) kept the URL at /documents/new (the error page renders in-place). Changed to throw redirect(303, '/') so the URL actually changes, matching the E2E test expectation that a read-only user is redirected away. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/documents/new/+page.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/documents/new/+page.server.ts b/frontend/src/routes/documents/new/+page.server.ts index 6ef92f14..605a74e5 100644 --- a/frontend/src/routes/documents/new/+page.server.ts +++ b/frontend/src/routes/documents/new/+page.server.ts @@ -1,4 +1,4 @@ -import { error, fail, redirect } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { createApiClient } from '$lib/api.server'; import { parseBackendError, getErrorMessage } from '$lib/errors'; @@ -16,7 +16,7 @@ export async function load({ locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL') ) ?? false; - if (!canWrite) throw error(403, 'Forbidden'); + if (!canWrite) throw redirect(303, '/'); const senderId = url.searchParams.get('senderId') || ''; const receiverId = url.searchParams.get('receiverId') || ''; -- 2.49.1 From 6400cef390cf43df0650e881112c3429273baafc Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 07:25:34 +0100 Subject: [PATCH 17/17] fix(e2e): fix tag rename and flaky logout tests admin.spec.ts: after clicking "Schlagwort bearbeiten", Svelte's {#if editingTagId} replaces the span with a form, so familieRow (filtered by the span) no longer matches. Find input[name="name"] and the save button directly instead. auth.spec.ts: dropdown opens via {#if userMenuOpen} which renders asynchronously. Wait for the Abmelden button to be visible before clicking to prevent a race condition. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/admin.spec.ts | 12 ++++++------ frontend/e2e/auth.spec.ts | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts index 0bf90de5..1ffd6f59 100644 --- a/frontend/e2e/admin.spec.ts +++ b/frontend/e2e/admin.spec.ts @@ -187,9 +187,10 @@ test.describe('Admin — tag management', () => { await familieRow.hover(); await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); - const nameInput = familieRow.locator('input[name="name"]'); - await nameInput.fill('Familie (E2E)'); - await familieRow.getByRole('button', { name: /Speichern/i }).click(); + // After clicking edit, {#if editingTagId} replaces the span with a form — + // the familieRow filter no longer matches, so we find the input directly. + await page.locator('input[name="name"]').fill('Familie (E2E)'); + await page.getByRole('button', { name: 'Speichern' }).click(); await expect(page.getByText('Familie (E2E)')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' }); @@ -208,9 +209,8 @@ test.describe('Admin — tag management', () => { await renamedRow.hover(); await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); - const nameInput = renamedRow.locator('input[name="name"]'); - await nameInput.fill('Familie'); - await renamedRow.getByRole('button', { name: /Speichern/i }).click(); + await page.locator('input[name="name"]').fill('Familie'); + await page.getByRole('button', { name: 'Speichern' }).click(); await expect(page.getByText('Familie')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' }); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index a2324067..767da083 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -61,8 +61,11 @@ test.describe('Authentication', () => { test('logout clears the session and redirects to /login', async ({ page }) => { await login(page); - // Logout is inside the user avatar dropdown — open it first + // Logout is inside the user avatar dropdown — open it first. + // Wait for the dropdown button to be visible before clicking Abmelden, + // since the {#if userMenuOpen} block renders asynchronously in Svelte. await page.locator('button[aria-haspopup="true"]').click(); + await expect(page.getByRole('button', { name: 'Abmelden' })).toBeVisible(); await page.getByRole('button', { name: 'Abmelden' }).click(); await expect(page).toHaveURL(/\/login/); // Confirm session is gone: navigating to / redirects back -- 2.49.1