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