feat(templates): UI fuer Berechtigungs-Vorlagen — Modul 6/16 des UI-Backlogs
Backend existierte vollstaendig (list mit entity_type-Filter, create, update, delete, apply — templates:read/write, seit Audit-Fix im Katalog), Frontend hatte 0% Abdeckung. - api/permissionTemplates.ts: TanStack-Hooks (usePermissionTemplates, create/update/delete/apply mit Cache-Invalidierung, TemplateLevel-Typ) - pages/PermissionTemplates.tsx: Template-Karten (Name, Level-Badge, Entity-Type, Auto-Share-Zusammenfassung), Create/Edit-Dialog mit Level-Select und JSON-Textarea inkl. Array-Validierung mit Fehlertext, Apply-Dialog (Entity-Type vorbelegt, Entity-ID), Ergebnis-Banner mit Anzahl erstellter Berechtigungen, Delete mit Confirm — alle Aktionen hinter templates:write gegated - Platzierung: Settings-Subpage /settings/permission-templates (statisch, Core-Route) + Settings-Nav-Item - i18n permissionTemplates.* de/en Verifikation: Vitest 10/10 (Rendering, Level-Badges, Create mit validem + invalidem JSON, Edit prefilled, Apply mit Ergebnis, Delete-Confirm, Permission-Gating, Empty/Error) · tsc exit 0 · production build exit 0.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* PermissionTemplates page tests — reusable permission presets (module 6/16).
|
||||
*
|
||||
* Covers: rendering, template cards with level badges, create flow with
|
||||
* JSON validation, edit flow, apply flow, delete with confirmation,
|
||||
* permission gating (templates:write), 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 { PermissionTemplatesPage } from '@/pages/PermissionTemplates';
|
||||
import type { PermissionTemplate } from '@/api/permissionTemplates';
|
||||
|
||||
const createMut = vi.fn().mockImplementation((_payload, opts) => {
|
||||
opts?.onSuccess?.({});
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const updateMut = vi.fn().mockImplementation((_payload, opts) => {
|
||||
opts?.onSuccess?.({});
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const deleteMut = vi.fn().mockResolvedValue({});
|
||||
const applyMut = vi.fn().mockImplementation((_payload, opts) => {
|
||||
opts?.onSuccess?.({ applied: [{ id: 'ep-1' }], count: 1 });
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
const makeTemplate = (overrides: Partial<PermissionTemplate> = {}): PermissionTemplate => ({
|
||||
id: 'tp-1',
|
||||
name: 'Standard-Freigabe Kontakte',
|
||||
entity_type: 'contact',
|
||||
trigger_condition: null,
|
||||
auto_share_with: [
|
||||
{ principal_type: 'user', principal_id: '22222222-2222-2222-2222-222222222222', level: 'read' },
|
||||
],
|
||||
level: 'read',
|
||||
tenant_id: 't-1',
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockItems: PermissionTemplate[] = [];
|
||||
let mockCanWrite = true;
|
||||
let mockError = false;
|
||||
|
||||
vi.mock('@/api/permissionTemplates', () => ({
|
||||
usePermissionTemplates: () => ({
|
||||
data: { items: mockItems, total: mockItems.length },
|
||||
isLoading: false,
|
||||
isError: mockError,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useCreatePermissionTemplate: () => ({
|
||||
mutate: createMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePermissionTemplate: () => ({
|
||||
mutate: updateMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeletePermissionTemplate: () => ({
|
||||
mutate: deleteMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useApplyPermissionTemplate: () => ({
|
||||
mutate: applyMut,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({
|
||||
hasPermission: (perm: string) => mockCanWrite || perm !== 'templates:write',
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<PermissionTemplatesPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockItems = [];
|
||||
mockCanWrite = true;
|
||||
mockError = false;
|
||||
});
|
||||
|
||||
describe('PermissionTemplatesPage', () => {
|
||||
it('renders the page with title', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('permission-templates-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no templates exist', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('templates-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state on load failure', () => {
|
||||
mockError = true;
|
||||
renderPage();
|
||||
expect(screen.getByTestId('templates-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders template cards with level badge and entity type', () => {
|
||||
mockItems = [makeTemplate()];
|
||||
renderPage();
|
||||
expect(screen.getByTestId('template-card-tp-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Standard-Freigabe Kontakte')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('template-level-tp-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides action buttons without templates:write', () => {
|
||||
mockItems = [makeTemplate()];
|
||||
mockCanWrite = false;
|
||||
renderPage();
|
||||
expect(screen.queryByTestId('template-create-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('template-edit-tp-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('template-delete-tp-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('template-apply-tp-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a template via the dialog with valid JSON', async () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('template-create-open'));
|
||||
expect(screen.getByTestId('template-form-submit')).toBeInTheDocument();
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: 'Neue Vorlage' } });
|
||||
const typeInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
|
||||
fireEvent.change(typeInputs[0], { target: { value: 'contact' } });
|
||||
|
||||
const jsonArea = screen.getByTestId('template-share-json') as HTMLTextAreaElement;
|
||||
fireEvent.change(jsonArea, {
|
||||
target: { value: '[{"principal_type": "user", "principal_id": "abc", "level": "read"}]' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('template-form-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMut).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Neue Vorlage',
|
||||
entity_type: 'contact',
|
||||
auto_share_with: [{ principal_type: 'user', principal_id: 'abc', level: 'read' }],
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid JSON in the create dialog', () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('template-create-open'));
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: 'Neue Vorlage' } });
|
||||
const typeInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
|
||||
fireEvent.change(typeInputs[0], { target: { value: 'contact' } });
|
||||
|
||||
const jsonArea = screen.getByTestId('template-share-json') as HTMLTextAreaElement;
|
||||
fireEvent.change(jsonArea, { target: { value: '{invalid json' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('template-form-submit'));
|
||||
|
||||
expect(createMut).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit dialog prefilled and submits the update', async () => {
|
||||
mockItems = [makeTemplate()];
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('template-edit-tp-1'));
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
|
||||
expect(nameInput).toHaveValue('Standard-Freigabe Kontakte');
|
||||
fireEvent.change(nameInput, { target: { value: 'Geänderte Vorlage' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('template-form-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMut).toHaveBeenCalledWith(
|
||||
{
|
||||
templateId: 'tp-1',
|
||||
payload: expect.objectContaining({
|
||||
name: 'Geänderte Vorlage',
|
||||
entity_type: 'contact',
|
||||
}),
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a template to an entity and shows the result count', async () => {
|
||||
mockItems = [makeTemplate()];
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('template-apply-tp-1'));
|
||||
|
||||
const entityInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
|
||||
const entityIdInput = entityInputs.find(
|
||||
(el) => (el as HTMLInputElement).placeholder?.includes('00000000'),
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(entityIdInput, {
|
||||
target: { value: '11111111-1111-1111-1111-111111111111' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('template-apply-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(applyMut).toHaveBeenCalledWith(
|
||||
{
|
||||
entity_type: 'contact',
|
||||
entity_id: '11111111-1111-1111-1111-111111111111',
|
||||
template_id: 'tp-1',
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByTestId('templates-result')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a template after confirmation', async () => {
|
||||
mockItems = [makeTemplate()];
|
||||
window.confirm = vi.fn(() => true);
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('template-delete-tp-1'));
|
||||
await waitFor(() => {
|
||||
expect(deleteMut).toHaveBeenCalledWith('tp-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user