From da3f1e5458cecca0f5ffe3ea5ea1174ab946955e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 21:01:52 +0330 Subject: [PATCH] feat: remove CityHighlights component and its associated tests --- components/home/CityHighlights.js | 138 ----------------- components/home/CityHighlights.test.js | 207 ------------------------- 2 files changed, 345 deletions(-) delete mode 100644 components/home/CityHighlights.js delete mode 100644 components/home/CityHighlights.test.js diff --git a/components/home/CityHighlights.js b/components/home/CityHighlights.js deleted file mode 100644 index b24ff12..0000000 --- a/components/home/CityHighlights.js +++ /dev/null @@ -1,138 +0,0 @@ -import Link from "next/link"; -import { getStateInfo } from "@/lib/getStateInfo"; -import { fetchReq } from "@/lib/req"; -import { buildCityIntro } from "@/lib/listingIntro"; -import { isNoindexDoctor } from "@/lib/entityQuality"; - -const API_URL = process.env.NEXT_PUBLIC_API_URL; -const SHOWN_SPECIALTIES = 8; -const SHOWN_DOCTORS = 4; - -/** - * بخش شهرمحور صفحهٔ اصلی — متن یکتا، آمار واقعی و تخصص‌های پرمراجعهٔ همان شهر. - * - * چرا لازم است: صفحهٔ اصلی تا پیش از این فقط `site_name` و `slogan` را شهری می‌کرد و - * متن رندرشدهٔ دو دامنه ۹۹٫۰٪ یکسان بود. گوگل canonicalِ درستِ ما را رد می‌کرد و - * صفحهٔ اصلی شهر را با دامنهٔ دیگر تجمیع می‌کرد. تمایز فقط با تگ حل نمی‌شود؛ محتوا - * باید واقعاً فرق کند، و آمارِ هر شهر خودبه‌خود فرق دارد. - * - * جدا از FrequentSearches است: آن فهرست ثابتِ تخصص‌هاست و روی همهٔ دامنه‌ها یکی؛ این - * یکی از داده‌ی همان شهر ساخته می‌شود. - */ -async function CityHighlights() { - const { matchedCity, isRoot } = await getStateInfo(); - - // دامنهٔ ریشه شهر نیست؛ آمار و متن شهری آنجا معنا ندارد. - if (isRoot || !matchedCity?.id) return null; - - const [counts, doctors] = await Promise.all([ - fetchCityCounts(matchedCity.id), - fetchCityDoctors(matchedCity.id), - ]); - if (counts.length === 0) return null; - - const doctorCount = counts.reduce((sum, s) => sum + s.number_of_doctors, 0); - if (doctorCount === 0) return null; - - const top = counts.slice(0, SHOWN_SPECIALTIES); - const cityName = matchedCity.name; - - return ( -
-

- {buildCityIntro(cityName, doctorCount, top[0].name)} -

- -

- تخصص‌های پرمراجعه در {cityName} -

- - - - {doctors.length > 0 && ( - <> -

- پزشکان {cityName} -

- - - - )} -
- ); -} - -/** - * تخصص‌های همان شهر که پزشک دارند، از پرپزشک به کم‌پزشک. - * - * خطا اینجا بلعیده می‌شود و آرایهٔ خالی برمی‌گردد: صفحهٔ اصلی مهم‌ترین صفحهٔ سایت است - * و قطعیِ API نباید سفیدش کند. این همان مرز واقعیِ error handling است — یک I/O boundary. - */ -/** - * چند پزشکِ همان شهر برای لینک مستقیم از صفحهٔ اصلی. - * - * بک‌اند خودش «دارای نوبت» را بالاتر مرتب می‌کند، پس ترتیب پیش‌فرض همان چیزی است که - * می‌خواهیم. پروفایل noindex کنار گذاشته می‌شود — همان سیاستی که sitemap اعمال - * می‌کند؛ لینک دادن از صفحهٔ اصلی به صفحه‌ای که خودمان از ایندکس بیرونش گذاشته‌ایم، - * سیگنال متناقض است. - */ -async function fetchCityDoctors(cityId) { - const data = await fetchReq( - `${API_URL}/api/v1/doctors?city_id=${cityId}&limit=12` - ); - - const items = data?.data ?? []; - if (!Array.isArray(items)) return []; - - return items - .filter((d) => !isNoindexDoctor(d)) - .slice(0, SHOWN_DOCTORS) - .map((d) => ({ - uuid: d.uuid, - name: d.name, - display_name: d.display_name, - specialty: d.specialties?.[0]?.name ?? null, - })); -} - -async function fetchCityCounts(cityId) { - const data = await fetchReq( - `${API_URL}/api/v1/specialties/doctor-counts?city_id=${cityId}` - ); - - const items = data?.data?.data ?? []; - if (!Array.isArray(items)) return []; - - return items - .map((s) => ({ ...s, number_of_doctors: Number(s?.number_of_doctors) || 0 })) - .filter((s) => s.slug && s.number_of_doctors > 0) - .sort((a, b) => b.number_of_doctors - a.number_of_doctors); -} - -export default CityHighlights; diff --git a/components/home/CityHighlights.test.js b/components/home/CityHighlights.test.js deleted file mode 100644 index 4902239..0000000 --- a/components/home/CityHighlights.test.js +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; - -const getStateInfo = vi.fn(); -const fetchReq = vi.fn(); - -vi.mock("@/lib/getStateInfo", () => ({ getStateInfo: (...a) => getStateInfo(...a) })); -vi.mock("@/lib/req", () => ({ fetchReq: (...a) => fetchReq(...a) })); - -const { default: CityHighlights } = await import("@/components/home/CityHighlights"); - -const BEHBAHAN = { id: 41, name: "بهبهان", site_name: "بهبهان نوبت" }; - -const counts = (rows) => ({ data: { data: rows } }); - -const ROWS = [ - { id: 1, name: "دندانپزشک", slug: "dentistry", number_of_doctors: 12 }, - { id: 2, name: "داخلی", slug: "internal-medicine", number_of_doctors: 7 }, - { id: 3, name: "بدون اسلاگ", slug: null, number_of_doctors: 9 }, - { id: 4, name: "بدون پزشک", slug: "empty", number_of_doctors: 0 }, -]; - -const DOCTORS = { - data: [ - { - uuid: "u-1", - name: "معصومه خدری", - owner_status: "claimed", - specialties: [{ id: "1", name: "داخلی" }], - address: [{ id: 1 }], - }, - { - uuid: "u-2", - name: "پروفایل بی‌مالک", - owner_status: "unclaimed", - specialties: [{ id: "1", name: "داخلی" }], - }, - ], -}; - -const renderComponent = async () => render(await CityHighlights()); - -/** مسیرِ فراخوانی را از روی URL تشخیص می‌دهد؛ ترتیب Promise.all نباید تست را بشکند. */ -const routeMock = ({ countsBody = counts(ROWS), doctorsBody = DOCTORS } = {}) => - fetchReq.mockImplementation((url) => - Promise.resolve(url.includes("doctor-counts") ? countsBody : doctorsBody) - ); - -beforeEach(() => { - getStateInfo.mockReset(); - fetchReq.mockReset(); - getStateInfo.mockResolvedValue({ matchedCity: BEHBAHAN, isRoot: false }); - routeMock(); -}); - -describe("CityHighlights", () => { - it("متن یکتا و نام شهر را نشان می‌دهد", async () => { - await renderComponent(); - - expect(screen.getAllByText(/بهبهان/).length).toBeGreaterThan(0); - expect( - screen.getByText("تخصص‌های پرمراجعه در بهبهان") - ).toBeInTheDocument(); - }); - - it("تخصص‌ها را از پرپزشک به کم‌پزشک می‌چیند و لینک می‌دهد", async () => { - await renderComponent(); - - const links = screen.getAllByRole("link"); - expect(links[0]).toHaveAttribute("href", "/specialties/dentistry"); - expect(links[1]).toHaveAttribute("href", "/specialties/internal-medicine"); - }); - - it("تخصص بدون slug یا بدون پزشک نمایش داده نمی‌شود", async () => { - await renderComponent(); - - expect(screen.queryByText("بدون اسلاگ")).not.toBeInTheDocument(); - expect(screen.queryByText("بدون پزشک")).not.toBeInTheDocument(); - }); - - it("شمار کل پزشکان در متن می‌آید", async () => { - await renderComponent(); - - // فقط ردیف‌های معتبر: 12 + 7 = 19 - expect(screen.getByText(/19/)).toBeInTheDocument(); - }); - - // ── پزشکان شاخص ─────────────────────────────────────────────────────────── - - it("پزشکان همان شهر را با لینک مستقیم نشان می‌دهد", async () => { - await renderComponent(); - - expect(screen.getByText("پزشکان بهبهان")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /معصومه خدری/ })).toHaveAttribute( - "href", - "/doctor/u-1" - ); - }); - - it("پروفایل noindex لینک نمی‌گیرد", async () => { - // همان سیاست sitemap؛ لینک از صفحهٔ اصلی به صفحهٔ ایندکس‌نشده سیگنال متناقض است. - await renderComponent(); - - expect(screen.queryByText("پروفایل بی‌مالک")).not.toBeInTheDocument(); - }); - - it("شهرِ بدون پزشکِ ایندکس‌پذیر، بخش پزشکان را حذف می‌کند ولی تخصص‌ها می‌مانند", async () => { - routeMock({ doctorsBody: { data: [] } }); - - await renderComponent(); - - expect(screen.queryByText(/^پزشکان /)).not.toBeInTheDocument(); - expect(screen.getByText("تخصص‌های پرمراجعه در بهبهان")).toBeInTheDocument(); - }); - - it("قطعی سرویس پزشکان بقیهٔ بخش را نمی‌شکند", async () => { - routeMock({ doctorsBody: null }); - - await renderComponent(); - - expect(screen.getByText("تخصص‌های پرمراجعه در بهبهان")).toBeInTheDocument(); - }); - - it("حداکثر چهار پزشک نشان می‌دهد", async () => { - routeMock({ - doctorsBody: { - data: Array.from({ length: 10 }, (_, i) => ({ - uuid: `d-${i}`, - name: `پزشک ${i}`, - owner_status: "claimed", - specialties: [{ id: "1", name: "داخلی" }], - })), - }, - }); - - await renderComponent(); - - const doctorLinks = screen - .getAllByRole("link") - .filter((a) => a.getAttribute("href").startsWith("/doctor/")); - expect(doctorLinks).toHaveLength(4); - }); - - // ── مسیرهایی که باید چیزی رندر نکنند ────────────────────────────────────── - - it("دامنهٔ ریشه هیچ بخشی نمی‌سازد", async () => { - getStateInfo.mockResolvedValue({ matchedCity: { id: 600 }, isRoot: true }); - - const { container } = await renderComponent(); - - expect(container).toBeEmptyDOMElement(); - expect(fetchReq).not.toHaveBeenCalled(); - }); - - it("قطعی API صفحه را نمی‌شکند", async () => { - // fetchReq در خطا خودش null می‌دهد؛ صفحهٔ اصلی نباید سفید شود. - fetchReq.mockResolvedValue(null); - - const { container } = await renderComponent(); - - expect(container).toBeEmptyDOMElement(); - }); - - it("پاسخ با شکل غیرمنتظره هم کرش نمی‌کند", async () => { - fetchReq.mockResolvedValue({ data: { data: "not-an-array" } }); - - const { container } = await renderComponent(); - - expect(container).toBeEmptyDOMElement(); - }); - - it("شهرِ بدون پزشک بخش را حذف می‌کند، نه اینکه صفر نشان دهد", async () => { - fetchReq.mockResolvedValue( - counts([{ id: 9, name: "x", slug: "x", number_of_doctors: 0 }]) - ); - - const { container } = await renderComponent(); - - expect(container).toBeEmptyDOMElement(); - expect(container.textContent).not.toContain("0 پزشک"); - }); - - it("شهرِ بدون شناسه بخش را حذف می‌کند", async () => { - getStateInfo.mockResolvedValue({ matchedCity: null, isRoot: false }); - - const { container } = await renderComponent(); - - expect(container).toBeEmptyDOMElement(); - }); - - it("حداکثر هشت تخصص نشان می‌دهد", async () => { - fetchReq.mockResolvedValue( - counts( - Array.from({ length: 20 }, (_, i) => ({ - id: i, - name: `تخصص ${i}`, - slug: `s-${i}`, - number_of_doctors: 20 - i, - })) - ) - ); - - await renderComponent(); - - expect(screen.getAllByRole("link")).toHaveLength(8); - }); -});