diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js new file mode 100644 index 0000000..0b8b622 --- /dev/null +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/services/response', () => ({ + request: { + getServiceSlotsForReschedule: vi.fn(), + serviceReschedule: vi.fn(), + }, +})); + +import { request } from '@/services/response'; +import RescheduleModal from './RescheduleModal'; + +const appointment = { + uuid: 'a-1', + doctor: { uuid: 'd-1' }, + service_items: [{ uuid: 's-1' }, { uuid: 's-2' }], +}; + +const slots = { + data: { + total_duration_minutes: 30, + start_times: [ + { start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' }, + { start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' }, + ], + }, +}; + +async function openAndPickFirstDay(user) { + render(); + await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' })); + + const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent)); + await user.click(days[0]); +} + +describe('مودال جابه‌جایی نوبت سایت', () => { + beforeEach(() => { + request.getServiceSlotsForReschedule.mockResolvedValue(slots); + request.serviceReschedule.mockResolvedValue({ data: {} }); + }); + + /** + * ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» می‌بیند و نمی‌تواند + * حتی به همان روز جابه‌جا شود. + */ + it('وقت‌ها را با کنارگذاشتن نوبت فعلی می‌گیرد', async () => { + const user = userEvent.setup(); + await openAndPickFirstDay(user); + + await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled()); + + const [doctorUuid, , appointmentUuid, serviceUuids] = + request.getServiceSlotsForReschedule.mock.calls[0]; + + expect(doctorUuid).toBe('d-1'); + expect(appointmentUuid).toBe('a-1'); + expect(serviceUuids).toEqual(['s-1', 's-2']); + }); + + /** مدت را سرور حساب می‌کند؛ فرانت فقط زمان شروع می‌فرستد. */ + it('فقط زمان شروع را می‌فرستد، نه مدت', async () => { + const onDone = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' })); + + const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent)); + await user.click(days[0]); + + const slot = await screen.findByRole('button', { name: '09:00' }); + await user.click(slot); + await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' })); + + await waitFor(() => + expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }), + ); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + it('روزِ بدون وقت آزاد را صریح می‌گوید', async () => { + request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } }); + const user = userEvent.setup(); + + await openAndPickFirstDay(user); + + expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument(); + }); + + /** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */ + it('پیام خطای سرور را نشان می‌دهد', async () => { + request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است')); + const user = userEvent.setup(); + + await openAndPickFirstDay(user); + + await user.click(await screen.findByRole('button', { name: '09:00' })); + await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' })); + + expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument(); + }); +}); diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js new file mode 100644 index 0000000..7b02f54 --- /dev/null +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/services/response', () => ({ + request: { + getCancellationPreview: vi.fn(), + cancelAppointment: vi.fn(), + }, +})); + +import { request } from '@/services/response'; +import ModalDeleteComment from './index'; + +const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' }; + +const preview = (over = {}) => ({ + data: { + penalty_rials: 2_500_000, + deposit_refundable: true, + credit_refundable: true, + within_free_window: false, + notes: [], + paid_rials: 5_000_000, + ...over, + }, +}); + +describe('مودال لغو نوبت سایت', () => { + beforeEach(() => { + request.cancelAppointment.mockResolvedValue({ data: {} }); + }); + + /** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را می‌بست و هیچ درخواستی نمی‌رفت. */ + it('پیش از تأیید، جریمه را از سرور نشان می‌دهد', async () => { + request.getCancellationPreview.mockResolvedValue(preview()); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + // ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط + // «مبلغ درست + واحد» سنجیده می‌شود نه شکل رقم‌ها. + const amount = await screen.findByText(/تومان$/); + expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/); + expect(request.cancelAppointment).not.toHaveBeenCalled(); + }); + + it('در بازهٔ رایگان «بدون جریمه» می‌گوید، نه صفر', async () => { + request.getCancellationPreview.mockResolvedValue( + preview({ penalty_rials: 0, within_free_window: true }), + ); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + expect(await screen.findByText('بدون جریمه')).toBeInTheDocument(); + expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument(); + }); + + it('تأیید، لغو را واقعاً می‌فرستد و والد را خبر می‌کند', async () => { + request.getCancellationPreview.mockResolvedValue(preview()); + const onDone = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + await screen.findByText(/جریمهٔ لغو/); + + await user.click(screen.getByRole('button', { name: 'لغو نوبت' })); + + await waitFor(() => + expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }), + ); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + /** پیش‌نمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */ + it('نبودِ پیش‌نمایش، لغو را قفل نمی‌کند', async () => { + request.getCancellationPreview.mockRejectedValue(new Error('down')); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled(); + }); +}); diff --git a/lib/getStateInfo.test.js b/lib/getStateInfo.test.js index f40cb1d..e78fefb 100644 --- a/lib/getStateInfo.test.js +++ b/lib/getStateInfo.test.js @@ -6,6 +6,12 @@ vi.mock('next/headers', () => ({ headers: async () => ({ get: (k) => (k === 'host' ? hostRef.value : null) }), })); +// هاستِ ناشناخته به API نمایندگی می‌رود؛ بدون این mock تست به شبکهٔ واقعی می‌زند و +// بعد از پنج ثانیه timeout می‌شود — همان شکستِ قدیمیِ این فایل. +vi.mock('@/lib/req', () => ({ + fetchReq: async () => null, +})); + import { getStateInfo } from '@/lib/getStateInfo'; beforeEach(() => { diff --git a/tailwind.config.js b/tailwind.config.js index 4515817..effef39 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -24,5 +24,17 @@ module.exports = { }, }, plugins: [], - darkMode: "class", + /** + * دو سازوکار تم هم‌زمان در پروژه هست و هر دو باید کار کنند: + * صفحات عمومی با `data-theme="dark"` (از `next-themes` با `attribute="data-"`) + * و پنل با کلاس `.dark`. + * + * تا امروز فقط `class` تعریف شده بود، پس **همهٔ `dark:`های صفحات عمومی هرگز اجرا + * نمی‌شدند** — جریان رزرو در دارک‌مود سفید می‌ماند. استراتژی `variant` هر دو را + * می‌پذیرد و هیچ‌کدام را عوض نمی‌کند. + */ + darkMode: [ + "variant", + ["&:where(.dark, .dark *)", '&:where([data-theme="dark"], [data-theme="dark"] *)'], + ], };