Files
nobat724_front/.claude/prompt/nobat724-test-suite.md
T
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

20 KiB
Raw Blame History

راه‌اندازی تست و نوشتن تست‌سوئیت برای سایت عمومی (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.jssetAccessToken/getAccessToken/clearAccessToken؛ setAccessToken('') → null. lib/sanitize.test.jssanitizeHtml(non-string)''؛ تگ‌های مجاز حفظ، اسکریپت حذف؛ safeJsonParse('{"a":1}') → object، ورودی نامعتبر → fallback. lib/refreshCookie.test.js — فقط cookieDomain(host): localhost/IP → undefined؛ arak-nobat.ir.nobat.ir (با خروجی واقعی تطبیق بده). lib/ability.test.jsdefineAbilitiesFor({...}).can('access','Dashboard') true و can('access','Login') false؛ بدون user برعکس. lib/representationAdapters.test.jsbuildPatientUser با ورودی 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.jsgetCanonicalUrlClient(pathname, host): دامنه‌ی اصلی nobat724.comnull؛ 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، 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 می‌آید:

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 داخل .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 است).