feat: remove CityHighlights component and its associated tests
This commit is contained in:
@@ -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 (
|
|
||||||
<section className="w-full max-w-[900px] z-[50]">
|
|
||||||
<p className="text-[#525252] text-[13px] md:text-[15px] font-normal leading-8 text-justify">
|
|
||||||
{buildCityIntro(cityName, doctorCount, top[0].name)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
|
||||||
تخصصهای پرمراجعه در {cityName}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
|
|
||||||
{top.map((specialty) => (
|
|
||||||
<li key={specialty.id}>
|
|
||||||
<Link
|
|
||||||
href={`/specialties/${specialty.slug}`}
|
|
||||||
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
|
|
||||||
>
|
|
||||||
<span>{specialty.name}</span>
|
|
||||||
<span className="text-[#7E7E7E]">{specialty.number_of_doctors}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{doctors.length > 0 && (
|
|
||||||
<>
|
|
||||||
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
|
||||||
پزشکان {cityName}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
|
|
||||||
{doctors.map((doctor) => (
|
|
||||||
<li key={doctor.uuid}>
|
|
||||||
<Link
|
|
||||||
href={`/doctor/${doctor.uuid}`}
|
|
||||||
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
|
|
||||||
>
|
|
||||||
<span>{doctor.display_name || doctor.name}</span>
|
|
||||||
{doctor.specialty && (
|
|
||||||
<span className="text-[#7E7E7E]">{doctor.specialty}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* تخصصهای همان شهر که پزشک دارند، از پرپزشک به کمپزشک.
|
|
||||||
*
|
|
||||||
* خطا اینجا بلعیده میشود و آرایهٔ خالی برمیگردد: صفحهٔ اصلی مهمترین صفحهٔ سایت است
|
|
||||||
* و قطعیِ 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;
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user