import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, 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() } })); vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }), })); import { api } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import ResourcesPage from './ResourcesPage'; const get = api.get as ReturnType; const post = api.post as ReturnType; const branch = { id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک', name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null }, address: null, telephone: null, active: true, timezone: 'Asia/Tehran', city: null, province: null, }; const laserType = { uuid: 't1', code: 'laser', name: 'دستگاه لیزر', is_system: false, active: true, resources_count: 1, created_at: 0, updated_at: 0 }; const skill = { uuid: 's1', name: 'آلکساندرایت', active: true, resources_count: 1, created_at: 0, updated_at: 0 }; const resource = { uuid: 'r1', name: 'لیزر ۱', address_uuid: 'b1', address_name: 'شعبهٔ مرکزی', type_uuid: 't1', type_code: 'laser', type_name: 'دستگاه لیزر', capacity: 3, setup_minutes: 5, cleanup_minutes: 10, attributes: {}, subject_kind: null, subject_uuid: null, skills: [{ skill_uuid: 's1', skill_name: 'آلکساندرایت', level: 4 }], active: true, created_at: 0, updated_at: 0, }; function mockApi() { get.mockImplementation((path: string) => { if (path.startsWith('/api/v1/addresses')) return Promise.resolve({ success: true, data: [branch] }); if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] }); if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] }); if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [resource] }); return Promise.resolve({ success: true, data: [] }); }); post.mockResolvedValue({ success: true, data: resource }); } describe('ResourcesPage', () => { beforeEach(() => vi.clearAllMocks()); it('shows capacity as concurrency and both buffer minutes', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument()); expect(screen.getByText('3 نفر')).toBeInTheDocument(); expect(screen.getByText('5 / 10 دقیقه')).toBeInTheDocument(); }); it('labels a resource with no bridge as equipment', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText(/تجهیزات/)).toBeInTheDocument()); }); it('renders each skill with its level', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText('آلکساندرایت · 4')).toBeInTheDocument()); }); /** * فیلترها از URL خوانده می‌شوند و مستقیم به سرور می‌روند — فیلتر دوبارهٔ سمت * کلاینت روی فهرستی که خودش فیلترشده آمده، منبع اختلاف است. */ it('passes the URL filters straight to the server', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources?type=t1&skill=s1&status=1', }); await waitFor(() => expect(get).toHaveBeenCalled()); const called = get.mock.calls.map((c) => String(c[0])); const listCall = called.find((p) => p.startsWith('/api/v1/resources')); // شعبه فیلتر نیست: منابع دامنهٔ شعبه ندارند. expect(listCall).not.toContain('address_uuid'); expect(listCall).toContain('type_uuid=t1'); expect(listCall).toContain('skill_uuid=s1'); expect(listCall).toContain('active=1'); }); it('sends the type only when creating, never when editing', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument()); fireEvent.click(screen.getByText('افزودن منبع')); fireEvent.change(screen.getByPlaceholderText('لیزر آلکساندرایت ۱'), { target: { value: 'لیزر تازه' } }); // نوع هنوز انتخاب نشده است → ذخیره غیرفعال است. expect(screen.getByText('ذخیره').closest('button')).toBeDisabled(); }); /** * جدول فقط فهرست است. هر اقدامِ سطح-منبع تبی در خودِ منبع دارد، پس ردیف یک لینک * دارد نه شش دکمه — که هم جدول را از لبه بیرون می‌زد، هم یک کار را دو جا می‌گذاشت. */ it('هر ردیف فقط یک اقدام دارد: مدیریتِ همان منبع', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument()); expect(screen.getAllByRole('link', { name: 'مدیریت' })[0]) .toHaveAttribute('href', '/admin/resources/r1'); for (const gone of ['ویرایش', 'مهارت‌ها', 'سرویس‌ها', 'مسدودسازی', 'حذف', 'تنظیمات']) { expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument(); } }); /** * سقف منابع از پلن اشتراک می‌آید. رسیدن به سقف باید همان‌جا گفته شود، نه با ۴۲۲ * بعد از پر کردن فرم. */ describe('سقف منابع پلن', () => { function mockWithPlan(maxResources: number, resources: unknown[]) { // اشتراک فقط برای نقش‌های صاحب محیط خوانده می‌شود. useAuthStore.setState({ primaryRole: 'clinic' }); get.mockImplementation((path: string) => { if (path.startsWith('/api/v1/subscription/my')) { return Promise.resolve({ success: true, data: { subscription: null, used_trial: false, effective_plan: { name: 'free', level: 0, max_secretaries: 1, max_resources: maxResources, features: {} }, }, }); } if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] }); if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] }); if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: resources }); return Promise.resolve({ success: true, data: [] }); }); } it('در سقف، دکمهٔ افزودن جای خود را به ارتقای پنل می‌دهد', async () => { mockWithPlan(1, [resource]); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByRole('link', { name: 'ارتقای پنل' })).toHaveAttribute('href', '/admin/subscription'), ); expect(screen.queryByText('افزودن منبع')).not.toBeInTheDocument(); expect(screen.getByText('۱ از ۱ منبع پلن فعلی')).toBeInTheDocument(); }); it('زیر سقف، دکمهٔ افزودن و شمارندهٔ مصرف را نشان می‌دهد', async () => { mockWithPlan(3, [resource]); renderWithProviders(, { route: '/admin/resources' }); await waitFor(() => expect(screen.getByText('۱ از ۳ منبع پلن فعلی')).toBeInTheDocument()); expect(screen.getByText('افزودن منبع')).toBeInTheDocument(); }); it('پلن نامحدود نه شمارنده دارد نه سقف', async () => { mockWithPlan(-1, [resource, { ...resource, uuid: 'r2', name: 'لیزر ۲' }]); renderWithProviders(, { route: '/admin/resources' }); // تا فهرست و پلن هر دو ننشسته‌اند، «نبودِ» شمارنده چیزی ثابت نمی‌کند. await waitFor(() => expect(screen.getByText('لیزر ۲')).toBeInTheDocument()); expect(screen.getByText('افزودن منبع')).toBeInTheDocument(); expect(screen.queryByText(/منبع پلن فعلی/)).not.toBeInTheDocument(); }); }); });