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
+316
View File
@@ -0,0 +1,316 @@
# راه‌اندازی تست و نوشتن تست‌سوئیت کامل برای Admin SPA
## پروژه
`clinicpro` — پنل ادمین React 19 + TypeScript زیر `assets/admin/` (build با Webpack Encore داخل ddev). فقط frontend ادمین؛ backend دست نمی‌خورد.
## زمینه
پنل ادمین **هیچ زیرساخت تستی ندارد** — نه test runner، نه تست. `package.json` هیچ `vitest`/`jest`/`@testing-library` و هیچ script `test` ندارد. کد شامل ۴۶ صفحه، ۱۹ کامپوننت UI، ۲ store (zustand)، ۳ hook، یک fetch wrapper و یک ماژول utility پر از منطق خالص (فرمت تاریخ شمسی، نرمال‌سازی رقم فارسی، اعتبارسنجی موبایل ایرانی) است که همگی قابل‌تست و در حال حاضر بدون پوشش‌اند.
## مشکل / هدف
۱. یک test runner مدرن و سازگار با استک (**Vitest + Testing Library + jsdom**) از صفر راه‌اندازی کن — مستقل از Webpack (Vitest با esbuild اجرا می‌شود و به pipeline Encore کاری ندارد).
۲. تست‌ها را **لایه‌به‌لایه و به‌ترتیب اولویت** بنویس: utils خالص → api wrapper → stores → hooks → کامپوننت‌های UI → چند صفحه نماینده. برای هر لایه یک الگوی تکرارپذیر مستقر کن تا بقیه‌ی صفحات بعداً با همان الگو پوشش داده شوند.
۳. هدف پوشش معنادار منطق و رفتار است، نه عدد پوشش صوری. هر تست باید رفتار واقعی را تأیید کند (نه snapshot بی‌معنا).
## فایل‌های مرتبط
| فایل | نقش | تست هدف |
|------|-----|---------|
| `clinicpro/package.json` | scripts + devDeps | افزودن deps تست + script `test` |
| `clinicpro/tsconfig.json` | alias `@/* → assets/admin/*`، `jsx: react-jsx` | باید در vitest منعکس شود |
| `clinicpro/vitest.config.ts` | **ساخته شود** | کانفیگ runner |
| `clinicpro/assets/admin/test/setup.ts` | **ساخته شود** | setup سراسری (jest-dom، mockها) |
| `clinicpro/assets/admin/test/utils.tsx` | **ساخته شود** | `renderWithProviders` (QueryClient + Router) |
| `assets/admin/lib/utils.ts` | فرمترها، رقم فارسی، موبایل، zod | تست واحد کامل |
| `assets/admin/lib/api.ts` | fetch wrapper، `ApiError`، ۴۰۱ refresh+retry | تست با mock fetch/localStorage/store/location |
| `assets/admin/stores/authStore.ts` | login/logout/refresh/fetchMe/switchContext | تست action |
| `assets/admin/stores/uiStore.ts` | toggleها + `applyTheme` (DOM) | تست action + DOM |
| `assets/admin/hooks/*.ts` | useSubscription, usePaymentConfig, usePwaInstall | تست با QueryClient + mock |
| `assets/admin/components/ui/*` | Pagination, StatusBadge, ConfirmDialog, Modal, MobileInput, … | render + رفتار |
| `assets/admin/pages/LoginPage.tsx`, `BlogFormPage.tsx`, یک list page | جریان واقعی | تست صفحه با providers |
## وضعیت فعلی
`package.json` scripts (بدون test):
```json
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
}
```
`tsconfig.json` (نکات کلیدی):
```json
"compilerOptions": { "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["assets/admin/*"] }, "strict": true },
"include": ["assets/admin/**/*"]
```
`lib/api.ts` — هسته‌ی fetch wrapper (وضعیت واقعی):
```ts
function getToken(): string | null {
const raw = localStorage.getItem('clinicpro-auth');
if (!raw) return null;
try { return JSON.parse(raw)?.state?.token ?? null; } catch { return null; }
}
// request<T>(path, options, retry=true):
// - ست‌کردن Content-Type: application/json + Authorization: Bearer <token> در صورت وجود
// - 401 + retry → refreshOnce() (de-dup با refreshPromise ماژولی) → اگر توکن جدید: retry با retry=false
// وگرنه logout() + window.location.replace('/admin/login') + throw ApiError(401,'ERR_UNAUTHORIZED')
// - سایر خطاها → throw ApiError(status, errors[0]?.code ?? 'ERR_UNKNOWN', errors[0]?.message ?? 'خطای ناشناخته')
// - موفق → res.json() as T
// api = { get, post, patch, put, delete } // writeها body را JSON.stringify می‌کنند
// ApiError { status, code, message }
// ApiResponse<T> = { success, data: T, errors[] } // single: data?.data
// PaginatedResponse<T> = { success, data: T[], meta: { totalRecords, totalPages, currentPage } } // list: items=data?.data ، total=data?.meta?.totalRecords
```
`lib/utils.ts` — توابع خالص قابل‌تست (امضاها):
```ts
formatRial(amount: number): string // Intl fa-IR + ' تومان'
formatNumber(n: number): string // Intl fa-IR
toDate(val: string|number|null|undefined): Date|null // number → ms*1000 ؛ 'Y-m-d' → ظهر محلی
formatDate(val): string // تقویم fa-IR-u-ca-persian ؛ نامعتبر → '—'
formatDateTime(val): string
toGregorianDate(d: Date): string // YYYY-MM-DD
maskMobile(mobile: string): string // 0912***678 اگر len>=7
cn(...classes): string
toEnglishDigits(input: string): string // نرمال‌سازی رقم فارسی/عربی
sanitizeMobileInput(input: string): string // فقط رقم، max 11
IRAN_MOBILE_RE = /^09\d{9}$/ ؛ isValidIranMobile(input): boolean
iranMobileSchema / iranMobileOptionalSchema // zod transform+refine
```
`index.tsx` — QueryClient سراسری: `{ queries: { retry: 1, staleTime: 30_000 } }`، mount در `#admin-root` با StrictMode → QueryClientProvider → BrowserRouter → App. **در تست `index.tsx` را import نکن** (service worker register دارد).
## وظایف
### ۱. نصب ابزار و کانفیگ runner
deps را اضافه کن (داخل ddev):
```bash
ddev exec yarn add -D vitest@^2 jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react vite-tsconfig-paths
```
> `@vitejs/plugin-react` برای transform JSX، `vite-tsconfig-paths` تا alias `@/*` از همان `tsconfig.json` خوانده شود (بدون تکرار دستی).
`clinicpro/vitest.config.ts` بساز:
```ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./assets/admin/test/setup.ts'],
include: ['assets/admin/**/*.{test,spec}.{ts,tsx}'],
css: false, // import .css در کامپوننت‌ها را no-op کن
clearMocks: true,
restoreMocks: true,
coverage: { provider: 'v8', include: ['assets/admin/**/*.{ts,tsx}'], exclude: ['assets/admin/**/*.{test,spec}.*', 'assets/admin/test/**', 'assets/admin/index.tsx'] },
},
});
```
script در `package.json`:
```json
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
```
`clinicpro/assets/admin/test/setup.ts` بساز:
```ts
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
// matchMedia (uiStore.applyTheme و کامپوننت‌ها ممکن است صدا بزنند)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (q: string) => ({ matches: false, media: q, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() }),
});
```
> نکته: jsdom به `window.location.replace` اجازه navigation نمی‌دهد؛ در تست api.ts آن را با `vi.stubGlobal`/`Object.defineProperty` mock کن (پایین).
### ۲. تست واحد `lib/utils.ts` (اول و مهم‌ترین — منطق خالص، بدون mock)
`assets/admin/lib/utils.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { toEnglishDigits, sanitizeMobileInput, isValidIranMobile, maskMobile, formatDate, toDate, iranMobileSchema, cn } from '@/lib/utils';
describe('toEnglishDigits', () => {
it('رقم فارسی و عربی را به انگلیسی تبدیل می‌کند', () => {
expect(toEnglishDigits('۰۹۱۲')).toBe('0912');
expect(toEnglishDigits('٠٩١٢')).toBe('0912');
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم را حذف و به ۱۱ رقم محدود می‌کند', () => {
expect(sanitizeMobileInput('0912-345 6789012')).toBe('09123456789');
});
});
describe('isValidIranMobile', () => {
it.each([['09123456789', true], ['9123456789', false], ['0812345678', false]])('%s → %s', (input, ok) => {
expect(isValidIranMobile(input as string)).toBe(ok);
});
});
describe('maskMobile', () => {
it('وسط شماره را ماسک می‌کند', () => { expect(maskMobile('09123456789')).toContain('***'); });
});
describe('formatDate', () => {
it('ورودی نامعتبر → "—"', () => { expect(formatDate(null)).toBe('—'); expect(formatDate('xxx')).toBe('—'); });
it('timestamp ثانیه‌ای را شمسی برمی‌گرداند', () => { expect(formatDate(1700000000)).not.toBe('—'); });
});
describe('iranMobileSchema (zod)', () => {
it('رقم فارسی را پذیرفته و نرمال می‌کند', () => {
expect(iranMobileSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('شماره نامعتبر را رد می‌کند', () => {
expect(iranMobileSchema.safeParse('123').success).toBe(false);
});
});
```
همه‌ی توابع export شده‌ی utils را پوشش بده (formatRial, formatNumber, formatDateTime, toGregorianDate, toDate حالت‌های مختلف، cn، iranMobileOptionalSchema).
### ۳. تست `lib/api.ts` (mock fetch/localStorage/store/location)
`assets/admin/lib/api.test.ts`. الگوی mock:
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// store را mock کن تا refresh/logout قابل‌کنترل شود
vi.mock('@/stores/authStore', () => ({
useAuthStore: { getState: () => ({ refresh: vi.fn().mockResolvedValue('new-token'), logout: vi.fn() }) },
}));
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('fetch', vi.fn());
Object.defineProperty(window, 'location', { value: { replace: vi.fn() }, writable: true });
});
function jsonRes(body: unknown, ok = true, status = 200) {
return { ok, status, json: () => Promise.resolve(body) } as Response;
}
```
سناریوهای لازم:
- موفق: `fetch` یک‌بار صدا، `Authorization: Bearer <token>` فقط وقتی توکن در localStorage هست (envelope `{state:{token}}`).
- خطای ۴۲۲/۴۰۰: `ApiError` با `status`/`code`/`message` درست از `errors[0]` پرتاب شود.
- ۴۰۱ + توکن جدید از refresh: درخواست **یک‌بار retry** شود و در نهایت موفق.
- ۴۰۱ + refresh ناموفق (`null`): `logout` صدا زده شود، `window.location.replace('/admin/login')`، و `ApiError(401,'ERR_UNAUTHORIZED')`.
- de-dup: دو درخواست همزمان که ۴۰۱ می‌گیرند فقط **یک‌بار** refresh را صدا بزنند (تست `refreshPromise`).
- `api.post` بدنه را `JSON.stringify` کند و method درست بفرستد.
### ۴. تست stores
`assets/admin/stores/authStore.test.ts` — با mock کردن `fetch` (store مستقیم fetch می‌زند):
- `login(token)` → state ست شود (`isAuthenticated=true`, `token`) و `fetchMe` فراخوانی شود.
- `logout()` → POST به `/oauth/logout` و پاک‌شدن state.
- `refresh()` → POST `/oauth/token/refresh`؛ موفق → توکن جدید برگردد و در state بنشیند؛ ناموفق → `null`.
- `fetchMe()` → GET `/oauth/userinfo` و ست‌شدن `primaryRole`, `userName`, `availableContexts`.
- بین تست‌ها state را reset کن: `useAuthStore.setState(useAuthStore.getInitialState?.() ?? initial)` یا `localStorage.clear()` + ست دستی.
`assets/admin/stores/uiStore.test.ts`:
- `toggleDarkMode`/`setBrandHue`/`setDensity` → state تغییر کند و `applyTheme` کلاس/attribute روی `document.documentElement` بگذارد (با `expect(document.documentElement.classList.contains('dark'))` یا attribute مربوطه).
- `toggleSidebar`/`setSidebarOpen` رفتار درست.
### ۵. تست hooks (با QueryClient wrapper + mock api)
`assets/admin/test/utils.tsx` — helper مشترک:
```tsx
import { ReactElement, ReactNode } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
export function makeClient() {
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> });
}
```
`hooks/useSubscription.test.tsx` و `usePaymentConfig.test.tsx`: `vi.mock('@/lib/api', ...)` تا `api.get` پاسخ ثابت بدهد؛ با `waitFor` خروجی hook (`hasFeature`, `isTestMode`, …) را assert کن.
`usePwaInstall.test.tsx`: رویداد `beforeinstallprompt` را dispatch کن، `localStorage['pwa-dismissed']` را تست کن، `dismiss()`/`install()` را بررسی کن.
### ۶. تست کامپوننت‌های UI (render + رفتار)
با `renderWithProviders`/`render` ساده (آنها که provider نمی‌خواهند). حداقل این‌ها:
- `Pagination` — تعداد صفحه درست، کلیک «بعدی»/«قبلی» callback را با صفحه‌ی درست صدا بزند، دکمه‌های مرزی disable شوند.
- `StatusBadge` / `AppointmentStatusDropdown` — مپ وضعیت → برچسب/رنگ فارسی.
- `ConfirmDialog` — تأیید/لغو callbackها، بسته‌شدن.
- `Modal` — باز/بسته، بستن با Esc/کلیک backdrop، focus.
- `MobileInput` / `PriceInput` — نرمال‌سازی ورودی (رقم فارسی → انگلیسی، فقط عدد) با `userEvent.type`.
- `DataTable` — رندر ستون‌ها/ردیف‌ها، حالت خالی، حالت loading.
نمونه:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Pagination } from '@/components/ui/Pagination';
it('کلیک «بعدی» صفحه‌ی بعد را می‌فرستد', async () => {
const onPage = vi.fn();
render(<Pagination currentPage={1} totalPages={5} onPageChange={onPage} />);
await userEvent.click(screen.getByRole('button', { name: /بعدی/ }));
expect(onPage).toHaveBeenCalledWith(2);
});
```
> props واقعی هر کامپوننت را از فایل خودش بخوان (نام props ممکن است فرق کند) — حدس نزن.
### ۷. تست صفحه (page) با mock لایه‌ی api
سه صفحه‌ی نماینده که سه الگو را پوشش می‌دهند:
- **`LoginPage`** — فرم RHF+Zod: ورودی نامعتبر موبایل → پیام خطای فارسی؛ submit موفق → `authStore.login`/`api.post` صدا زده شود (mock).
- **`BlogFormPage`** — اعتبارسنجی zod (عنوان <۳ کاراکتر → «عنوان الزامی است»، body <۱۰ → «محتوا الزامی است»)، submit با مقادیر معتبر → فراخوانی mutation.
- **یک list page (مثلاً `BlogsPage` یا `RatingsPage`)** — قرارداد `PaginatedResponse`: `api.get` را mock کن که `{ data: [...], meta: { totalRecords } }` بدهد؛ تأیید کن ردیف‌ها از `data?.data` و total/تعداد صفحه از `meta?.totalRecords` رندر می‌شوند.
الگوی mock صفحه:
```tsx
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error { constructor(public status: number, public code: string, message: string){ super(message); } },
}));
```
### ۸. اجرا و سبزکردن
```bash
ddev exec yarn test # کل سوئیت
ddev exec yarn test:cov # با پوشش
```
همه‌ی تست‌ها باید سبز شوند. اگر تستی به‌خاطر رفتار واقعی کد شکست خورد، **کد را تغییر نده** مگر باگ واقعی باشد — تست را با رفتار واقعی هم‌راستا کن (و باگ واقعی را جدا گزارش بده).
## نکات مهم
- **هیچ فرضی درباره‌ی props/نام تابع نزن** — هر فایل هدف را قبل از تست بخوان و امضاها/نام exportها را از کد واقعی بردار. مثال‌های بالا الگو هستند، نه قرارداد قطعی.
- Vitest مستقل از Webpack Encore است؛ خطای شناخته‌ی `lightningcss` در build ddev ربطی به تست ندارد. CSS importها با `css: false` بی‌اثر می‌شوند.
- `index.tsx` را در تست import نکن (service worker register + mount واقعی). فقط `App`/صفحات/ماژول‌ها را import کن.
- jsdom navigation ندارد: هرجا کد `window.location.replace` می‌زند (api.ts و App guardها) آن را mock کن.
- store‌ها `persist` دارند؛ بین تست‌ها `localStorage.clear()` و reset state تا نشت حالت بین تست‌ها رخ ندهد (`clearMocks/restoreMocks` در کانفیگ ست شده).
- `formatDate`/`formatDateTime` به تقویم `fa-IR-u-ca-persian` و timezone وابسته‌اند؛ به‌جای assert رشته‌ی دقیق، روی «نامعتبر → '—'» و «معتبر → غیر '—' و شامل رقم فارسی» assert کن تا تست شکننده نشود.
- اولویت پوشش: utils (۱۰۰٪) → api.ts → stores → hooks → UI components → pages. اگر زمان محدود شد، لایه‌های پایین‌تر (page) را با همان الگوی مستقرشده برای بقیه‌ی صفحات قابل‌گسترش بگذار و در گزارش، صفحاتِ پوشش‌داده‌نشده را فهرست کن (silent truncation ممنوع).
- این تغییر فقط frontend است؛ backend، migration و `docs/api/` دست نمی‌خورد.
- بعد از پایان: `ddev exec npx tsc --noEmit --project tsconfig.json` تا تست‌ها type-error وارد نکرده باشند (فایل‌های تست هم زیر `assets/admin/**` و در دامنه‌ی tsconfig هستند).
+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>
),
});
}
+12 -1
View File
@@ -9,15 +9,23 @@
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
"@symfony/webpack-encore": "^6.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4",
"core-js": "^3.38.0",
"jsdom": "^29.1.1",
"postcss": "^8.0.0",
"postcss-loader": "^8.0.0",
"regenerator-runtime": "^0.13.9",
"tailwindcss": "^4.0.0",
"ts-loader": "^9.6.0",
"typescript": "^5.0.0",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^2",
"webpack": "^5.72",
"webpack-cli": "^6.0.0"
},
@@ -50,6 +58,9 @@
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
"build": "encore production --progress",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths({ ignoreConfigErrors: true })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./assets/admin/test/setup.ts'],
include: ['assets/admin/**/*.{test,spec}.{ts,tsx}'],
css: false,
clearMocks: true,
restoreMocks: true,
coverage: {
provider: 'v8',
include: ['assets/admin/**/*.{ts,tsx}'],
exclude: [
'assets/admin/**/*.{test,spec}.*',
'assets/admin/test/**',
'assets/admin/index.tsx',
],
},
},
});
+847 -2145
View File
File diff suppressed because it is too large Load Diff