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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user