Files
hamed c316d22160 feat: add testing setup with Vitest and Testing Library
- Updated package.json to include Vitest and Testing Library dependencies and scripts for testing.
- Created a test suite for the ProvinceProvider context to validate cityId and province detection based on subdomains.
- Implemented unit tests for utility functions in helper/index.js, including phone number formatting and validation.
- Added tests for state information retrieval in lib/getStateInfo.js, ensuring correct city and state matching based on subdomains.
- Developed tests for appointment slot adaptation and availability checks in lib/appointmentSlots.js.
- Created tests for token storage functionality in lib/tokenStore.js.
- Implemented sanitization and JSON parsing tests in lib/sanitize.js.
- Added CASL ability tests in lib/ability.js to verify user access rights.
- Created tests for cookie management in lib/refreshCookie.js.
- Developed tests for patient user representation in lib/representationAdapters.js.
- Implemented client-side state information retrieval tests in lib/getStateInfoClient.js.
- Created tests for canonical URL generation in lib/getCanonicalUrl.js.
- Developed tests for clinic API service functions in services/clinicApi.js.
- Added request wrapper tests in services/response.js to ensure correct API interaction.
- Set up Vitest configuration in vitest.config.mjs for JSX support and alias resolution.
- Created setup and utility files for testing environment in test/setup.js and test/utils.jsx.
2026-06-28 23:25:46 +03:30

336 lines
20 KiB
Markdown
Raw Permalink 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.
# راه‌اندازی تست و نوشتن تست‌سوئیت برای سایت عمومی (nobat724_front)
## پروژه
`nobat724_front` — سایت عمومی نوبت‌دهی (Next.js 15 App Router، React 18، MUI v5 + Tailwind، RTL فارسی، Jalali، چند-دامنه‌ای از روی subdomain). پروژه‌ی Node خالص (`npm run ...`**JavaScript/JSX خالص — بدون TypeScript**.
> این پرامپت معادلِ `clinicpro/.claude/prompt/admin-spa-test-suite.md` است اما برای سایت عمومی. تفاوت‌های مهم: JS به‌جای TS، Next.js App Router به‌جای SPA، نیاز به mock کردن `next/headers`/`next/navigation`/`window.location`، و منطق تشخیص شهر از subdomain.
## زمینه
سایت عمومی **هیچ زیرساخت تستی ندارد**`package.json` فقط `cross-env` و `prettier` به‌عنوان devDep دارد و هیچ script `test`. در عین حال پر از منطق خالص و قابل‌تست است: نرمال‌سازی موبایل/کدملی، تبدیل رقم فارسی، query-builderهای فیلتر پزشک/کلینیک، تشخیص شهر از host، CASL ability، آداپتر اسلات نوبت، و wrapperهای سرویس API. هیچ‌کدام پوشش ندارند.
## مشکل / هدف
۱. **Vitest + Testing Library + jsdom** را برای یک پروژه‌ی Next.js 15 App Router با **JS/JSX** از صفر راه‌اندازی کن (مستقل از Next build).
۲. تست‌ها را **لایه‌به‌لایه** بنویس: توابع خالص (`utils/`, `lib/`) → تشخیص چند-دامنه‌ای → سرویس‌ها → CASL/context → چند کامپوننت. الگوی تکرارپذیر برای بقیه مستقر کن.
۳. هدف: پوشش معنادار منطق، نه عدد صوری.
## فایل‌های مرتبط
| فایل | نقش | تست هدف |
|------|-----|---------|
| `package.json` | scripts + deps | افزودن deps تست + script `test` |
| `jsconfig.json` | alias `@/* → ./*` | باید در vitest منعکس شود |
| `vitest.config.mjs` | **ساخته شود** | کانفیگ runner |
| `test/setup.js` | **ساخته شود** | jest-dom + mockهای سراسری |
| `test/utils.jsx` | **ساخته شود** | `renderWithProviders` |
| `utils/index.js` | توابع خالص پرشمار | تست واحد سنگین |
| `lib/appointmentSlots.js` | `adaptSlots`, `hasAvailable` | خالص |
| `lib/tokenStore.js` | توکن in-memory | خالص |
| `lib/sanitize.js` | `sanitizeHtml`, `safeJsonParse` | خالص |
| `lib/refreshCookie.js` | `cookieDomain(host)` | خالص |
| `lib/ability.js` | `defineAbilitiesFor(user)` (CASL) | خالص |
| `lib/representationAdapters.js` | `buildPatientUser` | خالص |
| `lib/getStateInfo.js` | تشخیص شهر سرور (`next/headers`) | mock headers |
| `lib/getStateInfoClient.js` | تشخیص شهر کلاینت (`window`) | mock window |
| `lib/getCanonicalUrl.js` | `getCanonicalUrlClient()` | خالص/جزئی |
| `services/clinicApi.js` | `getClinicDoctors` (native fetch) | mock fetch |
| `services/response.js` | wrapperهای `request.*` | mock `@/services/api` |
| `context/ProvinceProvider.js` | `useProvince()` | render + mock window |
## وضعیت فعلی
`package.json` (بدون test، فقط دو devDep):
```json
"scripts": {
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"devDependencies": { "cross-env": "^10.1.0", "prettier": "^3.7.4" }
```
نسخه‌ها: `next ^15.5.7`، `react ^18.3.1` (نه ۱۹)، `@mui/material ^5`، `@casl/ability ^6`، `axios ^1`، `moment-jalaali`/`dayjs`. alias در `jsconfig.json`: `"paths": { "@/*": ["./*"] }` (ریشه‌ی پروژه).
`lib/appointmentSlots.js` (خالص — هدف عالی):
```js
// adaptSlots(slotsResponse): اسلات‌ها را به { morning, evening } تقسیم می‌کند
// بر اساس slot.start_time < "12:00" ؛ ورودی می‌تواند data.sessions یا sessions باشد
// hasAvailable(slots): Array.isArray(slots) && slots.some(s => s.is_available)
```
`utils/index.js` — اهداف خالص (امضاهای واقعی):
```js
formatPhoneNumber(input) // رقم فارسی/عربی → ASCII، حذف غیررقم، حداکثر ۹، فرمت "## ### ####"
validatePhoneNumber(input) // { valid, text } ؛ قانون: ۱۱ رقم، ^09\d{9}$ ؛ پیام‌ها فارسی
isValidIranNationalCode(input) // checksum کدملی ایران (۱۰ رقم، رد ارقام یکسان، mod-11)
getCleanNumberValue(value, def=0)// رقم فارسی/عربی→انگلیسی، strip به [0-9.]، parseFloat، NaN→def
changeNumToDefault(str) // ۰-۹ و ٠-٩ → ASCII
imageUrl(url, fallback) // URL مطلق یا /assets عبور می‌کند؛ /uploads → پیشوند NEXT_PUBLIC_API_URL
normalizeBlog(blog) // reshape به { images, tag, created, body, author }
// Query builderها (خالص؛ نگاشت فیلتر UI → پارامتر API):
// QueryForDoctorsFilter, QueryForDoctorsReq, buildDoctorParams,
// QueryForClinicsFilter, buildClinicParams
// نگاشت‌ها: gender 'مرد'→'man'/'زن'→'woman' ؛ sort '3'→'ASC'/'4'|'5'→'DESC' ؛ limit:12
```
`services/clinicApi.js` (native fetch، double-nested):
```js
// getClinicDoctors(slug, params={}):
// fetch(`${NEXT_PUBLIC_API_URL}/api/v1/clinic/doctor-list/${slug}?${URLSearchParams}`)
// throw اگر baseUrl یا slug نباشد
// موفق → unwrap json.data.data و json.data.meta → { data, page:{ total_pages, current } }
// خطا → { data: [], page: { total_pages: 1, current: 1 } }
```
`lib/getStateInfoClient.js` (sync، SSR-guard):
```js
// typeof window === "undefined" → { matchedCity:null, matchedState:null, host:null, fullUrl:null }
// subdomain = window.location.host.split(".")[0]
// matchedCity = city.json.find(c => c.domain.includes(subdomain))
// matchedState = state.json.find(s => s.id === matchedCity.province_id)
```
`lib/ability.js` (CASL):
```js
// defineAbilitiesFor(user): اگر user → can('access','Dashboard'), cannot('access','Login')
// وگرنه → can('access','Login'), cannot('access','Dashboard')
// return build()
```
## وظایف
### ۱. نصب ابزار و کانفیگ runner
```bash
npm i -D vitest@^2 jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react@^4
```
> **مهم:** `@vitejs/plugin-react` را روی **v4** پین کن — v6 فقط-ESM است و با vite 5 (که vitest 2 استفاده می‌کند) ناسازگار است و runner را می‌شکند.
`nobat724_front/vitest.config.mjs` بساز (پسوند `.mjs` چون پروژه `"type":"module"` ندارد و config باید ESM لود شود):
```js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
// include: /\.(js|jsx)$/ → JSX داخل فایل‌های .js را هم transform کن (Next از .js استفاده می‌کند)
plugins: [react({ include: /\.(js|jsx)$/ })],
resolve: {
alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.js'],
include: ['**/*.{test,spec}.{js,jsx}'],
exclude: ['node_modules', '.next', 'e2e'],
css: false,
clearMocks: true,
restoreMocks: true,
env: { NEXT_PUBLIC_API_URL: 'http://api.test.local' },
},
});
```
> `env.NEXT_PUBLIC_API_URL` لازم است چون `services/api.js` و `services/clinicApi.js` هنگام import آن را می‌خوانند.
scriptها در `package.json`:
```json
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
```
`test/setup.js`:
```js
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
window.matchMedia = window.matchMedia || ((q) => ({
matches: false, media: q, onchange: null,
addEventListener: vi.fn(), removeEventListener: vi.fn(),
addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(),
}));
```
`test/utils.jsx` (برای کامپوننت‌هایی که نیاز به provider/router دارند):
```jsx
import { render } from '@testing-library/react';
// next/navigation در jsdom وجود ندارد — هر تست کامپوننت که از useRouter/usePathname
// استفاده می‌کند باید آن را mock کند (پایین). این helper فقط render پایه است.
export function renderUI(ui, options) {
return render(ui, options);
}
```
### ۲. تست توابع خالص `utils/index.js` (اول و سنگین‌ترین)
`utils/index.test.js` — همه‌ی توابع خالص را پوشش بده. نمونه‌ها:
```js
import { describe, it, expect } from 'vitest';
import {
changeNumToDefault, formatPhoneNumber, validatePhoneNumber,
isValidIranNationalCode, getCleanNumberValue, imageUrl,
} from '@/utils';
describe('changeNumToDefault', () => {
it('رقم فارسی و عربی → انگلیسی', () => {
expect(changeNumToDefault('۰۹۱۲')).toBe('0912');
expect(changeNumToDefault('٠٩١٢')).toBe('0912');
});
});
describe('validatePhoneNumber', () => {
it.each([
['09123456789', true],
['9123456789', false],
['0812345678', false],
])('%s → valid=%s', (input, valid) => {
expect(validatePhoneNumber(input).valid).toBe(valid);
});
});
describe('isValidIranNationalCode', () => {
it('کدملی معتبر را می‌پذیرد و نامعتبر را رد می‌کند', () => {
expect(isValidIranNationalCode('0079460902')).toBe(true); // مقدار معتبر را با خروجی واقعی تابع تطبیق بده
expect(isValidIranNationalCode('1111111111')).toBe(false); // ارقام یکسان
expect(isValidIranNationalCode('123')).toBe(false);
});
});
describe('imageUrl', () => {
it('مسیر /uploads را با NEXT_PUBLIC_API_URL پیشوند می‌دهد', () => {
expect(imageUrl('/uploads/a.jpg')).toContain('/uploads/a.jpg');
});
it('URL مطلق را دست‌نخورده برمی‌گرداند', () => {
expect(imageUrl('https://x.com/a.jpg')).toBe('https://x.com/a.jpg');
});
});
```
ضمناً query-builderها را پوشش بده: یک فیلتر UI نمونه بده و assert کن خروجی شامل نگاشت درست است (مثلاً `{ gender: 'مرد' }` → param `man`، `{ sort: '3' }``ASC`, `limit: 12`).
> **مهم:** قبل از نوشتن، `utils/index.js` را بخوان و **مقادیر assert را با خروجی واقعی تابع تطبیق بده** (به‌ویژه کدملی معتبر و فرمت دقیق `formatPhoneNumber`). حدس نزن — اگر assertion با رفتار واقعی نخواند، تست را با کد هم‌راستا کن (نه برعکس) مگر باگ واقعی باشد.
### ۳. تست خالص `lib/`
`lib/appointmentSlots.test.js`:
```js
import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots';
// اسلات با start_time '09:00' → morning ، '14:00' → evening
// hasAvailable: آرایه با حداقل یک is_available:true → true ؛ [] → false ؛ غیرآرایه → false
```
`lib/tokenStore.test.js``setAccessToken`/`getAccessToken`/`clearAccessToken`؛ `setAccessToken('')` → null.
`lib/sanitize.test.js``sanitizeHtml(non-string)``''`؛ تگ‌های مجاز حفظ، اسکریپت حذف؛ `safeJsonParse('{"a":1}')` → object، ورودی نامعتبر → fallback.
`lib/refreshCookie.test.js` — فقط `cookieDomain(host)`: `localhost`/IP → `undefined`؛ `arak-nobat.ir``.nobat.ir` (با خروجی واقعی تطبیق بده).
`lib/ability.test.js``defineAbilitiesFor({...}).can('access','Dashboard')` true و `can('access','Login')` false؛ بدون user برعکس.
`lib/representationAdapters.test.js``buildPatientUser` با ورودی double-nested `{ data: { data: {...} } }` → خروجی flatten با fallbackها.
### ۴. تشخیص چند-دامنه‌ای (mock host / window / next/headers)
`lib/getStateInfo.test.js` (سرور — `next/headers` را mock کن):
```js
import { vi } from 'vitest';
vi.mock('next/headers', () => ({
headers: () => ({ get: (k) => (k === 'host' ? 'arak-nobat.ir' : null) }),
cookies: () => ({ get: () => undefined }),
}));
import { getStateInfo } from '@/lib/getStateInfo';
// await getStateInfo() → matchedCity.domain شامل 'arak'، matchedState.id === province_id
```
> توجه: در Next 15، `headers()`/`cookies()` async‌اند ولی `await` روی مقدار غیر-Promise هم کار می‌کند؛ mock بالا کافی است.
`lib/getStateInfoClient.test.js` (کلاینت — `window.location.host` را ست کن):
```js
// Object.defineProperty(window, 'location', { value: { host: 'arak-nobat.ir' }, writable: true });
// getStateInfoClient().matchedCity شهر آراک را برمی‌گرداند
// در حالت SSR-guard (حذف window) → همه null (می‌توانی این حالت را در یک تست جدا با vi.stubGlobal بسازی)
```
`lib/getCanonicalUrl.test.js``getCanonicalUrlClient(pathname, host)`: دامنه‌ی اصلی `nobat724.com``null`؛ subdomain → `https://nobat724.com${pathname}`؛ `www.` حذف و lowercase (با امضای واقعی تابع تطبیق بده).
### ۵. سرویس‌ها
`services/clinicApi.test.js` (mock global fetch):
```js
import { vi, beforeEach } from 'vitest';
import { getClinicDoctors } from '@/services/clinicApi';
beforeEach(() => vi.stubGlobal('fetch', vi.fn()));
it('پاسخ double-nested را به { data, page } تبدیل می‌کند', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({
data: { data: [{ uuid: 'd1' }], meta: { total_pages: 2, current: 1 } },
}) });
const out = await getClinicDoctors('my-clinic');
expect(out.data).toHaveLength(1);
expect(out.page.total_pages).toBe(2);
});
it('خطا → shape پیش‌فرض خالی', async () => {
fetch.mockRejectedValue(new Error('net'));
const out = await getClinicDoctors('x');
expect(out.data).toEqual([]);
expect(out.page).toEqual({ total_pages: 1, current: 1 });
});
```
`services/response.test.js` — wrapperها قرارداد درست (method/path/options) را به لایه‌ی axios می‌فرستند. `@/services/api` را mock کن:
```js
vi.mock('@/services/api', () => ({
default: { get: vi.fn(() => Promise.resolve({})), post: vi.fn(() => Promise.resolve({})),
patch: vi.fn(() => Promise.resolve({})), delete: vi.fn(() => Promise.resolve({})) },
}));
import api from '@/services/api';
import { request } from '@/services/response';
// چند wrapper نماینده را صدا بزن و assert کن api.get/post با path درست و { requireAuth: true } (هرجا لازم) فراخوانی شده
// مثلاً request.getUserInfo() → api.get با مسیر oauth/userinfo و requireAuth:true (با کد واقعی response.js تطبیق بده)
```
> منطق interceptorهای `services/api.js` (401 refresh، `extractErrorMessage`، dedup `refreshPromise`) پیچیده و وابسته به axios/toastify/tokenStore است؛ آن را **اختیاری/پیشرفته** بگذار. اگر وقت بود: `extractErrorMessage` را اگر export شده مستقیم تست کن، وگرنه روی wrapperهای `response.js` تمرکز کن.
### ۶. CASL context و یک کامپوننت
`context/ProvinceProvider.test.jsx` — با mock `window.location.hostname`، یک مصرف‌کننده‌ی `useProvince()` را render کن و assert کن `cityId` درست از `city.json` می‌آید:
```jsx
import { render, screen } from '@testing-library/react';
import { ProvinceProvider, useProvince } from '@/context/ProvinceProvider';
function Probe() { const { cityId } = useProvince(); return <span>{String(cityId)}</span>; }
// Object.defineProperty(window,'location',{ value:{ hostname:'arak-nobat.ir' }, writable:true });
// render(<ProvinceProvider><Probe/></ProvinceProvider>) → cityId شهر آراک (useEffect → waitFor)
```
یک کامپوننت ساده‌ی client (مثلاً یکی از `components/common/` یا یک کامپوننت presentational بدون fetch) را هم render-smoke کن. اگر کامپوننت از `next/navigation` استفاده می‌کند:
```js
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));
```
### ۷. اجرا و سبزکردن + lint
```bash
npm run test # کل سوئیت
npm run test:cov # پوشش
npm run lint # ESLint بدون رگرسیون
```
همه باید سبز شوند. اگر تستی به‌خاطر رفتار واقعی کد شکست خورد، تست را با رفتار واقعی هم‌راستا کن (نه تغییر کد) مگر باگ واقعی باشد و آن را جدا گزارش بده.
## نکات مهم
- **پروژه JS/JSX است، نه TS** — هیچ type annotation ننویس؛ فایل تست‌ها `.js`/`.jsx`. plugin-react با `include: /\.(js|jsx)$/` تنظیم شود تا JSX داخل `.js` transform شود.
- **alias `@` به ریشه‌ی پروژه اشاره می‌کند** (`@/utils`, `@/lib/...`, `@/services/...`, `@/context/...`) — نه به `src`.
- **React 18 (نه 19)** — `@testing-library/react` v16 سازگار است.
- `@vitejs/plugin-react` را **v4** پین کن و config را `.mjs` بگذار (همان pitfall ESM/vite5 که در نسخه‌ی admin رخ داد).
- **Server Componentها و صفحات App Router** را مستقیم import نکن مگر `next/headers`/`next/navigation`/`next/cache` را mock کرده باشی. اولویت با لایه‌ی خالص (`utils/`, `lib/`, `services/`) است که بیشترین ارزش و کمترین mock را دارد.
- `next/headers` و `next/navigation` در jsdom وجود ندارند → هرجا کد آن‌ها را صدا می‌زند `vi.mock` کن.
- **توابع تاریخ Jalali** (`convertToJalali`, `convertTimestampToJalali`, …) به `moment-jalaali`/timezone وابسته‌اند؛ به‌جای assert رشته‌ی دقیق، روی «خروجی غیرخالی / شامل رقم فارسی / طول مورد انتظار» assert کن تا تست با timezone شکننده نشود.
- **سه نقطه‌ی تشخیص شهر** (`getStateInfo` سرور با `===`، `getStateInfoClient` کلاینت با `.includes`، `ProvinceProvider`) منطق کمی متفاوت دارند — هرکدام را با host مناسب جدا تست کن و این تفاوت را در تست منعکس کن.
- **پوشش را صریح گزارش کن**: با ۵۰۵ فایل کامپوننت و ۱۶ صفحه، پوشش کامل در یک اجرا غیرواقعی است. لایه‌ی خالص + سرویس + تشخیص دامنه + CASL را کامل کن، و فایل‌های پوشش‌نداده را فهرست کن (silent truncation ممنوع).
- این تغییر فقط frontend سایت عمومی است؛ backend (`clinicpro`) و قرارداد API دست نمی‌خورد.
- بعد از پایان حتماً `npm run build` را **اجرا نکن** برای تست (کند و غیرضروری)؛ فقط `npm run test` و `npm run lint`. مطمئن شو افزودن deps تست build را نمی‌شکند (Vitest کاملاً مستقل از Next build است).