feat(approvals): UI für Freigaben — Review-Queue mit Approve/Reject (Modul 1/16)

Backend:
- Phantom-Permission-Bug gefixt: approvals:read/write/approve fehlten in
  CORE_PERMISSIONS (Rollen konnten sie nie zugewiesen bekommen — gleiche
  Fehlerklasse wie dashboard:read in M2)

Frontend:
- api/approvals.ts: TanStack Hooks (list/detail/approve/reject/expire/create)
- pages/Approvals.tsx: Review-Queue — Status-Tabs (Offen/Alle/Genehmigt/
  Abgelehnt/Abgelaufen), Karten mit Aktion/Entity/Requester/Metadata,
  Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve)
- Route /approvals (PermissionRoute approvals:read), Sidebar-Eintrag
- i18n approvals.* + nav.approvals (de/en)

Verifikation: Vitest 10/10 (Rendering, Tabs, Approve/Reject-Flow,
Kommentar, Permission-Gating, Resolved-Zustände), RBAC-Regression 102/102,
tsc clean, Build OK
This commit is contained in:
Agent Zero
2026-09-08 23:26:56 +02:00
parent 4eb05d96ad
commit ecc7a24c1c
8 changed files with 708 additions and 3 deletions
@@ -0,0 +1,179 @@
/**
* Approvals page tests — review queue UI for approval requests.
*
* Covers: rendering, status tabs, approve/reject flow with comment modal,
* permission gating (approvals:approve decides button visibility).
*/
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 { ApprovalsPage } from '@/pages/Approvals';
import type { ApprovalRequest } from '@/api/approvals';
const approveMut = vi.fn().mockResolvedValue({});
const rejectMut = vi.fn().mockResolvedValue({});
const makeApproval = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
id: 'ap-1',
tenant_id: 't-1',
entity_type: 'contact',
entity_id: '11111111-1111-1111-1111-111111111111',
action: 'Kunde löschen',
requested_by: '22222222-2222-2222-2222-222222222222',
requested_by_type: 'agent',
approver_id: null,
approver_group: null,
status: 'pending',
comment: null,
created_at: '2026-09-01T10:00:00Z',
resolved_at: null,
expires_at: null,
metadata: { reason: 'cleanup' },
...overrides,
});
let mockItems: ApprovalRequest[] = [];
let mockCanApprove = true;
vi.mock('@/api/approvals', () => ({
useApprovals: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
}),
useApproveApproval: () => ({
mutate: approveMut,
isPending: false,
}),
useRejectApproval: () => ({
mutate: rejectMut,
isPending: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanApprove || perm !== 'approvals:approve',
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<ApprovalsPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockCanApprove = true;
});
describe('ApprovalsPage', () => {
it('renders the page with title and status tabs', () => {
renderPage();
expect(screen.getByTestId('approvals-page')).toBeInTheDocument();
expect(screen.getByTestId('approvals-tab-pending')).toBeInTheDocument();
expect(screen.getByTestId('approvals-tab-all')).toBeInTheDocument();
expect(screen.getByTestId('approvals-tab-approved')).toBeInTheDocument();
expect(screen.getByTestId('approvals-tab-rejected')).toBeInTheDocument();
expect(screen.getByTestId('approvals-tab-expired')).toBeInTheDocument();
});
it('shows empty state when no approvals exist', () => {
renderPage();
expect(screen.getByTestId('approvals-empty')).toBeInTheDocument();
});
it('renders approval cards with action, entity and requester', () => {
mockItems = [makeApproval()];
renderPage();
expect(screen.getByText('Kunde löschen')).toBeInTheDocument();
expect(screen.getByText(/contact/)).toBeInTheDocument();
expect(screen.getByTestId('approval-card-ap-1')).toBeInTheDocument();
});
it('shows approve/reject buttons for pending requests when user can decide', () => {
mockItems = [makeApproval()];
renderPage();
expect(screen.getByTestId('approve-btn-ap-1')).toBeInTheDocument();
expect(screen.getByTestId('reject-btn-ap-1')).toBeInTheDocument();
});
it('hides approve/reject buttons without approvals:approve permission', () => {
mockItems = [makeApproval()];
mockCanApprove = false;
renderPage();
expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument();
});
it('opens comment modal on approve and submits the decision', async () => {
mockItems = [makeApproval()];
renderPage();
fireEvent.click(screen.getByTestId('approve-btn-ap-1'));
const textarea = await screen.findByTestId('approval-comment-input');
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: 'Sieht gut aus' } });
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
await waitFor(() =>
expect(approveMut).toHaveBeenCalledWith({
requestId: 'ap-1',
payload: { comment: 'Sieht gut aus' },
}),
);
});
it('opens comment modal on reject and submits the decision', async () => {
mockItems = [makeApproval()];
renderPage();
fireEvent.click(screen.getByTestId('reject-btn-ap-1'));
const textarea = await screen.findByTestId('approval-comment-input');
fireEvent.change(textarea, { target: { value: 'Zu riskant' } });
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
await waitFor(() =>
expect(rejectMut).toHaveBeenCalledWith({
requestId: 'ap-1',
payload: { comment: 'Zu riskant' },
}),
);
});
it('submits without comment when textarea is empty', async () => {
mockItems = [makeApproval()];
renderPage();
fireEvent.click(screen.getByTestId('approve-btn-ap-1'));
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
await waitFor(() =>
expect(approveMut).toHaveBeenCalledWith({
requestId: 'ap-1',
payload: { comment: undefined },
}),
);
});
it('does not show action buttons for resolved requests', () => {
mockItems = [makeApproval({ status: 'approved', resolved_at: '2026-09-02T10:00:00Z' })];
renderPage();
expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument();
});
it('shows metadata entries on the card', () => {
mockItems = [makeApproval({ metadata: { reason: 'Datenbereinigung', priority: 'hoch' } })];
renderPage();
expect(screen.getByText(/Datenbereinigung/)).toBeInTheDocument();
expect(screen.getByText(/hoch/)).toBeInTheDocument();
});
});