Files
clinicpro/.claude/prompt/admin-spa-test-suite.md
T
hamed 6084dc5d6b 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.
2026-06-28 22:58:15 +03:30

317 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# راه‌اندازی تست و نوشتن تست‌سوئیت کامل برای 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 هستند).