feat(delegations): UI fuer Berechtigungs-Delegationen — Modul 2/16 des UI-Backlogs

Backend existierte vollstaendig (5 Endpoints: list/create/update/delete/
active-check, delegations:read/write seit Audit-Fix im Katalog), Frontend
hatte 0% Abdeckung. Modul folgt dem Approvals-Muster (Modul 1):

- api/delegations.ts: TanStack-Hooks (useDelegations mit direction-Filter,
useActiveDelegations, create/update/delete-Mutations mit Cache-Invalidierung)
- pages/Delegations.tsx: Richtungstabs (alle/von mir/an mich), Karten mit
  Phasen-Badges (aktiv/geplant/abgelaufen/inaktiv), Erstellen-Dialog mit
  Empfaenger-Picker (useUsers, sich selbst ausschliessend), Start/Ende-
  Datetime, Scope-Toggle (alle Berechtigungen), Aktivieren/Deaktivieren,
  Loeschen mit Confirm — Aktionen hinter delegations:write gegated
- Route /delegations (PermissionRoute delegations:read) — als Core-Route
  bewusst statisch registriert (Phase-Q-Regel: nur Plugin-Routen laufen
  ueber Manifeste) Sidebar-Entry order 92 (ArrowRightLeft-Icon)
- i18n delegations.* + nav.delegations de/en

Verifikation: Vitest 9/9 (Rendering, Tabs, Phasen, Permission-Gating,
Create-Flow, Toggle, Delete) · tsc exit 0 · production build exit 0.
This commit is contained in:
Agent Zero
2026-09-13 09:17:17 +02:00
parent b58c96ff71
commit 36771d471d
7 changed files with 752 additions and 3 deletions
@@ -0,0 +1,220 @@
/**
* Delegations page tests — temporary permission handover UI (module 2/16).
*
* Covers: rendering, direction tabs, phase badges, activate/deactivate +
* delete with permission gating (delegations:write), create dialog with
* recipient picker, empty and error states.
*/
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 { DelegationsPage } from '@/pages/Delegations';
import type { Delegation } from '@/api/delegations';
import type { UserResponse } from '@/api/users';
const createMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const updateMut = vi.fn().mockResolvedValue({});
const deleteMut = vi.fn().mockResolvedValue({});
const CURRENT_USER_ID = '11111111-1111-1111-1111-111111111111';
const OTHER_USER = {
id: '22222222-2222-2222-2222-222222222222',
email: 'other@example.com',
name: 'Other User',
first_name: null,
last_name: null,
avatar_url: null,
role: 'viewer',
role_id: null,
is_active: true,
tenant_id: 't-1',
} as UserResponse;
const makeDelegation = (overrides: Partial<Delegation> = {}): Delegation => ({
id: 'dg-1',
from_user_id: CURRENT_USER_ID,
to_user_id: OTHER_USER.id,
start_at: '2026-09-01T00:00:00Z',
end_at: '2026-10-01T00:00:00Z',
scope: { all: true },
active: true,
tenant_id: 't-1',
created_at: '2026-08-30T10:00:00Z',
updated_at: null,
...overrides,
});
let mockItems: Delegation[] = [];
let mockCanWrite = true;
let mockError = false;
vi.mock('@/api/delegations', () => ({
useDelegations: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useCreateDelegation: () => ({
mutate: createMut,
isPending: false,
}),
useUpdateDelegation: () => ({
mutate: updateMut,
isPending: false,
}),
useDeleteDelegation: () => ({
mutate: deleteMut,
isPending: false,
}),
}));
vi.mock('@/api/users', () => ({
useUsers: () => ({
data: { items: [OTHER_USER], total: 1, page: 1, page_size: 100 },
isLoading: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanWrite || perm !== 'delegations:write',
}),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (s: { user: { id: string } }) => unknown) =>
selector({ user: { id: CURRENT_USER_ID } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<DelegationsPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockCanWrite = true;
mockError = false;
});
describe('DelegationsPage', () => {
it('renders the page with title and direction tabs', () => {
renderPage();
expect(screen.getByTestId('delegations-page')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-all')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-from')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-to')).toBeInTheDocument();
});
it('shows empty state when no delegations exist', () => {
renderPage();
expect(screen.getByTestId('delegations-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
expect(screen.getByTestId('delegations-error')).toBeInTheDocument();
});
it('renders delegation cards with phase badges', () => {
mockItems = [
makeDelegation(),
makeDelegation({ id: 'dg-2', active: false }),
];
renderPage();
expect(screen.getByTestId('delegation-card-dg-1')).toBeInTheDocument();
expect(screen.getByTestId('delegation-card-dg-2')).toBeInTheDocument();
// dg-1: 2026 range is in the past relative to real now — phase derived
// from dates; both badges must exist with the right testids
expect(screen.getByTestId('delegation-phase-dg-1')).toBeInTheDocument();
expect(screen.getByTestId('delegation-phase-dg-2')).toBeInTheDocument();
// inactive card shows the inactive badge text
expect(screen.getByTestId('delegation-phase-dg-2')).toHaveTextContent(/inactive|inaktiv/i);
});
it('shows create button only with delegations:write', () => {
const { unmount } = renderPage();
expect(screen.getByTestId('delegation-create-open')).toBeInTheDocument();
unmount();
mockCanWrite = false;
const { getByTestId } = renderPage();
expect(getByTestId('delegations-page')).toBeInTheDocument();
// create button is NOT rendered without the permission
expect(screen.queryByTestId('delegation-create-open')).not.toBeInTheDocument();
});
it('hides action buttons without delegations:write', () => {
mockItems = [makeDelegation()];
mockCanWrite = false;
renderPage();
expect(screen.queryByTestId('delegation-toggle-dg-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('delegation-delete-dg-1')).not.toBeInTheDocument();
});
it('opens the create dialog, picks a recipient and submits', async () => {
renderPage();
fireEvent.click(screen.getByTestId('delegation-create-open'));
// Dialog is open
expect(screen.getByTestId('delegation-create-submit')).toBeInTheDocument();
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: OTHER_USER.id } });
// The Input component renders label htmlFor + matching input id —
// resolve the datetime inputs via their label text (i18n-resolved:
// de "Beginn" / "Ende", en "Start" / "End").
const startInput = (screen.getByLabelText(/beginn|start/i) ||
document.querySelector('input[type="datetime-local"]')) as HTMLInputElement;
const dateInputs = Array.from(document.querySelectorAll('input[type="datetime-local"]')) as HTMLInputElement[];
const startEl = startInput || dateInputs[0];
const endEl = dateInputs[1] ?? dateInputs[0];
fireEvent.change(startEl, { target: { value: '2026-09-14T09:00' } });
fireEvent.change(endEl, { target: { value: '2026-09-20T09:00' } });
fireEvent.click(screen.getByTestId('delegation-create-submit'));
await waitFor(() => {
// createMut IS the mutate function: mutate(payload, { onSuccess }) — two args
expect(createMut).toHaveBeenCalledWith(
expect.objectContaining({
to_user_id: OTHER_USER.id,
scope: { all: true },
}),
expect.anything(),
);
});
});
it('deactivates a delegation via the toggle button', async () => {
mockItems = [makeDelegation()];
renderPage();
fireEvent.click(screen.getByTestId('delegation-toggle-dg-1'));
await waitFor(() => {
expect(updateMut).toHaveBeenCalledWith(
{ delegationId: 'dg-1', payload: { active: false } },
);
});
});
it('deletes a delegation after confirmation', async () => {
mockItems = [makeDelegation()];
window.confirm = vi.fn(() => true);
renderPage();
fireEvent.click(screen.getByTestId('delegation-delete-dg-1'));
await waitFor(() => {
expect(deleteMut).toHaveBeenCalledWith('dg-1');
});
});
});