import { beforeEach, describe, expect, it, vi } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { GET } from "@/app/api/experts/search/route"; import { mapExpertResult, resetCategoryPool } from "@/lib/experts-search"; import { fetchExperts } from "@/lib/experts-search-client"; import { ExpertSearchView, type ExpertSearchViewProps } from "@/components/ExpertSearch"; const raw = (o: Record = {}) => ({ id: "e1", first_name: "Jane", last_name: "Doe", slug: "JaneDoe", byline: "Coach", url_picture: "https://img/j.jpg", rating: 4.5, transfer_method_setup: true, categories: [{ id: 42, name: "Business " }, { id: 44, name: "Coaching" }], email: "j@x.com", phone: "555", dob: "1990", kyc_status: "ok", stripe_account_set: true, quick_question_rate: 5, is_blocking: false, user_id: "u1", ...o, }); async function call(url: string, impl: (u: string) => Response) { const orig = globalThis.fetch; const spy = vi.fn(async (u: unknown, _init?: unknown) => impl(String(u))); globalThis.fetch = spy as unknown as typeof fetch; try { return { res: await GET(new Request(url)), spy }; } finally { globalThis.fetch = orig; } } const ok = (data: unknown[]) => new Response(JSON.stringify({ status: "success", data })); beforeEach(() => resetCategoryPool()); describe("mapExpertResult", () => { it("keeps only public fields", () => { const m = mapExpertResult(raw())!; expect(Object.keys(m).sort()).toEqual( ["byline", "categories", "displayName", "id", "pictureUrl", "rating", "slug"], ); expect(m.displayName).toBe("Jane Doe"); expect(m.categories[0].name).toBe("Business"); const s = JSON.stringify(m); for (const bad of ["j@x.com", "555", "1990", "kyc", "stripe", "is_blocking", "u1"]) { expect(s).not.toContain(bad); } }); it("drops items without an id", () => expect(mapExpertResult({ first_name: "x" })).toBeNull()); }); describe("GET /api/experts/search", () => { it("returns empty for no query without calling upstream", async () => { const { res, spy } = await call("http://x/api/experts/search", () => ok([])); expect(await res.json()).toEqual({ experts: [] }); expect(spy).not.toHaveBeenCalled(); }); it("text search hits the v3 experts search, never a user endpoint, with no auth", async () => { const { res, spy } = await call("http://x/api/experts/search?q=pat", () => ok([raw()])); const json = await res.json(); expect(json.experts).toHaveLength(1); expect(JSON.stringify(json)).not.toContain("j@x.com"); const url = String(spy.mock.calls[0][0]); expect(url).toContain("/api/v3/experts?"); expect(url).toContain("search=pat"); expect(url).not.toMatch(/\/api\/v4\/users|\/admin\//); expect(JSON.stringify(spy.mock.calls[0][1])).not.toMatch(/authorization/i); }); it("category browse filters the pool by category name and caches it", async () => { const list = [raw(), raw({ id: "e2", categories: [{ id: 9, name: "Media" }] })]; const { res, spy } = await call("http://x/api/experts/search?category=business", () => ok(list)); expect((await res.json()).experts.map((e: { id: string }) => e.id)).toEqual(["e1"]); const again = await call("http://x/api/experts/search?category=Media", () => ok(list)); expect((await again.res.json()).experts.map((e: { id: string }) => e.id)).toEqual(["e2"]); expect(spy).toHaveBeenCalledTimes(1); expect(again.spy).not.toHaveBeenCalled(); }); it("upstream failure -> 502", async () => { const { res } = await call("http://x/api/experts/search?q=a", () => new Response("no", { status: 500 })); expect(res.status).toBe(502); expect(await res.json()).toEqual({ error: "experts_unavailable" }); }); }); describe("fetchExperts", () => { it("calls the same-origin route and rejects on failure", async () => { const f = vi.fn(async (_u: string) => new Response(JSON.stringify({ experts: [mapExpertResult(raw())] }))); expect(await fetchExperts({ q: "a b" }, f as unknown as typeof fetch)).toHaveLength(1); expect(f.mock.calls[0][0]).toBe("/api/experts/search?q=a+b"); const bad = vi.fn(async (_u: string) => new Response("", { status: 502 })); await expect(fetchExperts({ category: "Media" }, bad as unknown as typeof fetch)).rejects.toThrow(); expect(bad.mock.calls[0][0]).toBe("/api/experts/search?category=Media"); }); }); const view = (o: Partial = {}) => renderToStaticMarkup(createElement(ExpertSearchView, { query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }], activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o, })); describe("ExpertSearchView", () => { it("labels the input and renders category buttons", () => { const h = view(); expect(h).toContain(''); expect(h).toContain('id="expert-q"'); expect(h).toMatch(/]*type="button"[^>]*aria-pressed="false"[^>]*>Business<\/button>/); }); it("marks the active category pressed", () => { expect(view({ activeCategory: "Business" })).toContain('aria-pressed="true"'); }); it("renders results, empty and error states", () => { const r = mapExpertResult(raw())!; expect(view({ status: "ready", results: [r] })).toContain("Jane Doe"); expect(view({ status: "ready", results: [] })).toContain("No experts found."); expect(view({ status: "error" })).toContain('role="alert"'); expect(view({ status: "idle" })).not.toContain("data-testid=\"search-result\""); }); });