115 lines
5.5 KiB
TypeScript
115 lines
5.5 KiB
TypeScript
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<string, unknown> = {}) => ({
|
|
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<ExpertSearchViewProps> = {}) =>
|
|
renderToStaticMarkup(createElement(ExpertSearchView, {
|
|
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
|
|
activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o,
|
|
}));
|
|
|
|
describe("ExpertSearchView", () => {
|
|
it("labels the input and renders category buttons", () => {
|
|
const h = view();
|
|
expect(h).toContain('<label for="expert-q">Search experts</label>');
|
|
expect(h).toContain('id="expert-q"');
|
|
expect(h).toMatch(/<button[^>]*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\"");
|
|
});
|
|
});
|