- 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.
20 KiB
راهاندازی تست و نوشتن تستسوئیت برای سایت عمومی (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):
"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 (خالص — هدف عالی):
// 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 — اهداف خالص (امضاهای واقعی):
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):
// 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):
// 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):
// defineAbilitiesFor(user): اگر user → can('access','Dashboard'), cannot('access','Login')
// وگرنه → can('access','Login'), cannot('access','Dashboard')
// return build()
وظایف
۱. نصب ابزار و کانفیگ runner
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 لود شود):
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:
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
test/setup.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 دارند):
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 — همهی توابع خالص را پوشش بده. نمونهها:
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:
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 کن):
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 را ست کن):
// 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):
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 کن:
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، deduprefreshPromise) پیچیده و وابسته به axios/toastify/tokenStore است؛ آن را اختیاری/پیشرفته بگذار. اگر وقت بود:extractErrorMessageرا اگر export شده مستقیم تست کن، وگرنه روی wrapperهایresponse.jsتمرکز کن.
۶. CASL context و یک کامپوننت
context/ProvinceProvider.test.jsx — با mock window.location.hostname، یک مصرفکنندهی useProvince() را render کن و assert کن cityId درست از city.json میآید:
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 استفاده میکند:
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));
۷. اجرا و سبزکردن + lint
npm run test # کل سوئیت
npm run test:cov # پوشش
npm run lint # ESLint بدون رگرسیون
همه باید سبز شوند. اگر تستی بهخاطر رفتار واقعی کد شکست خورد، تست را با رفتار واقعی همراستا کن (نه تغییر کد) مگر باگ واقعی باشد و آن را جدا گزارش بده.
نکات مهم
- پروژه JS/JSX است، نه TS — هیچ type annotation ننویس؛ فایل تستها
.js/.jsx. plugin-react باinclude: /\.(js|jsx)$/تنظیم شود تا JSX داخل.jstransform شود. - alias
@به ریشهی پروژه اشاره میکند (@/utils,@/lib/...,@/services/...,@/context/...) — نه بهsrc. - React 18 (نه 19) —
@testing-library/reactv16 سازگار است. @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 است).