Compare commits

...

3 Commits

Author SHA1 Message Date
Marcel
82e61291d4 feat(invites): group picker in new-invite form
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m11s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m57s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
- load() fetches /api/groups in parallel with /api/invites; returns
  sorted groups array and groupsLoadError for partial failures
- create action forwards groupIds[] to POST /api/invites so invited
  users are placed in the selected groups on registration
- +page.svelte: group checkboxes via UserGroupsSection inside the form;
  amber warning banner when groups could not be loaded
- page.svelte.test.ts: groups checkboxes + warning banner tests
- page.server.test.ts: parallel fetch, sorting, error fallback,
  groupIds in POST body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:20:53 +02:00
Marcel
a1b319a535 feat(frontend): add GROUP_HAS_ACTIVE_INVITES error code + i18n keys
Adds the error code to the ErrorCode union and getErrorMessage() switch.
Adds admin_new_invite_groups, admin_invite_groups_load_error, and
error_group_has_active_invites to all three locale files (de/en/es).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:19:59 +02:00
Marcel
d7fcbfd4d9 feat(groups): prevent deletion of groups referenced by active invites
Adds GROUP_HAS_ACTIVE_INVITES error code and guards UserService.deleteGroup()
with a 409 conflict when any active (non-revoked, non-expired, non-exhausted)
invite token still holds the group UUID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:57:06 +02:00
12 changed files with 370 additions and 12 deletions

View File

@@ -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 */

View File

@@ -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);
} }

View File

@@ -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);
} }
} }

View File

@@ -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();

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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':

View File

@@ -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
}) })
}); });

View File

@@ -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)}

View 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([]);
});
});

View File

@@ -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();
});
}); });