feat: add comprehensive tests for UI components, hooks, and API interactions

- Implement tests for Pagination, StatusBadge, ConfirmDialog, and MobileInput components.
- Add tests for useSubscription, usePaymentConfig, and usePwaInstall hooks.
- Create tests for API requests in the api module, including success and error handling.
- Add utility function tests for formatting and validating Iranian mobile numbers.
- Implement tests for BlogFormPage and BlogsPage to validate form submissions and data fetching.
- Add tests for LoginPage to ensure proper validation and state management.
- Create tests for authStore and uiStore to validate state management and functionality.
- Set up Vitest configuration and testing utilities for consistent testing environment.
This commit is contained in:
hamed
2026-06-28 22:58:15 +03:30
parent 439bb0868e
commit 6084dc5d6b
15 changed files with 2083 additions and 2146 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Pagination from '@/components/ui/Pagination';
import StatusBadge, { ActiveBadge } from '@/components/ui/StatusBadge';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import MobileInput from '@/components/ui/MobileInput';
describe('Pagination', () => {
it('وقتی یک صفحه یا کمتر است چیزی رندر نمی‌کند', () => {
const { container } = render(
<Pagination page={1} total={5} limit={10} onPageChange={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
it('دکمه «قبلی» در صفحه اول غیرفعال است', () => {
render(<Pagination page={1} total={50} limit={10} onPageChange={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons[0]).toBeDisabled(); // prev
expect(buttons[buttons.length - 1]).not.toBeDisabled(); // next
});
it('کلیک «بعدی» صفحه page+1 را می‌فرستد', async () => {
const onPage = vi.fn();
render(<Pagination page={1} total={50} limit={10} onPageChange={onPage} />);
const buttons = screen.getAllByRole('button');
await userEvent.click(buttons[buttons.length - 1]);
expect(onPage).toHaveBeenCalledWith(2);
});
it('کلیک روی شماره صفحه همان شماره را می‌فرستد', async () => {
const onPage = vi.fn();
render(<Pagination page={1} total={50} limit={10} onPageChange={onPage} />);
await userEvent.click(screen.getByText('۳'));
expect(onPage).toHaveBeenCalledWith(3);
});
});
describe('StatusBadge', () => {
it.each([
['appointment', 'confirmed', 'تأیید شده'],
['appointment', 'no_show', 'غیبت'],
['payment', 'success', 'موفق'],
['sms', 'approved', 'تأیید شده'],
['settlement', 'rejected', 'رد شده'],
])('%s/%s → %s', (type, value, label) => {
render(<StatusBadge type={type as never} value={value} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('مقدار ناشناخته → خود مقدار نمایش داده می‌شود', () => {
render(<StatusBadge type="appointment" value="zzz" />);
expect(screen.getByText('zzz')).toBeInTheDocument();
});
it('ActiveBadge فعال/غیرفعال', () => {
const { rerender } = render(<ActiveBadge active={true} />);
expect(screen.getByText('فعال')).toBeInTheDocument();
rerender(<ActiveBadge active={false} />);
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
});
});
describe('ConfirmDialog', () => {
it('وقتی open=false چیزی رندر نمی‌شود', () => {
const { container } = render(
<ConfirmDialog open={false} title="t" message="m" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
it('تأیید و لغو callbackهای درست را صدا می‌زنند', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog open title="حذف" message="مطمئنید؟" onConfirm={onConfirm} onCancel={onCancel} />,
);
await userEvent.click(screen.getByRole('button', { name: 'تأیید' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('button', { name: 'لغو' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('در حالت loading دکمه‌ها غیرفعال و متن «در حال انجام...» است', () => {
render(
<ConfirmDialog open loading title="t" message="m" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(screen.getByText('در حال انجام...')).toBeInTheDocument();
screen.getAllByRole('button').forEach((b) => {
if (b.textContent) expect(b).toBeDisabled();
});
});
});
describe('MobileInput', () => {
it('ارقام فارسی و جداکننده را قبل از onChange نرمال می‌کند', () => {
const onChange = vi.fn();
render(<MobileInput onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '۰۹۱۲-۳۴۵ ۶۷۸۹' } });
expect(onChange).toHaveBeenCalled();
const evt = onChange.mock.calls[0][0];
expect(evt.target.value).toBe('09123456789');
});
it('placeholder پیش‌فرض و maxLength=11 دارد', () => {
render(<MobileInput />);
const input = screen.getByPlaceholderText('09xxxxxxxxx') as HTMLInputElement;
expect(input.maxLength).toBe(11);
expect(input.getAttribute('dir')).toBe('ltr');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { renderHookWithClient } 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 {},
}));
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import { useSubscription } from '@/hooks/useSubscription';
import { usePaymentConfig } from '@/hooks/usePaymentConfig';
import { usePwaInstall } from '@/hooks/usePwaInstall';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
localStorage.clear();
get.mockReset();
useAuthStore.setState({ primaryRole: null });
});
describe('useSubscription', () => {
it('برای نقش doctor فعال است و hasFeature/maxSecretaries را برمی‌گرداند', async () => {
useAuthStore.setState({ primaryRole: 'doctor' });
get.mockResolvedValue({
data: {
subscription: {
plan: { features: { sms: true, claims: false }, max_secretaries: 3 },
days_remaining: 5,
},
},
});
const { result } = renderHookWithClient(() => useSubscription());
await waitFor(() => expect(result.current.hasPlan).toBe(true));
expect(result.current.hasFeature('sms')).toBe(true);
expect(result.current.hasFeature('claims')).toBe(false);
expect(result.current.hasFeature('unknown')).toBe(false);
expect(result.current.maxSecretaries).toBe(3);
expect(result.current.isExpiringSoon).toBe(true);
});
it('برای نقش admin غیرفعال است (query اجرا نمی‌شود)', async () => {
useAuthStore.setState({ primaryRole: 'admin' });
const { result } = renderHookWithClient(() => useSubscription());
expect(result.current.hasPlan).toBe(false);
expect(get).not.toHaveBeenCalled();
});
});
describe('usePaymentConfig', () => {
it('test_mode را به isTestMode مپ می‌کند', async () => {
get.mockResolvedValue({ data: { test_mode: true } });
const { result } = renderHookWithClient(() => usePaymentConfig());
await waitFor(() => expect(result.current.isTestMode).toBe(true));
});
it('پیش‌فرض isTestMode=false', () => {
get.mockResolvedValue({ data: { test_mode: false } });
const { result } = renderHookWithClient(() => usePaymentConfig());
expect(result.current.isTestMode).toBe(false);
});
});
describe('usePwaInstall', () => {
it('رویداد beforeinstallprompt را می‌گیرد', () => {
const { result } = renderHook(() => usePwaInstall());
expect(result.current.promptEvent).toBeNull();
act(() => {
window.dispatchEvent(new Event('beforeinstallprompt'));
});
expect(result.current.promptEvent).not.toBeNull();
});
it('dismiss در localStorage ذخیره و isDismissed را true می‌کند', () => {
const { result } = renderHook(() => usePwaInstall());
expect(result.current.isDismissed).toBe(false);
act(() => result.current.dismiss());
expect(localStorage.getItem('pwa-dismissed')).toBe('1');
expect(result.current.isDismissed).toBe(true);
});
it('اگر قبلاً dismiss شده باشد، اولیه isDismissed=true', () => {
localStorage.setItem('pwa-dismissed', '1');
const { result } = renderHook(() => usePwaInstall());
expect(result.current.isDismissed).toBe(true);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { refreshMock, logoutMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
logoutMock: vi.fn(),
}));
vi.mock('@/stores/authStore', () => ({
useAuthStore: { getState: () => ({ refresh: refreshMock, logout: logoutMock }) },
}));
import { api, ApiError } from '@/lib/api';
const replaceMock = vi.fn();
function jsonRes(body: unknown, ok = true, status = 200): Response {
return { ok, status, json: () => Promise.resolve(body) } as unknown as Response;
}
function setToken(token: string) {
localStorage.setItem('clinicpro-auth', JSON.stringify({ state: { token } }));
}
beforeEach(() => {
localStorage.clear();
refreshMock.mockReset();
logoutMock.mockReset();
replaceMock.mockReset();
vi.stubGlobal('fetch', vi.fn());
Object.defineProperty(window, 'location', {
value: { replace: replaceMock },
writable: true,
configurable: true,
});
});
describe('request — موفق', () => {
it('بدنه JSON را برمی‌گرداند', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonRes({ success: true, data: { id: 1 } }),
);
const out = await api.get<{ data: { id: number } }>('/api/v1/x');
expect(out).toEqual({ success: true, data: { id: 1 } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('وقتی توکن هست هدر Authorization می‌فرستد', async () => {
setToken('tok-123');
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.get('/api/v1/x');
const headers = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBe('Bearer tok-123');
});
it('بدون توکن، هدر Authorization نمی‌فرستد', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.get('/api/v1/x');
const headers = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBeUndefined();
expect(headers['Content-Type']).toBe('application/json');
});
it('post بدنه را JSON.stringify و method=POST می‌فرستد', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({ ok: 1 }));
await api.post('/api/v1/x', { a: 1 });
const opts = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(opts.method).toBe('POST');
expect(opts.body).toBe(JSON.stringify({ a: 1 }));
});
});
describe('request — خطاهای غیر۴۰۱', () => {
it('۴۲۲ → ApiError با code/message از errors[0]', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
jsonRes({ errors: [{ code: 'ERR_VALIDATION', message: 'نامعتبر' }] }, false, 422),
);
await expect(api.get('/api/v1/x')).rejects.toMatchObject({
status: 422,
code: 'ERR_VALIDATION',
message: 'نامعتبر',
});
});
it('بدنه بدون errors → ERR_UNKNOWN', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({}, false, 500));
const err = (await api.get('/api/v1/x').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe('ERR_UNKNOWN');
expect(err.status).toBe(500);
});
});
describe('request — جریان ۴۰۱', () => {
it('refresh موفق → یک‌بار retry و موفقیت', async () => {
refreshMock.mockResolvedValue('new-token');
(fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(jsonRes({}, false, 401))
.mockResolvedValueOnce(jsonRes({ success: true, data: 'ok' }));
const out = await api.get('/api/v1/x');
expect(out).toEqual({ success: true, data: 'ok' });
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
expect(logoutMock).not.toHaveBeenCalled();
});
it('refresh ناموفق → logout + redirect + ApiError(401)', async () => {
refreshMock.mockResolvedValue(null);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(jsonRes({}, false, 401));
const err = (await api.get('/api/v1/x').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(401);
expect(err.code).toBe('ERR_UNAUTHORIZED');
expect(logoutMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledWith('/admin/login');
});
it('de-dup: دو درخواست همزمان ۴۰۱ فقط یک‌بار refresh می‌زنند', async () => {
refreshMock.mockImplementation(
() => new Promise((r) => setTimeout(() => r('new-token'), 10)),
);
const f = fetch as ReturnType<typeof vi.fn>;
f.mockImplementation(() => {
const n = f.mock.calls.length;
return Promise.resolve(n <= 2 ? jsonRes({}, false, 401) : jsonRes({ ok: 1 }));
});
await Promise.all([api.get('/api/v1/a'), api.get('/api/v1/b')]);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(4);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import {
formatRial,
formatNumber,
toDate,
formatDate,
formatDateTime,
toGregorianDate,
maskMobile,
cn,
toEnglishDigits,
sanitizeMobileInput,
isValidIranMobile,
IRAN_MOBILE_RE,
iranMobileSchema,
iranMobileOptionalSchema,
} from '@/lib/utils';
const PERSIAN_DIGITS = /[۰-۹]/;
describe('formatRial', () => {
it('عدد را با جداکننده فارسی و پسوند تومان برمی‌گرداند', () => {
const out = formatRial(1000);
expect(out).toContain('تومان');
expect(out).toMatch(PERSIAN_DIGITS);
});
it('صفر را هم فرمت می‌کند', () => {
expect(formatRial(0)).toContain('تومان');
});
});
describe('formatNumber', () => {
it('رقم فارسی برمی‌گرداند', () => {
expect(formatNumber(1234)).toMatch(PERSIAN_DIGITS);
});
});
describe('toDate', () => {
it('null و رشته خالی → null', () => {
expect(toDate(null)).toBeNull();
expect(toDate(undefined)).toBeNull();
expect(toDate('')).toBeNull();
});
it('عدد را به‌عنوان ثانیه (×۱۰۰۰) تفسیر می‌کند', () => {
const d = toDate(1_700_000_000);
expect(d).toBeInstanceOf(Date);
expect(d!.getTime()).toBe(1_700_000_000 * 1000);
});
it('Y-m-d را ظهر محلی می‌گیرد (نه نیمه‌شب UTC)', () => {
const d = toDate('2024-03-21');
expect(d!.getHours()).toBe(12);
expect(d!.getFullYear()).toBe(2024);
});
it('رشته ISO کامل را پارس می‌کند', () => {
const d = toDate('2024-03-21T08:30:00Z');
expect(d).toBeInstanceOf(Date);
expect(isNaN(d!.getTime())).toBe(false);
});
});
describe('formatDate', () => {
it('ورودی نامعتبر → "—"', () => {
expect(formatDate(null)).toBe('—');
expect(formatDate('not-a-date')).toBe('—');
});
it('timestamp ثانیه‌ای معتبر → رشته شمسی با رقم فارسی', () => {
const out = formatDate(1_700_000_000);
expect(out).not.toBe('—');
expect(out).toMatch(PERSIAN_DIGITS);
});
});
describe('formatDateTime', () => {
it('ورودی نامعتبر → "—"', () => {
expect(formatDateTime(undefined)).toBe('—');
});
it('معتبر → شامل ساعت و رقم فارسی', () => {
const out = formatDateTime(1_700_000_000);
expect(out).not.toBe('—');
expect(out).toMatch(PERSIAN_DIGITS);
});
});
describe('toGregorianDate', () => {
it('YYYY-MM-DD با صفر پیش‌رو', () => {
expect(toGregorianDate(new Date(2024, 0, 5))).toBe('2024-01-05');
expect(toGregorianDate(new Date(2024, 11, 31))).toBe('2024-12-31');
});
});
describe('maskMobile', () => {
it('شماره ۱۱ رقمی را ماسک می‌کند', () => {
expect(maskMobile('09123456789')).toBe('0912***789');
});
it('شماره کوتاه‌تر از ۷ رقم بدون تغییر', () => {
expect(maskMobile('123')).toBe('123');
});
});
describe('cn', () => {
it('مقادیر falsy را حذف و بقیه را با فاصله می‌چسباند', () => {
expect(cn('a', false, null, undefined, 'b')).toBe('a b');
expect(cn()).toBe('');
});
});
describe('toEnglishDigits', () => {
it('رقم فارسی به انگلیسی', () => {
expect(toEnglishDigits('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('رقم عربی به انگلیسی', () => {
expect(toEnglishDigits('٠٩١٢')).toBe('0912');
});
it('ورودی خالی → رشته خالی', () => {
expect(toEnglishDigits('')).toBe('');
});
it('کاراکترهای غیررقمی را دست‌نخورده نگه می‌دارد', () => {
expect(toEnglishDigits('شماره ۰۹')).toBe('شماره 09');
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم حذف، رقم فارسی نرمال، حداکثر ۱۱ رقم', () => {
expect(sanitizeMobileInput('۰۹۱۲-۳۴۵ ۶۷۸۹۰۱۲')).toBe('09123456789');
});
it('فقط حروف → خالی', () => {
expect(sanitizeMobileInput('abc')).toBe('');
});
});
describe('isValidIranMobile / IRAN_MOBILE_RE', () => {
it.each([
['09123456789', true],
['۰۹۱۲۳۴۵۶۷۸۹', true],
['9123456789', false],
['08123456789', false],
['0912345678', false],
['091234567890', false],
])('%s → %s', (input, expected) => {
expect(isValidIranMobile(input as string)).toBe(expected);
});
it('regex مستقیم روی ارقام انگلیسی', () => {
expect(IRAN_MOBILE_RE.test('09120000000')).toBe(true);
});
});
describe('iranMobileSchema', () => {
it('رقم فارسی را پذیرفته و نرمال می‌کند', () => {
expect(iranMobileSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('جداکننده‌ها را حذف می‌کند', () => {
expect(iranMobileSchema.parse('0912-345-6789')).toBe('09123456789');
});
it('شماره نامعتبر → ناموفق', () => {
const r = iranMobileSchema.safeParse('12345');
expect(r.success).toBe(false);
});
});
describe('iranMobileOptionalSchema', () => {
it('رشته خالی مجاز است', () => {
expect(iranMobileOptionalSchema.parse('')).toBe('');
});
it('شماره معتبر نرمال می‌شود', () => {
expect(iranMobileOptionalSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('شماره نیمه‌کاره → ناموفق', () => {
expect(iranMobileOptionalSchema.safeParse('0912').success).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/utils';
vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
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 BlogFormPage from '@/pages/BlogFormPage';
const post = api.post as ReturnType<typeof vi.fn>;
beforeEach(() => {
post.mockReset();
});
describe('BlogFormPage — اعتبارسنجی zod (حالت ساخت)', () => {
it('submit با فیلدهای خالی → خطای عنوان و جلوگیری از فراخوانی API', async () => {
renderWithProviders(<BlogFormPage />, { route: '/admin/blogs/new' });
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(await screen.findByText('عنوان الزامی است')).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
});
it('عنوان کوتاه‌تر از ۳ کاراکتر هم خطا می‌دهد', async () => {
renderWithProviders(<BlogFormPage />, { route: '/admin/blogs/new' });
await userEvent.type(screen.getByPlaceholderText('عنوان جذاب بنویسید...'), 'اب');
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(await screen.findByText('عنوان الزامی است')).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
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 BlogsPage from '@/pages/BlogsPage';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
});
describe('BlogsPage — قرارداد PaginatedResponse', () => {
it('ردیف‌ها از data.data و تعداد از meta.totalRecords می‌آیند', async () => {
get.mockResolvedValue({
success: true,
data: [
{ uuid: 'b1', title: 'مقاله اول', status: 'published', tags: [], created_at: 1700000000 },
{ uuid: 'b2', title: 'مقاله دوم', status: 'draft', tags: [], created_at: 1700000000 },
],
meta: { totalRecords: 2, totalPages: 1, currentPage: 1 },
errors: [],
});
renderWithProviders(<BlogsPage />, { route: '/admin/blogs' });
expect(await screen.findByText('مقاله اول')).toBeInTheDocument();
expect(screen.getByText('مقاله دوم')).toBeInTheDocument();
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/blogs'));
});
it('پاسخ خالی → پیام «هیچ مقاله‌ای یافت نشد»', async () => {
get.mockResolvedValue({
success: true,
data: [],
meta: { totalRecords: 0, totalPages: 0, currentPage: 1 },
errors: [],
});
renderWithProviders(<BlogsPage />, { route: '/admin/blogs' });
expect(await screen.findByText('هیچ مقاله‌ای یافت نشد')).toBeInTheDocument();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } 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('@/components/ui/PwaLoginCard', () => ({ default: () => null }));
import { toast } from 'sonner';
import LoginPage from '@/pages/LoginPage';
const errorToast = toast.error as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
describe('LoginPage — حالت رمز عبور', () => {
it('submit با فیلد خالی → خطای اعتبارسنجی و بدون fetch', async () => {
renderWithProviders(<LoginPage />, { route: '/admin/login' });
await userEvent.click(screen.getByRole('button', { name: 'ورود به سیستم' }));
expect(errorToast).toHaveBeenCalledWith('شماره موبایل و رمز عبور الزامی است');
expect(fetch).not.toHaveBeenCalled();
});
});
describe('LoginPage — تعویض حالت', () => {
it('کلیک «ورود با پیامک» فرم پیامک را نشان می‌دهد', async () => {
renderWithProviders(<LoginPage />, { route: '/admin/login' });
await userEvent.click(screen.getByRole('button', { name: 'ورود با پیامک' }));
expect(screen.getByRole('button', { name: 'ارسال کد تأیید' })).toBeInTheDocument();
});
it('شماره موبایل نامعتبر در حالت پیامک → خطا', async () => {
renderWithProviders(<LoginPage />, { route: '/admin/login' });
await userEvent.click(screen.getByRole('button', { name: 'ورود با پیامک' }));
await userEvent.type(screen.getByPlaceholderText('09xxxxxxxxx'), '12345');
await userEvent.click(screen.getByRole('button', { name: 'ارسال کد تأیید' }));
expect(errorToast).toHaveBeenCalledWith('شماره موبایل معتبر نیست');
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from '@/stores/authStore';
const initial = useAuthStore.getInitialState();
function jsonRes(body: unknown, ok = true): Response {
return { ok, json: () => Promise.resolve(body) } as unknown as Response;
}
beforeEach(() => {
localStorage.clear();
useAuthStore.setState(initial, true);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonRes({ success: false })));
});
describe('login', () => {
it('توکن را ست و isAuthenticated را true می‌کند', () => {
useAuthStore.getState().login('tok', 'refresh');
const s = useAuthStore.getState();
expect(s.token).toBe('tok');
expect(s.refreshToken).toBe('refresh');
expect(s.isAuthenticated).toBe(true);
});
it('fetchMe (userinfo) را صدا می‌زند', () => {
useAuthStore.getState().login('tok');
expect(fetch).toHaveBeenCalledWith('/oauth/userinfo', expect.any(Object));
});
});
describe('logout', () => {
it('state را پاک می‌کند و وقتی refreshToken هست به /oauth/logout POST می‌زند', () => {
useAuthStore.setState({ token: 'tok', refreshToken: 'rt', isAuthenticated: true });
useAuthStore.getState().logout();
const s = useAuthStore.getState();
expect(s.token).toBeNull();
expect(s.isAuthenticated).toBe(false);
expect(fetch).toHaveBeenCalledWith('/oauth/logout', expect.objectContaining({ method: 'POST' }));
});
});
describe('refresh', () => {
it('بدون refreshToken → null و بدون fetch', async () => {
const out = await useAuthStore.getState().refresh();
expect(out).toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
it('پاسخ موفق → توکن جدید برمی‌گرداند و state را به‌روز می‌کند', async () => {
useAuthStore.setState({ refreshToken: 'rt' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
jsonRes({ access_token: 'new-tok', refresh_token: 'new-rt' }),
);
const out = await useAuthStore.getState().refresh();
expect(out).toBe('new-tok');
expect(useAuthStore.getState().token).toBe('new-tok');
expect(useAuthStore.getState().refreshToken).toBe('new-rt');
});
it('پاسخ بدون access_token → null', async () => {
useAuthStore.setState({ refreshToken: 'rt' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(jsonRes({}, false));
expect(await useAuthStore.getState().refresh()).toBeNull();
});
});
describe('fetchMe', () => {
it('userinfo موفق → فیلدهای کاربر را ست می‌کند', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
jsonRes({
success: true,
data: {
uuid: 'u1',
realName: 'دکتر تست',
primary_role: 'doctor',
available_contexts: [{ type: 'doctor', db_uuid: 'd1', name: 'مطب', role: 'doctor' }],
},
}),
);
await useAuthStore.getState().fetchMe();
const s = useAuthStore.getState();
expect(s.userUuid).toBe('u1');
expect(s.userName).toBe('دکتر تست');
expect(s.primaryRole).toBe('doctor');
expect(s.availableContexts).toHaveLength(1);
});
});
describe('switchContext', () => {
it('پاسخ موفق → db و context و primaryRole را ست می‌کند', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
jsonRes({
success: true,
data: {
db_uuid: 'db2',
db_key: 'key2',
context: { type: 'clinic', db_uuid: 'db2', name: 'کلینیک', role: 'clinic', doctor_uuid: 'doc9' },
},
}),
);
await useAuthStore.getState().switchContext('db2');
const s = useAuthStore.getState();
expect(s.dbUuid).toBe('db2');
expect(s.dbKey).toBe('key2');
expect(s.primaryRole).toBe('clinic');
expect(s.doctorUuid).toBe('doc9');
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useUiStore, HUES } from '@/stores/uiStore';
const initial = useUiStore.getInitialState();
beforeEach(() => {
localStorage.clear();
useUiStore.setState(initial, true);
document.documentElement.className = '';
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-density');
});
describe('sidebar', () => {
it('toggleSidebar وضعیت را برعکس می‌کند', () => {
expect(useUiStore.getState().sidebarOpen).toBe(true);
useUiStore.getState().toggleSidebar();
expect(useUiStore.getState().sidebarOpen).toBe(false);
});
it('setSidebarOpen مقدار صریح می‌گذارد', () => {
useUiStore.getState().setSidebarOpen(false);
expect(useUiStore.getState().sidebarOpen).toBe(false);
});
});
describe('darkMode + applyTheme', () => {
it('toggleDarkMode کلاس dark و data-theme را روی root می‌گذارد', () => {
useUiStore.getState().toggleDarkMode();
expect(useUiStore.getState().darkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('toggle دوباره به light برمی‌گرداند', () => {
useUiStore.getState().toggleDarkMode();
useUiStore.getState().toggleDarkMode();
expect(useUiStore.getState().darkMode).toBe(false);
expect(document.documentElement.classList.contains('dark')).toBe(false);
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
});
describe('density', () => {
it('setDensity مقدار و data-density را تنظیم می‌کند', () => {
useUiStore.getState().setDensity('compact');
expect(useUiStore.getState().density).toBe('compact');
expect(document.documentElement.getAttribute('data-density')).toBe('compact');
});
});
describe('brandHue', () => {
it('setBrandHue متغیرهای CSS را روی root می‌نویسد', () => {
const hue = HUES[2];
useUiStore.getState().setBrandHue(hue);
expect(useUiStore.getState().brandHue).toEqual(hue);
expect(document.documentElement.style.getPropertyValue('--brand-h')).toBe(String(hue.h));
expect(document.documentElement.style.getPropertyValue('--brand-c')).toBe(String(hue.c));
});
});
describe('settingsPanel', () => {
it('toggleSettingsPanel وضعیت را برعکس می‌کند', () => {
expect(useUiStore.getState().settingsPanelOpen).toBe(false);
useUiStore.getState().toggleSettingsPanel();
expect(useUiStore.getState().settingsPanelOpen).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
+52
View File
@@ -0,0 +1,52 @@
import { ReactElement, ReactNode } from 'react';
import { render, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
export function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
export function Providers({
children,
client = makeClient(),
route = '/admin/dashboard',
}: {
children: ReactNode;
client?: QueryClient;
route?: string;
}) {
return (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</QueryClientProvider>
);
}
export function renderWithProviders(ui: ReactElement, opts?: { route?: string }) {
const client = makeClient();
return {
client,
...render(ui, {
wrapper: ({ children }) => (
<Providers client={client} route={opts?.route}>
{children}
</Providers>
),
}),
};
}
export function renderHookWithClient<T>(cb: () => T) {
const client = makeClient();
return renderHook(cb, {
wrapper: ({ children }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}