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 هستند).