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