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.
This commit is contained in:
hamed
2026-06-28 23:25:46 +03:30
parent de9e6b7678
commit c316d22160
12 changed files with 2834 additions and 55 deletions
+335
View File
@@ -0,0 +1,335 @@
# راه‌اندازی تست و نوشتن تست‌سوئیت برای سایت عمومی (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 است).
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ProvinceProvider, useProvince } from '@/context/ProvinceProvider';
function Probe() {
const { cityId, isProvinceInclude } = useProvince();
return <span data-testid="out">{cityId ? `${cityId}:${isProvinceInclude.name}` : 'none'}</span>;
}
function setHostname(hostname) {
Object.defineProperty(window, 'location', {
value: { hostname },
writable: true,
configurable: true,
});
}
describe('ProvinceProvider / useProvince', () => {
it('host شهر شناخته‌شده → cityId و شهر را از useEffect ست می‌کند', async () => {
setHostname('arak-nobat.ir');
render(
<ProvinceProvider>
<Probe />
</ProvinceProvider>,
);
await waitFor(() => expect(screen.getByTestId('out')).toHaveTextContent('129:اراک'));
});
it('host ناشناخته → cityId null', async () => {
setHostname('example.com');
render(
<ProvinceProvider>
<Probe />
</ProvinceProvider>,
);
await waitFor(() => expect(screen.getByTestId('out')).toHaveTextContent('none'));
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
changeNumToDefault,
formatPhoneNumber,
validatePhoneNumber,
isValidIranNationalCode,
getCleanNumberValue,
imageUrl,
QueryForDoctorsFilter,
} from '@/helper';
describe('changeNumToDefault', () => {
it('رقم فارسی و عربی → انگلیسی', () => {
expect(changeNumToDefault('۰۹۱۲')).toBe('0912');
expect(changeNumToDefault('٠٩١٢')).toBe('0912');
});
it('ورودی falsy → رشته خالی', () => {
expect(changeNumToDefault('')).toBe('');
expect(changeNumToDefault(null)).toBe('');
});
});
describe('formatPhoneNumber', () => {
it('۹ رقم را به فرمت "## ### ####" در می‌آورد', () => {
expect(formatPhoneNumber('912345678')).toBe('91 234 5678');
});
it('رقم فارسی را نرمال و به ۹ رقم محدود می‌کند', () => {
expect(formatPhoneNumber('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09 123 4567');
});
it('کاراکترهای غیررقمی را حذف می‌کند', () => {
expect(formatPhoneNumber('912-345-678')).toBe('91 234 5678');
});
});
describe('validatePhoneNumber', () => {
it.each([
['09123456789', true],
['9123456789', false],
['08123456789', false],
['0912345678', false],
['', false],
])('%s → valid=%s', (input, valid) => {
expect(validatePhoneNumber(input).valid).toBe(valid);
});
it('رقم غیرانگلیسی → پیام مخصوص', () => {
const r = validatePhoneNumber('۰۹۱۲۳۴۵۶۷۸۹');
expect(r.valid).toBe(false);
expect(r.text).toBe('فقط از اعداد انگلیسی استفاده کنید.');
});
it('ورودی معتبر → text=null', () => {
expect(validatePhoneNumber('09123456789')).toEqual({ valid: true, text: null });
});
});
describe('isValidIranNationalCode', () => {
it('کدملی معتبر را می‌پذیرد', () => {
expect(isValidIranNationalCode('0013542419')).toBe(true);
});
it.each([
['1111111111', false], // ارقام یکسان
['123', false], // طول نادرست
['0013542410', false], // رقم کنترلی غلط
['', false],
])('%s → %s', (input, expected) => {
expect(isValidIranNationalCode(input)).toBe(expected);
});
});
describe('getCleanNumberValue', () => {
it('رقم فارسی + جداکننده → عدد', () => {
expect(getCleanNumberValue('۱۲۳۴')).toBe(1234);
});
it('اعشار را حفظ می‌کند', () => {
expect(getCleanNumberValue('12.5')).toBe(12.5);
});
it('ورودی نامعتبر → مقدار پیش‌فرض', () => {
expect(getCleanNumberValue('abc')).toBe(0);
expect(getCleanNumberValue('abc', 7)).toBe(7);
});
});
describe('imageUrl', () => {
it('URL مطلق دست‌نخورده', () => {
expect(imageUrl('https://x.com/a.jpg')).toBe('https://x.com/a.jpg');
});
it('/uploads با NEXT_PUBLIC_API_URL پیشوند می‌گیرد', () => {
expect(imageUrl('/uploads/a.jpg')).toBe('http://api.test.local/uploads/a.jpg');
});
it('مسیر /assets دست‌نخورده', () => {
expect(imageUrl('/assets/images/x.png')).toBe('/assets/images/x.png');
});
it('خالی → fallback', () => {
expect(imageUrl('')).toBe('/assets/images/user.png');
});
});
describe('QueryForDoctorsFilter', () => {
it('فیلدهای معتبر را نگاشت و فیلدهای پیش‌فرض را حذف می‌کند', () => {
const q = QueryForDoctorsFilter({
active: 1,
specialty: { name: 'قلب' },
city: { name: 'یزد' },
gender: 'مرد',
degree: 'همه موارد', // باید حذف شود
name: ' ', // باید حذف شود
sort: 4,
});
expect(q).toEqual({ active: 1, specialty: 'قلب', city: 'یزد', gender: 'مرد', sort: 4 });
});
it('gender «هر دو» و sort خارج از 3..5 حذف می‌شوند', () => {
const q = QueryForDoctorsFilter({ gender: 'هر دو', sort: 9 });
expect(q.gender).toBeUndefined();
expect(q.sort).toBeUndefined();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { hostRef } = vi.hoisted(() => ({ hostRef: { value: '' } }));
vi.mock('next/headers', () => ({
headers: async () => ({ get: (k) => (k === 'host' ? hostRef.value : null) }),
}));
import { getStateInfo } from '@/lib/getStateInfo';
beforeEach(() => {
hostRef.value = '';
});
describe('getStateInfo (server)', () => {
it('subdomain شناخته‌شده → شهر و استان درست', async () => {
hostRef.value = 'arak-nobat.ir';
const { matchedCity, matchedState } = await getStateInfo();
expect(matchedCity?.name).toBe('اراک');
expect(matchedState?.name).toBe('مرکزی');
});
it('host ناشناخته → بدون تطبیق', async () => {
hostRef.value = 'unknown-city.ir';
const { matchedCity, matchedState } = await getStateInfo();
expect(matchedCity).toBeUndefined();
expect(matchedState).toBeFalsy();
});
it('host خالی → بدون تطبیق', async () => {
hostRef.value = '';
const { matchedCity } = await getStateInfo();
expect(matchedCity).toBeUndefined();
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots';
import { getAccessToken, setAccessToken, clearAccessToken } from '@/lib/tokenStore';
import { sanitizeHtml, safeJsonParse } from '@/lib/sanitize';
import { defineAbilitiesFor } from '@/lib/ability';
import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from '@/lib/refreshCookie';
import { buildPatientUser } from '@/lib/representationAdapters';
describe('appointmentSlots', () => {
const resp = {
sessions: [
{ slots: [
{ start_time: '09:00', is_available: true },
{ start_time: '11:30', is_available: false },
{ start_time: '14:00', is_available: true },
] },
],
};
it('adaptSlots اسلات‌ها را بر اساس 12:00 به صبح/عصر تقسیم می‌کند', () => {
const { morning, evening } = adaptSlots(resp);
expect(morning).toHaveLength(2);
expect(evening).toHaveLength(1);
expect(evening[0].start_time).toBe('14:00');
});
it('ساختار data.sessions را هم می‌پذیرد', () => {
const { morning } = adaptSlots({ data: resp });
expect(morning).toHaveLength(2);
});
it('ورودی خالی → آرایه‌های خالی', () => {
expect(adaptSlots(null)).toEqual({ morning: [], evening: [] });
});
it('hasAvailable', () => {
expect(hasAvailable([{ is_available: false }, { is_available: true }])).toBe(true);
expect(hasAvailable([{ is_available: false }])).toBe(false);
expect(hasAvailable([])).toBe(false);
expect(hasAvailable(null)).toBe(false);
});
});
describe('tokenStore', () => {
beforeEach(() => clearAccessToken());
it('set/get/clear', () => {
expect(getAccessToken()).toBeNull();
setAccessToken('tok');
expect(getAccessToken()).toBe('tok');
clearAccessToken();
expect(getAccessToken()).toBeNull();
});
it('set با مقدار falsy → null', () => {
setAccessToken('');
expect(getAccessToken()).toBeNull();
});
});
describe('sanitize', () => {
it('غیررشته → رشته خالی', () => {
expect(sanitizeHtml(null)).toBe('');
expect(sanitizeHtml(123)).toBe('');
});
it('تگ مجاز حفظ، اسکریپت حذف', () => {
const out = sanitizeHtml('<p>سلام</p><script>alert(1)</script>');
expect(out).toContain('<p>سلام</p>');
expect(out).not.toContain('<script>');
});
it('safeJsonParse معتبر → object', () => {
expect(safeJsonParse('{"a":1}')).toEqual({ a: 1 });
});
it('safeJsonParse نامعتبر → fallback', () => {
expect(safeJsonParse('{bad', { x: 1 })).toEqual({ x: 1 });
expect(safeJsonParse('')).toBeNull();
});
});
describe('ability (CASL)', () => {
it('کاربر لاگین → دسترسی Dashboard، نه Login', () => {
const a = defineAbilitiesFor({ id: 1 });
expect(a.can('access', 'Dashboard')).toBe(true);
expect(a.can('access', 'Login')).toBe(false);
});
it('بدون کاربر → دسترسی Login، نه Dashboard', () => {
const a = defineAbilitiesFor(null);
expect(a.can('access', 'Login')).toBe(true);
expect(a.can('access', 'Dashboard')).toBe(false);
});
});
describe('refreshCookie (cookieDomain از طریق set)', () => {
let response;
beforeEach(() => {
response = { cookies: { set: vi.fn() } };
});
it('دامنه‌ی واقعی → "." + دو لیبل آخر', () => {
setRefreshCookie(response, 'rt', 'arak-nobat.ir');
const opts = response.cookies.set.mock.calls[0][2];
expect(response.cookies.set.mock.calls[0][0]).toBe(COOKIE_NAME);
expect(opts.domain).toBe('.arak-nobat.ir');
expect(opts.httpOnly).toBe(true);
});
it('host سه‌لیبلی → فقط دو لیبل آخر', () => {
setRefreshCookie(response, 'rt', 'www.arak-nobat.ir');
expect(response.cookies.set.mock.calls[0][2].domain).toBe('.arak-nobat.ir');
});
it('localhost و IP → domain undefined', () => {
setRefreshCookie(response, 'rt', 'localhost:3000');
expect(response.cookies.set.mock.calls[0][2].domain).toBeUndefined();
response.cookies.set.mockClear();
setRefreshCookie(response, 'rt', '127.0.0.1');
expect(response.cookies.set.mock.calls[0][2].domain).toBeUndefined();
});
it('clearRefreshCookie با maxAge=0', () => {
clearRefreshCookie(response, 'arak-nobat.ir');
expect(response.cookies.set.mock.calls[0][2].maxAge).toBe(0);
});
});
describe('representationAdapters.buildPatientUser', () => {
it('ساختار double-nested را flatten می‌کند', () => {
const out = buildPatientUser({
data: { data: { label: 'علی', family: 'رضایی', phone: '09120000000', national_code: '0013542419' } },
});
expect(out.name).toBe('علی رضایی');
expect(out.phone).toBe('09120000000');
expect(out.national_code).toBe('0013542419');
});
it('ورودی خالی → مقادیر پیش‌فرض', () => {
const out = buildPatientUser(null);
expect(out.name).toBe('');
expect(out.profile).toBe('/assets/images/profile-user.png');
expect(out.turns).toEqual({ current: [], done: [] });
});
it('extras به‌عنوان fallback استفاده می‌شود', () => {
const out = buildPatientUser({}, { realName: 'مهمان', mobile: '0935' });
expect(out.name).toBe('مهمان');
expect(out.phone).toBe('0935');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getStateInfoClient } from '@/lib/getStateInfoClient';
import { getCanonicalUrlClient } from '@/lib/getCanonicalUrl';
function setLocation(loc) {
Object.defineProperty(window, 'location', { value: loc, writable: true, configurable: true });
}
describe('getStateInfoClient (window)', () => {
it('subdomain شناخته‌شده → شهر و استان', () => {
setLocation({ host: 'arak-nobat.ir', href: 'https://arak-nobat.ir/' });
const { matchedCity, matchedState, host } = getStateInfoClient();
expect(matchedCity?.name).toBe('اراک');
expect(matchedState?.name).toBe('مرکزی');
expect(host).toBe('arak-nobat.ir');
});
it('host ناشناخته → بدون تطبیق', () => {
setLocation({ host: 'zzz.ir', href: 'https://zzz.ir/' });
expect(getStateInfoClient().matchedCity).toBeUndefined();
});
});
describe('getCanonicalUrlClient (window)', () => {
beforeEach(() => {
setLocation({ host: 'arak-nobat.ir', pathname: '/doctors' });
});
it('subdomain → canonical به دامنه‌ی اصلی', () => {
expect(getCanonicalUrlClient()).toBe('https://nobat724.com/doctors');
});
it('دامنه‌ی اصلی → null', () => {
setLocation({ host: 'nobat724.com', pathname: '/doctors' });
expect(getCanonicalUrlClient()).toBeNull();
});
it('www و حروف بزرگ نرمال می‌شوند (دامنه‌ی اصلی) → null', () => {
setLocation({ host: 'WWW.Nobat724.com', pathname: '/x' });
expect(getCanonicalUrlClient()).toBeNull();
});
});
+1991 -53
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -6,7 +6,10 @@
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev", "dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.3", "@casl/ability": "^6.7.3",
@@ -54,8 +57,15 @@
"uninstall": "^0.0.0" "uninstall": "^0.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.7.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"prettier": "^3.7.4" "jsdom": "^29.1.1",
"prettier": "^3.7.4",
"vitest": "^2.1.9"
}, },
"overrides": { "overrides": {
"react": "^18.3.1", "react": "^18.3.1",
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- clinicApi (native fetch) ---
import { getClinicDoctors } from '@/services/clinicApi';
describe('getClinicDoctors', () => {
beforeEach(() => vi.stubGlobal('fetch', vi.fn()));
it('پاسخ double-nested → { data, page }', async () => {
fetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: { data: [{ uuid: 'd1' }, { uuid: 'd2' }], meta: { totalPages: 3, currentPage: 1 } },
}),
});
const out = await getClinicDoctors('my-clinic', { page: 1 });
expect(out.data).toHaveLength(2);
expect(out.page).toEqual({ total_pages: 3, current: 1 });
const calledUrl = fetch.mock.calls[0][0];
expect(calledUrl).toContain('/api/v1/clinic/doctor-list/my-clinic');
});
it('بدون meta → صفحه‌بندی پیش‌فرض', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: { data: [] } }) });
const out = await getClinicDoctors('x');
expect(out.page).toEqual({ total_pages: 1, current: 1 });
});
it('پاسخ ناموفق (ok=false) → shape خالی پیش‌فرض', async () => {
fetch.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('err') });
const out = await getClinicDoctors('x');
expect(out).toEqual({ data: [], page: { total_pages: 1, current: 1 } });
});
it('خطای شبکه → shape خالی پیش‌فرض', async () => {
fetch.mockRejectedValue(new Error('net'));
const out = await getClinicDoctors('x');
expect(out.data).toEqual([]);
});
});
// --- response.js wrappers (mock axios layer) ---
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';
describe('request wrappers — قرارداد به لایه‌ی API', () => {
beforeEach(() => {
api.get.mockClear();
api.post.mockClear();
});
it('getUserProfile → GET مسیر درست با requireAuth', () => {
request.getUserProfile('u1');
expect(api.get).toHaveBeenCalledWith('/api/v1/user-profile/u1', { requireAuth: true });
});
it('getUserInfo → GET oauth/userinfo با requireAuth', () => {
request.getUserInfo();
expect(api.get).toHaveBeenCalledWith('oauth/userinfo', { requireAuth: true });
});
it('postAppointment → POST با body و requireAuth', () => {
request.postAppointment({ slot: 5 });
expect(api.post).toHaveBeenCalledWith('api/v1/appointment', { slot: 5 }, { requireAuth: true });
});
});
+18
View File
@@ -0,0 +1,18 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
window.matchMedia =
window.matchMedia ||
((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
+5
View File
@@ -0,0 +1,5 @@
import { render } from '@testing-library/react';
export function renderUI(ui, options) {
return render(ui, options);
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
// پروژه از .js برای فایل‌های حاوی JSX استفاده می‌کند (الگوی Next).
// esbuild را وادار می‌کنیم .js را با loader=jsx و runtime خودکار پردازش کند،
// و همین loader را در dep-optimizer هم ست می‌کنیم تا import-analysis نشکند.
export default defineConfig({
resolve: {
alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
},
esbuild: {
loader: 'jsx',
include: /\.(jsx?|tsx?)(\?.*)?$/,
exclude: [],
jsx: 'automatic',
},
optimizeDeps: {
esbuildOptions: { loader: { '.js': 'jsx' } },
},
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' },
},
});