# راه‌اندازی تست و نوشتن تست‌سوئیت کامل برای 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(path, options, retry=true): // - ست‌کردن Content-Type: application/json + Authorization: Bearer در صورت وجود // - 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 = { success, data: T, errors[] } // single: data?.data // PaginatedResponse = { 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 ` فقط وقتی توکن در 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 ( {children} ); } export function renderWithProviders(ui: ReactElement, opts?: { route?: string }) { const client = makeClient(); return { client, ...render(ui, { wrapper: ({ children }) => {children}) }; } export function renderHookWithClient(cb: () => T) { const client = makeClient(); return renderHook(cb, { wrapper: ({ children }) => {children} }); } ``` `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(); 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 هستند).