From c316d22160a78ebeb5c6cfbe1ce4d178cbdf7053 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 28 Jun 2026 23:25:46 +0330 Subject: [PATCH] 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. --- .claude/prompt/nobat724-test-suite.md | 335 ++++ context/ProvinceProvider.test.jsx | 38 + helper/index.test.js | 115 ++ lib/getStateInfo.test.js | 35 + lib/lib.test.js | 136 ++ lib/multiDomainClient.test.js | 42 + package-lock.json | 2044 ++++++++++++++++++++++++- package.json | 14 +- services/services.test.js | 76 + test/setup.js | 18 + test/utils.jsx | 5 + vitest.config.mjs | 31 + 12 files changed, 2834 insertions(+), 55 deletions(-) create mode 100644 .claude/prompt/nobat724-test-suite.md create mode 100644 context/ProvinceProvider.test.jsx create mode 100644 helper/index.test.js create mode 100644 lib/getStateInfo.test.js create mode 100644 lib/lib.test.js create mode 100644 lib/multiDomainClient.test.js create mode 100644 services/services.test.js create mode 100644 test/setup.js create mode 100644 test/utils.jsx create mode 100644 vitest.config.mjs diff --git a/.claude/prompt/nobat724-test-suite.md b/.claude/prompt/nobat724-test-suite.md new file mode 100644 index 0000000..27a4f47 --- /dev/null +++ b/.claude/prompt/nobat724-test-suite.md @@ -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 {String(cityId)}; } +// Object.defineProperty(window,'location',{ value:{ hostname:'arak-nobat.ir' }, writable:true }); +// render() → 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 است). diff --git a/context/ProvinceProvider.test.jsx b/context/ProvinceProvider.test.jsx new file mode 100644 index 0000000..1d48323 --- /dev/null +++ b/context/ProvinceProvider.test.jsx @@ -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 {cityId ? `${cityId}:${isProvinceInclude.name}` : 'none'}; +} + +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( + + + , + ); + await waitFor(() => expect(screen.getByTestId('out')).toHaveTextContent('129:اراک')); + }); + + it('host ناشناخته → cityId null', async () => { + setHostname('example.com'); + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('out')).toHaveTextContent('none')); + }); +}); diff --git a/helper/index.test.js b/helper/index.test.js new file mode 100644 index 0000000..caf88f9 --- /dev/null +++ b/helper/index.test.js @@ -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(); + }); +}); diff --git a/lib/getStateInfo.test.js b/lib/getStateInfo.test.js new file mode 100644 index 0000000..f40cb1d --- /dev/null +++ b/lib/getStateInfo.test.js @@ -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(); + }); +}); diff --git a/lib/lib.test.js b/lib/lib.test.js new file mode 100644 index 0000000..3c48294 --- /dev/null +++ b/lib/lib.test.js @@ -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('

سلام

'); + expect(out).toContain('

سلام

'); + expect(out).not.toContain('