Compare commits
16 Commits
worktree-f
...
82e61291d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e61291d4 | ||
|
|
a1b319a535 | ||
|
|
d7fcbfd4d9 | ||
|
|
636900110a | ||
|
|
d78ee4397b | ||
|
|
ebdb36b7d0 | ||
|
|
93ff6cfb67 | ||
|
|
ed4c4a52eb | ||
|
|
2ca8428be4 | ||
|
|
6fffc06c28 | ||
|
|
ffcb901376 | ||
|
|
30469e74c9 | ||
|
|
5646e739c2 | ||
|
|
bbbdf8cd09 | ||
|
|
f727429699 | ||
|
|
e268e2dbca |
@@ -2,6 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -32,6 +33,10 @@ jobs:
|
|||||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Sync SvelteKit
|
||||||
|
run: npx svelte-kit sync
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -192,6 +197,14 @@ jobs:
|
|||||||
./mvnw clean test
|
./mvnw clean test
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Upload surefire reports
|
||||||
|
if: always()
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: surefire-reports
|
||||||
|
path: backend/target/surefire-reports/
|
||||||
|
|
||||||
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
||||||
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
||||||
# the JSON keys would silently break it (fail2ban-regex would return
|
# the JSON keys would silently break it (fail2ban-regex would return
|
||||||
|
|||||||
@@ -273,6 +273,16 @@
|
|||||||
</profiles>
|
</profiles>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ public enum ErrorCode {
|
|||||||
INVITE_REVOKED,
|
INVITE_REVOKED,
|
||||||
/** The invite has passed its expiry date. 410 */
|
/** The invite has passed its expiry date. 410 */
|
||||||
INVITE_EXPIRED,
|
INVITE_EXPIRED,
|
||||||
|
/** A group cannot be deleted because one or more active invites reference it. 409 */
|
||||||
|
GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
/** The request is not authenticated. 401 */
|
/** The request is not authenticated. 401 */
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
|
|||||||
|
|
||||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||||
List<InviteToken> findAllOrderedByCreatedAt();
|
List<InviteToken> findAllOrderedByCreatedAt();
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
|
||||||
|
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class UserService {
|
|||||||
|
|
||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
@@ -288,6 +289,10 @@ public class UserService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteGroup(UUID id) {
|
public void deleteGroup(UUID id) {
|
||||||
|
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
|
||||||
|
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
"Cannot delete group " + id + " — referenced by one or more active invites");
|
||||||
|
}
|
||||||
groupRepository.deleteById(id);
|
groupRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class UserServiceTest {
|
|||||||
|
|
||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
|
@Mock InviteTokenRepository inviteTokenRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
@@ -903,6 +904,29 @@ class UserServiceTest {
|
|||||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── deleteGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> userService.deleteGroup(groupId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
|
||||||
|
|
||||||
|
userService.deleteGroup(groupId);
|
||||||
|
|
||||||
|
verify(groupRepository).deleteById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||||
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||||
|
|||||||
2
backend/src/test/resources/application.properties
Normal file
2
backend/src/test/resources/application.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
logging.level.root=WARN
|
||||||
|
logging.level.org.raddatz=INFO
|
||||||
@@ -165,7 +165,7 @@ npm run check # svelte-check (type checking)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test # Vitest unit + server tests (headless)
|
npm run test # Vitest unit + server tests (headless)
|
||||||
npm run test:coverage # Coverage report (server project only)
|
npm run test:coverage # Coverage report (server + client)
|
||||||
npm run test:e2e # Playwright E2E tests
|
npm run test:e2e # Playwright E2E tests
|
||||||
npm run test:e2e:headed # Playwright E2E with visible browser
|
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||||
npm run test:e2e:ui # Playwright UI mode
|
npm run test:e2e:ui # Playwright UI mode
|
||||||
|
|||||||
@@ -703,6 +703,7 @@
|
|||||||
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
||||||
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
||||||
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
||||||
|
"error_group_has_active_invites": "Diese Gruppe kann nicht gelöscht werden, da sie in einer aktiven Einladung verwendet wird.",
|
||||||
"register_heading": "Konto erstellen",
|
"register_heading": "Konto erstellen",
|
||||||
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
||||||
"register_label_first_name": "Vorname",
|
"register_label_first_name": "Vorname",
|
||||||
@@ -762,6 +763,8 @@
|
|||||||
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
|
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
|
||||||
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
|
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
|
||||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||||
|
"admin_new_invite_groups": "Gruppen (optional)",
|
||||||
|
"admin_invite_groups_load_error": "Gruppen konnten nicht geladen werden. Die Einladung kann ohne Gruppenauswahl erstellt werden.",
|
||||||
"admin_invite_created_title": "Einladung erstellt",
|
"admin_invite_created_title": "Einladung erstellt",
|
||||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||||
|
|||||||
@@ -703,6 +703,7 @@
|
|||||||
"error_invite_exhausted": "This invite link has already been fully used.",
|
"error_invite_exhausted": "This invite link has already been fully used.",
|
||||||
"error_invite_revoked": "This invite link has been deactivated.",
|
"error_invite_revoked": "This invite link has been deactivated.",
|
||||||
"error_invite_expired": "This invite link has expired.",
|
"error_invite_expired": "This invite link has expired.",
|
||||||
|
"error_group_has_active_invites": "This group cannot be deleted because it is referenced by one or more active invite links.",
|
||||||
"register_heading": "Create account",
|
"register_heading": "Create account",
|
||||||
"register_subtext": "You've been invited to join Familienarchiv.",
|
"register_subtext": "You've been invited to join Familienarchiv.",
|
||||||
"register_label_first_name": "First name",
|
"register_label_first_name": "First name",
|
||||||
@@ -762,6 +763,8 @@
|
|||||||
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
||||||
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
||||||
"admin_new_invite_expires": "Expiry date (optional)",
|
"admin_new_invite_expires": "Expiry date (optional)",
|
||||||
|
"admin_new_invite_groups": "Groups (optional)",
|
||||||
|
"admin_invite_groups_load_error": "Groups could not be loaded. The invite can still be created without group assignment.",
|
||||||
"admin_invite_created_title": "Invite created",
|
"admin_invite_created_title": "Invite created",
|
||||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||||
|
|||||||
@@ -703,6 +703,7 @@
|
|||||||
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
|
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
|
||||||
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
|
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
|
||||||
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
||||||
|
"error_group_has_active_invites": "Este grupo no puede eliminarse porque está referenciado por uno o más enlaces de invitación activos.",
|
||||||
"register_heading": "Crear cuenta",
|
"register_heading": "Crear cuenta",
|
||||||
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
||||||
"register_label_first_name": "Nombre",
|
"register_label_first_name": "Nombre",
|
||||||
@@ -762,6 +763,8 @@
|
|||||||
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
||||||
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
||||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||||
|
"admin_new_invite_groups": "Grupos (opcional)",
|
||||||
|
"admin_invite_groups_load_error": "No se pudieron cargar los grupos. La invitación puede crearse sin asignar grupos.",
|
||||||
"admin_invite_created_title": "Invitación creada",
|
"admin_invite_created_title": "Invitación creada",
|
||||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type ErrorCode =
|
|||||||
| 'INVITE_EXHAUSTED'
|
| 'INVITE_EXHAUSTED'
|
||||||
| 'INVITE_REVOKED'
|
| 'INVITE_REVOKED'
|
||||||
| 'INVITE_EXPIRED'
|
| 'INVITE_EXPIRED'
|
||||||
|
| 'GROUP_HAS_ACTIVE_INVITES'
|
||||||
| 'ANNOTATION_NOT_FOUND'
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
| 'ANNOTATION_UPDATE_FAILED'
|
| 'ANNOTATION_UPDATE_FAILED'
|
||||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||||
@@ -108,6 +109,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_invite_revoked();
|
return m.error_invite_revoked();
|
||||||
case 'INVITE_EXPIRED':
|
case 'INVITE_EXPIRED':
|
||||||
return m.error_invite_expired();
|
return m.error_invite_expired();
|
||||||
|
case 'GROUP_HAS_ACTIVE_INVITES':
|
||||||
|
return m.error_group_has_active_invites();
|
||||||
case 'ANNOTATION_NOT_FOUND':
|
case 'ANNOTATION_NOT_FOUND':
|
||||||
return m.error_annotation_not_found();
|
return m.error_annotation_not_found();
|
||||||
case 'ANNOTATION_UPDATE_FAILED':
|
case 'ANNOTATION_UPDATE_FAILED':
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
const availableStandard = $derived([
|
const availableStandard = $derived([
|
||||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||||
@@ -18,17 +19,7 @@ const availableAdmin = $derived([
|
|||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
@@ -58,23 +49,8 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -85,11 +61,11 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
<form
|
<form
|
||||||
id="new-group-form"
|
id="new-group-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance={() => async ({ result, update }) => {
|
||||||
oninput={() => {
|
if (result.type === 'redirect') unsaved.clearOnSuccess();
|
||||||
isDirty = true;
|
await update();
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
}}
|
||||||
|
oninput={unsaved.markDirty}
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<!-- Name card -->
|
<!-- Name card -->
|
||||||
|
|||||||
125
frontend/src/routes/admin/groups/new/page.svelte.spec.ts
Normal file
125
frontend/src/routes/admin/groups/new/page.svelte.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: (_el: HTMLFormElement, fn?: unknown) => {
|
||||||
|
enhanceCaptureRef.submitFn = fn;
|
||||||
|
return { destroy: vi.fn() };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type SubmitFn = () => Promise<
|
||||||
|
(opts: {
|
||||||
|
result: { type: string; [key: string]: unknown };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin new group page – unsaved-changes guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
enhanceCaptureRef.submitFn = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show unsaved warning initially', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation and shows banner when form is dirty', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cancel navigation when form is clean', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard button calls goto with the target URL', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /verwerfen/i }).click();
|
||||||
|
|
||||||
|
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/groups');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears banner when enhance callback receives a redirect result', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'redirect', location: '/admin/groups', status: 303 },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps banner when enhance callback receives a failure result', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'failure', status: 400, data: { error: 'Name bereits vergeben' } },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { parseBackendError } from '$lib/shared/errors';
|
import { parseBackendError } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export interface InviteListItem {
|
export interface InviteListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,22 +18,37 @@ export interface InviteListItem {
|
|||||||
shareableUrl: string;
|
shareableUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserGroup = components['schemas']['UserGroup'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
const status = url.searchParams.get('status') ?? 'active';
|
const status = url.searchParams.get('status') ?? 'active';
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
const backendError = await parseBackendError(res);
|
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
return {
|
fetch(`${apiUrl}/api/groups`)
|
||||||
invites: [] as InviteListItem[],
|
]);
|
||||||
status,
|
|
||||||
loadError: backendError?.code ?? 'INTERNAL_ERROR'
|
let invites: InviteListItem[] = [];
|
||||||
};
|
let loadError: string | null = null;
|
||||||
|
if (!invitesRes.ok) {
|
||||||
|
const backendError = await parseBackendError(invitesRes);
|
||||||
|
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
invites = await invitesRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
const invites: InviteListItem[] = await res.json();
|
let groups: UserGroup[] = [];
|
||||||
return { invites, status, loadError: null };
|
let groupsLoadError: string | null = null;
|
||||||
|
if (!groupsRes.ok) {
|
||||||
|
const backendError = await parseBackendError(groupsRes);
|
||||||
|
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
const raw: UserGroup[] = await groupsRes.json();
|
||||||
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invites, status, loadError, groups, groupsLoadError };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -45,6 +61,7 @@ export const actions = {
|
|||||||
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||||
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||||
@@ -56,7 +73,8 @@ export const actions = {
|
|||||||
prefillFirstName,
|
prefillFirstName,
|
||||||
prefillLastName,
|
prefillLastName,
|
||||||
prefillEmail,
|
prefillEmail,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
groupIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { InviteListItem } from './+page.server.ts';
|
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
||||||
|
import type { InviteListItem, UserGroup } from './+page.server.ts';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
@@ -12,6 +13,8 @@ let {
|
|||||||
invites: InviteListItem[];
|
invites: InviteListItem[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
};
|
};
|
||||||
form?: {
|
form?: {
|
||||||
createError?: string;
|
createError?: string;
|
||||||
@@ -253,6 +256,20 @@ function statusIcon(status: string) {
|
|||||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<p class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.admin_new_invite_groups()}
|
||||||
|
</p>
|
||||||
|
{#if data.groupsLoadError}
|
||||||
|
<div
|
||||||
|
class="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 font-sans text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
{m.admin_invite_groups_load_error()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<UserGroupsSection groups={data.groups} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if form?.createError}
|
{#if form?.createError}
|
||||||
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
||||||
{getErrorMessage(form.createError)}
|
{getErrorMessage(form.createError)}
|
||||||
|
|||||||
231
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
231
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Test the load and create action logic in isolation without importing the actual module
|
||||||
|
// (which depends on SvelteKit env and $types at runtime).
|
||||||
|
// We replicate the exact logic here to test the branching behaviour.
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:8080';
|
||||||
|
|
||||||
|
interface InviteListItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
displayCode: string;
|
||||||
|
label?: string;
|
||||||
|
useCount: number;
|
||||||
|
maxUses?: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
revoked: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
shareableUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFn(
|
||||||
|
status: string,
|
||||||
|
fetchImpl: (url: string) => Promise<MockResponse>
|
||||||
|
): Promise<{
|
||||||
|
invites: InviteListItem[];
|
||||||
|
status: string;
|
||||||
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
|
}> {
|
||||||
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
|
fetchImpl(`${API_URL}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
|
fetchImpl(`${API_URL}/api/groups`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
let invites: InviteListItem[] = [];
|
||||||
|
let loadError: string | null = null;
|
||||||
|
if (!invitesRes.ok) {
|
||||||
|
const body = (await invitesRes.json()) as { code?: string } | null;
|
||||||
|
loadError = body?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
invites = (await invitesRes.json()) as InviteListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups: UserGroup[] = [];
|
||||||
|
let groupsLoadError: string | null = null;
|
||||||
|
if (!groupsRes.ok) {
|
||||||
|
const body = (await groupsRes.json()) as { code?: string } | null;
|
||||||
|
groupsLoadError = body?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
const raw = (await groupsRes.json()) as UserGroup[];
|
||||||
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invites, status, loadError, groups, groupsLoadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActionFn(
|
||||||
|
formData: FormData,
|
||||||
|
fetchImpl: (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
): Promise<{ ok: boolean; body: unknown }> {
|
||||||
|
const label = (formData.get('label') as string) || undefined;
|
||||||
|
const maxUsesRaw = formData.get('maxUses') as string;
|
||||||
|
const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined;
|
||||||
|
const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined;
|
||||||
|
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||||
|
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||||
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
|
const res = await fetchImpl(`${API_URL}/api/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label,
|
||||||
|
maxUses,
|
||||||
|
prefillFirstName,
|
||||||
|
prefillLastName,
|
||||||
|
prefillEmail,
|
||||||
|
expiresAt,
|
||||||
|
groupIds
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
return { ok: res.ok, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin/invites load()', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mockFetch = vi.fn<any>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
|
const invites: InviteListItem[] = [];
|
||||||
|
const groups: UserGroup[] = [
|
||||||
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => invites })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(result.groupsLoadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
|
const groups: UserGroup[] = [
|
||||||
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'FORBIDDEN' }) });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: false, json: async () => null });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
||||||
|
|
||||||
|
await loadFn('active', mockFetch as unknown as (url: string) => Promise<MockResponse>);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites create action', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mockFetch = vi.fn<any>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('groupIds', 'g-1');
|
||||||
|
fd.append('groupIds', 'g-2');
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await createActionFn(
|
||||||
|
fd,
|
||||||
|
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await createActionFn(
|
||||||
|
fd,
|
||||||
|
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,15 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
||||||
id: 'i-1',
|
id: 'i-1',
|
||||||
|
code: 'XYZ1234567',
|
||||||
displayCode: 'XYZ-1234',
|
displayCode: 'XYZ-1234',
|
||||||
label: 'Familie',
|
label: 'Familie',
|
||||||
useCount: 0,
|
useCount: 0,
|
||||||
maxUses: 5,
|
maxUses: 5,
|
||||||
expiresAt: '2027-01-01T00:00:00Z',
|
expiresAt: '2027-01-01T00:00:00Z',
|
||||||
|
revoked: false,
|
||||||
status: 'active' as string,
|
status: 'active' as string,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
shareableUrl: 'http://example.com/i/i-1',
|
shareableUrl: 'http://example.com/i/i-1',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
@@ -22,11 +25,15 @@ const baseData = (
|
|||||||
invites: ReturnType<typeof makeInvite>[];
|
invites: ReturnType<typeof makeInvite>[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: { id: string; name: string; permissions: string[] }[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
}> = {}
|
}> = {}
|
||||||
) => ({
|
) => ({
|
||||||
invites: [],
|
invites: [],
|
||||||
status: 'active',
|
status: 'active',
|
||||||
loadError: null,
|
loadError: null,
|
||||||
|
groups: [],
|
||||||
|
groupsLoadError: null,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,4 +260,43 @@ describe('admin/invites page', () => {
|
|||||||
const banner = document.querySelector('.bg-red-50');
|
const banner = document.querySelector('.bg-red-50');
|
||||||
expect(banner).not.toBeNull();
|
expect(banner).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── groups section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows a groups-load warning banner when data.groupsLoadError is set', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const banner = document.querySelector('.bg-amber-50');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders group checkboxes inside the new-invite form when groups are provided', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...baseData(),
|
||||||
|
groups: [
|
||||||
|
{ id: 'g-1', name: 'Administratoren', permissions: ['ADMIN'] },
|
||||||
|
{ id: 'g-2', name: 'Familie', permissions: ['READ_ALL'] }
|
||||||
|
],
|
||||||
|
groupsLoadError: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Administratoren' })).toBeVisible();
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Familie' })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
|
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
|
||||||
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
||||||
import AccountSection from './AccountSection.svelte';
|
import AccountSection from './AccountSection.svelte';
|
||||||
|
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
@@ -44,23 +35,8 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -71,11 +47,11 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
<form
|
<form
|
||||||
id="new-user-form"
|
id="new-user-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance={() => async ({ result, update }) => {
|
||||||
oninput={() => {
|
if (result.type === 'redirect') unsaved.clearOnSuccess();
|
||||||
isDirty = true;
|
await update();
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
}}
|
||||||
|
oninput={unsaved.markDirty}
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: (_el: HTMLFormElement, fn?: unknown) => {
|
||||||
|
enhanceCaptureRef.submitFn = fn;
|
||||||
|
return { destroy: vi.fn() };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
const groups = [
|
const groups = [
|
||||||
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
|
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
|
||||||
@@ -20,6 +30,13 @@ const baseData = {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type SubmitFn = () => Promise<
|
||||||
|
(opts: {
|
||||||
|
result: { type: string; [key: string]: unknown };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
>;
|
||||||
|
|
||||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Admin new user page – rendering', () => {
|
describe('Admin new user page – rendering', () => {
|
||||||
@@ -66,3 +83,103 @@ describe('Admin new user page – error display', () => {
|
|||||||
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin new user page – unsaved-changes guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
enhanceCaptureRef.submitFn = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show unsaved warning initially', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation and shows banner when form is dirty', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cancel navigation when form is clean', async () => {
|
||||||
|
render(Page, { 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') } });
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard button calls goto with the target URL', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /verwerfen/i }).click();
|
||||||
|
|
||||||
|
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears banner when enhance callback receives a redirect result', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'redirect', location: '/admin/users', status: 303 },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps banner when enhance callback receives a failure result', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { createRawSnippet } from 'svelte';
|
import { createRawSnippet } from 'svelte';
|
||||||
|
|
||||||
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
||||||
@@ -96,13 +96,13 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('opens dropdown on button click', async () => {
|
it('opens dropdown on button click', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('profile link points to /profile', async () => {
|
it('profile link points to /profile', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /Profil/i }))
|
.element(page.getByRole('link', { name: /Profil/i }))
|
||||||
.toHaveAttribute('href', '/profile');
|
.toHaveAttribute('href', '/profile');
|
||||||
@@ -110,16 +110,16 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('logout button is in the dropdown', async () => {
|
it('logout button is in the dropdown', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes dropdown when Escape is pressed', async () => {
|
it('closes dropdown when Escape is pressed', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
const btn = page.getByRole('button', { name: /MM/ });
|
const btnEl = (await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement;
|
||||||
await btn.click();
|
btnEl.click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
await userEvent.keyboard('{Escape}');
|
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 15_000,
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user