feat(ui): Gäste-Verwaltung UI — SettingsGuests (UI-Backlog Modul 14/16)

- api/guests.ts: useGuests, useInviteGuest, useRevokeGuest (/api/v1/guests)
- SettingsGuests.tsx: Admin-Gate (Outbox-Muster), Gästeliste mit Status-Badges
  (invited/active/disabled), Invite-Modal (RHF+zod), Revoke-ConfirmDialog
- Route /settings/guests + Nav-Eintrag in Settings.tsx
- i18n de/en: 14 Keys
- Vitest 8/8, tsc clean, Build OK
This commit is contained in:
Agent Zero
2026-09-16 00:08:59 +02:00
parent f8b07032e5
commit b3eaa0e39b
7 changed files with 473 additions and 2 deletions
@@ -0,0 +1,154 @@
/**
* Guests settings page tests — admin guest management UI
* (UI-Backlog module 14/16).
*
* Covers: admin gate, guest list rendering with status badges,
* invite dialog flow, revoke flow, empty state.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { SettingsGuestsPage } from '@/pages/SettingsGuests';
type GuestLike = {
id: string;
email: string;
name: string;
status: string;
role: string;
created_at: string | null;
};
const inviteMut = vi.fn().mockResolvedValue({});
const revokeMut = vi.fn().mockResolvedValue({});
const makeGuest = (overrides: Partial<GuestLike> = {}): GuestLike => ({
id: '11111111-1111-1111-1111-111111111111',
email: 'gast@example.de',
name: 'Test Gast',
status: 'active',
role: 'guest',
created_at: '2026-09-01T10:00:00Z',
...overrides,
});
let mockGuests: GuestLike[] = [];
let mockIsAdmin = true;
let mockLoading = false;
vi.mock('@/api/guests', () => ({
useGuests: () => ({ data: mockGuests, isLoading: mockLoading, isError: false }),
useInviteGuest: () => ({ mutate: inviteMut, mutateAsync: inviteMut, isPending: false }),
useRevokeGuest: () => ({ mutate: revokeMut, mutateAsync: revokeMut, isPending: false }),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (state: { user: { is_system_admin: boolean } | null }) => unknown) =>
selector({ user: { is_system_admin: mockIsAdmin } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<SettingsGuestsPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockGuests = [];
mockIsAdmin = true;
mockLoading = false;
});
describe('SettingsGuestsPage', () => {
it('renders admin-only notice for non-admin users', () => {
mockIsAdmin = false;
renderPage();
expect(screen.getByTestId('guests-admin-only')).toBeInTheDocument();
expect(screen.queryByTestId('invite-guest-btn')).not.toBeInTheDocument();
});
it('renders empty state when no guests exist', () => {
renderPage();
expect(screen.getByTestId('settings-guests-page')).toBeInTheDocument();
expect(screen.getByTestId('invite-guest-btn')).toBeInTheDocument();
// EmptyState text is rendered via i18n (noGuests)
expect(screen.getByText(/Noch keine Gäste|No guests/i)).toBeInTheDocument();
});
it('renders guest cards with status badges', () => {
mockGuests = [
makeGuest({ status: 'active' }),
makeGuest({ id: '22222222-2222-2222-2222-222222222222', email: 'pending@example.de', name: 'Pending Gast', status: 'invited' }),
makeGuest({ id: '33333333-3333-3333-3333-333333333333', email: 'gone@example.de', name: 'Gone Gast', status: 'disabled' }),
];
renderPage();
expect(screen.getByTestId('guest-card-11111111-1111-1111-1111-111111111111')).toBeInTheDocument();
expect(screen.getByTestId('guest-card-22222222-2222-2222-2222-222222222222')).toBeInTheDocument();
expect(screen.getByTestId('guest-card-33333333-3333-3333-3333-333333333333')).toBeInTheDocument();
expect(screen.getByText('gast@example.de')).toBeInTheDocument();
expect(screen.getByText('pending@example.de')).toBeInTheDocument();
expect(screen.getByText('gone@example.de')).toBeInTheDocument();
});
it('hides revoke button for disabled guests only', () => {
mockGuests = [
makeGuest({ status: 'disabled' }),
];
renderPage();
expect(screen.queryByTestId('revoke-guest-11111111-1111-1111-1111-111111111111')).not.toBeInTheDocument();
});
it('shows revoke button for active guests', () => {
mockGuests = [makeGuest({ status: 'active' })];
renderPage();
expect(screen.getByTestId('revoke-guest-11111111-1111-1111-1111-111111111111')).toBeInTheDocument();
});
it('opens invite dialog, fills form and submits', async () => {
renderPage();
fireEvent.click(screen.getByTestId('invite-guest-btn'));
const form = await screen.findByTestId('invite-guest-form');
expect(form).toBeInTheDocument();
fireEvent.change(screen.getByTestId('guest-name'), { target: { value: 'Neuer Gast' } });
fireEvent.change(screen.getByTestId('guest-email'), { target: { value: 'neu@example.de' } });
fireEvent.click(screen.getByTestId('send-guest-invite-btn'));
await waitFor(() => {
expect(inviteMut).toHaveBeenCalledWith({ name: 'Neuer Gast', email: 'neu@example.de' });
});
});
it('revokes a guest after confirmation', async () => {
mockGuests = [makeGuest({ status: 'active' })];
renderPage();
fireEvent.click(screen.getByTestId('revoke-guest-11111111-1111-1111-1111-111111111111'));
// ConfirmDialog confirm button (default label, de locale) — must not
// match the card's revoke button ('Widerrufen'), so exact match.
const confirmBtn = await screen.findByRole('button', { name: 'Bestätigen' });
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(revokeMut).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111');
});
});
it('blocks invite submission with invalid email (zod)', async () => {
renderPage();
fireEvent.click(screen.getByTestId('invite-guest-btn'));
await screen.findByTestId('invite-guest-form');
fireEvent.change(screen.getByTestId('guest-name'), { target: { value: 'Neuer Gast' } });
fireEvent.change(screen.getByTestId('guest-email'), { target: { value: 'not-an-email' } });
fireEvent.click(screen.getByTestId('send-guest-invite-btn'));
await waitFor(() => {
expect(inviteMut).not.toHaveBeenCalled();
});
});
});