Files
clinicpro/.claude/prompt/admin-spa-test-suite.md
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

19 KiB
Raw Permalink Blame History

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

"scripts": {
  "dev-server": "encore dev-server",
  "dev": "encore dev",
  "watch": "encore dev --watch",
  "build": "encore production --progress"
}

tsconfig.json (نکات کلیدی):

"compilerOptions": { "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["assets/admin/*"] }, "strict": true },
"include": ["assets/admin/**/*"]

lib/api.ts — هسته‌ی fetch wrapper (وضعیت واقعی):

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 — توابع خالص قابل‌تست (امضاها):

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):

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 بساز:

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:

"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"

clinicpro/assets/admin/test/setup.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:

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:

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 مشترک:

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.

نمونه:

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 صفحه:

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); } },
}));

۸. اجرا و سبزکردن

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