188 lines
8.6 KiB
TypeScript
188 lines
8.6 KiB
TypeScript
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<typeof vi.fn>;
|
||
const post = api.post as ReturnType<typeof vi.fn>;
|
||
|
||
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(<ResourcesPage />, { 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(<ResourcesPage />, { route: '/admin/resources' });
|
||
|
||
await waitFor(() => expect(screen.getByText(/تجهیزات/)).toBeInTheDocument());
|
||
});
|
||
|
||
it('renders each skill with its level', async () => {
|
||
mockApi();
|
||
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
|
||
|
||
await waitFor(() => expect(screen.getByText('آلکساندرایت · 4')).toBeInTheDocument());
|
||
});
|
||
|
||
/**
|
||
* فیلترها از URL خوانده میشوند و مستقیم به سرور میروند — فیلتر دوبارهٔ سمت
|
||
* کلاینت روی فهرستی که خودش فیلترشده آمده، منبع اختلاف است.
|
||
*/
|
||
it('passes the URL filters straight to the server', async () => {
|
||
mockApi();
|
||
renderWithProviders(<ResourcesPage />, {
|
||
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(<ResourcesPage />, { 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(<ResourcesPage />, { 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(<ResourcesPage />, { 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(<ResourcesPage />, { route: '/admin/resources' });
|
||
|
||
await waitFor(() => expect(screen.getByText('۱ از ۳ منبع پلن فعلی')).toBeInTheDocument());
|
||
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
|
||
});
|
||
|
||
it('پلن نامحدود نه شمارنده دارد نه سقف', async () => {
|
||
mockWithPlan(-1, [resource, { ...resource, uuid: 'r2', name: 'لیزر ۲' }]);
|
||
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
|
||
|
||
// تا فهرست و پلن هر دو ننشستهاند، «نبودِ» شمارنده چیزی ثابت نمیکند.
|
||
await waitFor(() => expect(screen.getByText('لیزر ۲')).toBeInTheDocument());
|
||
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
|
||
expect(screen.queryByText(/منبع پلن فعلی/)).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|