# راه‌اندازی تست و نوشتن تست‌سوئیت کامل برای 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 هستند).
