Files
clinicpro/assets/admin/components/appointments/ResourceBookingModal.test.tsx
T
hamedandClaude Opus 5 fd27ceef7d feat(appointments): book onto a resource from its tab, drop the read-only resource timeline
"Add appointment" while a resource tab is active now opens a booking modal for
that resource: its own services, then a time, then the responsible doctor.

It reuses the booking engine that already existed (appointment-availability →
appointment-hold → appointment-confirm) rather than adding a second path. That
engine answers service-first and returns a resource assignment per slot, so the
modal keeps only the slots where the engine actually offered this resource and
pins that role to it on hold. Showing the other slots would let an operator pick
a time that can only come back as a 409.

The responsible doctor is required because every appointment has a doctor and
confirm will not run without one; the resource records which device the work
happens on.

The read-only "منابع" timeline under the schedule is removed along with its
component and hook, which had no other consumers. GET /api/v1/resources/timeline
is untouched on the backend and now has no client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:43:33 +03:30

112 lines
5.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import ResourceBookingModal from './ResourceBookingModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const resource = {
uuid: 'r-laser',
name: 'لیزر CO2',
address_uuid: 'addr-1',
} as never;
/** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */
const slots = [
{ start: 1783859400, end: 1783861200, assignment: { device: [{ uuid: 'r-laser', name: 'لیزر CO2' }] } },
{ start: 1783863000, end: 1783864800, assignment: { device: [{ uuid: 'r-other', name: 'لیزر دیگر' }] } },
];
beforeEach(() => {
get.mockReset();
post.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/services')) {
return Promise.resolve({ success: true, data: [
{ service_uuid: 's-1', service_name: 'لیزر صورت', active: true, effective_duration_minutes: 30 },
{ service_uuid: 's-2', service_name: 'سرویس غیرفعال', active: false, effective_duration_minutes: 20 },
] });
}
if (url.includes('/my/clinic-doctors')) {
return Promise.resolve({ success: true, data: { data: [{ uuid: 'd-1', name: 'دکتر مرادی' }] } });
}
return Promise.resolve({ success: true, data: [] });
});
post.mockImplementation((url: string) => {
if (url.includes('appointment-availability')) {
return Promise.resolve({ success: true, data: { plan: { total_minutes: 30, segments: [] }, slots, reason: null } });
}
if (url.includes('appointment-hold')) {
return Promise.resolve({ success: true, data: { hold_uuid: 'h-1', assignment: {} } });
}
return Promise.resolve({ success: true, data: { uuid: 'appt-1' } });
});
});
describe('ResourceBookingModal', () => {
/** فقط سرویس‌هایی که خودِ منبع ارائه می‌دهد — سرور هم بقیه را با ۴۲۲ رد می‌کند. */
it('فقط سرویس‌های فعالِ همین منبع را می‌آورد', async () => {
renderWithProviders(
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
);
expect(await screen.findByText('ثبت نوبت — لیزر CO2')).toBeInTheDocument();
const called = get.mock.calls.map((c: any[]) => c[0]);
expect(called.some((u: string) => u.includes('/api/v1/resource/r-laser/services'))).toBe(true);
});
/**
* هستهٔ منبع‌محور بودن: موتور خدمت‌محور جواب می‌دهد، پس وقتی که این منبع در آن
* پیشنهاد نشده باید حذف شود — نشان دادنش یعنی اپراتور چیزی بگیرد که ۴۰۹ می‌شود.
*/
it('فقط وقت‌هایی را نشان می‌دهد که موتور همین منبع را در آن‌ها داده', async () => {
const user = userEvent.setup();
renderWithProviders(
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
);
await screen.findByText('ثبت نوبت — لیزر CO2');
await user.click(await screen.findByText('سرویس را انتخاب کنید'));
await user.click(await screen.findByText(/لیزر صورت/));
await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument());
// وقتِ منبعِ دیگر (۱۷۸۳۸۶۳۰۰۰) نباید دکمه‌ای داشته باشد.
const buttons = screen.getAllByRole('button').map((b) => b.textContent ?? '');
const timeButtons = buttons.filter((t) => /\d{2}:\d{2}/.test(t));
expect(timeButtons).toHaveLength(1);
});
/** پزشک مسئول اجباری است: `confirm` بدون آن اصلاً کار نمی‌کند. */
it('تا پزشک مسئول انتخاب نشود، ثبت غیرفعال است', async () => {
const user = userEvent.setup();
renderWithProviders(
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
);
await screen.findByText('ثبت نوبت — لیزر CO2');
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
await user.click(await screen.findByText('سرویس را انتخاب کنید'));
await user.click(await screen.findByText(/لیزر صورت/));
await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument());
const timeBtn = screen.getAllByRole('button').find((b) => /\d{2}:\d{2}/.test(b.textContent ?? ''));
await user.click(timeBtn!);
// زمان انتخاب شد ولی پزشک نه → هنوز غیرفعال.
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
expect(await screen.findByText('پزشک انجام‌دهنده')).toBeInTheDocument();
});
});