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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('شماره موبایل معتبر نیست');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user