feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import PolicySimulationPage from './PolicySimulationPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const policy = {
|
||||
uuid: 'p1',
|
||||
category: 'pricing',
|
||||
name: 'تخفیف وفاداری',
|
||||
condition: {},
|
||||
effects: [{ type: 'discount_percent', value: 25 }],
|
||||
priority: 0,
|
||||
version: 2,
|
||||
active: false,
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
address_uuid: null,
|
||||
service_uuid: null,
|
||||
catalog_category_uuid: null,
|
||||
specificity: 0,
|
||||
};
|
||||
|
||||
function run(version: number, severity: string) {
|
||||
return {
|
||||
uuid: 'r1',
|
||||
policy_uuid: 'p1',
|
||||
policy_version: version,
|
||||
sample_size: 4,
|
||||
affected_count: 3,
|
||||
affected_percent: 75,
|
||||
severity,
|
||||
created_at: 1_700_000_000,
|
||||
warning: null,
|
||||
rows: [
|
||||
{
|
||||
appointment_uuid: 'a1',
|
||||
patient_name: 'ز. احمدی',
|
||||
slot_start: 1_700_000_000,
|
||||
before: '2,000,000 ریال',
|
||||
after: '1,500,000 ریال',
|
||||
reason: '500,000 ریال تخفیف',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mockApi(runs: unknown[]) {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === '/api/v1/policy/p1') return Promise.resolve({ success: true, data: policy });
|
||||
if (path === '/api/v1/policy/p1/simulations') return Promise.resolve({ success: true, data: runs });
|
||||
return Promise.resolve({ success: true, data: null });
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/policies/:policyUuid/simulate" element={<PolicySimulationPage />} />
|
||||
</Routes>,
|
||||
{ route: '/admin/policies/p1/simulate' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('PolicySimulationPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows the before/after change of each affected appointment', async () => {
|
||||
mockApi([run(2, 'high')]);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ز. احمدی')).toBeInTheDocument());
|
||||
expect(screen.getByText('2,000,000 ریال')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,500,000 ریال')).toBeInTheDocument();
|
||||
expect(screen.getByText(/تحت تأثیر: 3 نوبت/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** گزارشِ نسخهٔ قبلی نباید دکمهٔ فعالسازی را باز کند — همان قاعدهٔ بکاند. */
|
||||
it('disables activation when the report belongs to an older version', async () => {
|
||||
mockApi([run(1, 'low')]);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/فعالسازی قانون/)).toBeDisabled());
|
||||
expect(screen.getByText(/دوباره آزمایش کنید/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables activation when the report matches the current version', async () => {
|
||||
mockApi([run(2, 'low')]);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/فعالسازی قانون/)).toBeEnabled());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user