bdf9cupe3: Add expert text search and category browse to the left column
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { browseCategory, searchExperts } from "@/lib/experts-search";
|
||||||
|
|
||||||
|
// GET /api/experts/search?q=<text> or ?category=<name>
|
||||||
|
// Same-origin proxy for Minnect's PUBLIC expert list. EXPERTS ONLY: it never
|
||||||
|
// calls a user-search endpoint. Public fields only. No session needed.
|
||||||
|
// Upstream failure -> 502 { error: "experts_unavailable" }.
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 80);
|
||||||
|
const category = (url.searchParams.get("category") ?? "").trim().slice(0, 80);
|
||||||
|
if (!q && !category) return NextResponse.json({ experts: [] });
|
||||||
|
const result = q ? await searchExperts(q) : await browseCategory(category);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ experts: result.experts });
|
||||||
|
}
|
||||||
+16
-2
@@ -83,7 +83,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
|||||||
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
|
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
|
||||||
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
|
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
|
||||||
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
|
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
|
||||||
.col-left ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
|
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
|
||||||
.col-left a[aria-current="page"] { background: var(--ink); color: #fff; }
|
.col-left a[aria-current="page"] { background: var(--ink); color: #fff; }
|
||||||
.col-center { flex: 1 1 400px; min-width: 0; }
|
.col-center { flex: 1 1 400px; min-width: 0; }
|
||||||
@@ -105,9 +105,23 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
|||||||
.minnect-audio { width: 100%; height: 36px; }
|
.minnect-audio { width: 100%; height: 36px; }
|
||||||
@media (max-width: 959px) {
|
@media (max-width: 959px) {
|
||||||
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
||||||
.col-left ul { flex-direction: row; }
|
.col-left > ul { flex-direction: row; }
|
||||||
.col-center { flex: 1 1 100%; order: 3; }
|
.col-center { flex: 1 1 100%; order: 3; }
|
||||||
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
|
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
|
||||||
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
|
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
|
||||||
.col-right .rail-card { flex: 0 0 260px; }
|
.col-right .rail-card { flex: 0 0 260px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
|
||||||
|
.expert-search { margin-top: 16px; }
|
||||||
|
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
|
||||||
|
.expert-search form { display: flex; flex-direction: column; }
|
||||||
|
.expert-search input { border-radius: 0; width: 100%; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||||
|
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: #fff; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
|
||||||
|
.chip[aria-pressed="true"] { background: var(--red); border-color: var(--red); color: #fff; }
|
||||||
|
.search-results { margin-top: 12px; }
|
||||||
|
.search-note { margin: 0; font-size: 14px; color: var(--grey); }
|
||||||
|
@media (max-width: 959px) {
|
||||||
|
.col-left .expert-search { flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SignInForm } from "./SignInForm";
|
|||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
import { Feed } from "./Feed";
|
import { Feed } from "./Feed";
|
||||||
import { DiscoverRails } from "./DiscoverRails";
|
import { DiscoverRails } from "./DiscoverRails";
|
||||||
|
import { ExpertSearch } from "./ExpertSearch";
|
||||||
|
|
||||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function App() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li><Link href="/" aria-current="page">Feed</Link></li>
|
<li><Link href="/" aria-current="page">Feed</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<ExpertSearch />
|
||||||
</nav>
|
</nav>
|
||||||
<div className="col-center">
|
<div className="col-center">
|
||||||
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { DiscoverCategory } from "@/lib/discover";
|
||||||
|
import { fetchDiscover } from "@/lib/discover-client";
|
||||||
|
import type { ExpertResult } from "@/lib/experts-search";
|
||||||
|
import { fetchExperts } from "@/lib/experts-search-client";
|
||||||
|
import { initials } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
export type SearchStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface ExpertSearchViewProps {
|
||||||
|
query: string;
|
||||||
|
onQuery: (q: string) => void;
|
||||||
|
categories: DiscoverCategory[];
|
||||||
|
activeCategory: string | null;
|
||||||
|
onCategory: (name: string | null) => void;
|
||||||
|
status: SearchStatus;
|
||||||
|
results: ExpertResult[];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
||||||
|
return (
|
||||||
|
<details className="expert-search" open={p.defaultOpen}>
|
||||||
|
<summary>Find experts</summary>
|
||||||
|
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<label htmlFor="expert-q">Search experts</label>
|
||||||
|
<input
|
||||||
|
id="expert-q"
|
||||||
|
type="search"
|
||||||
|
value={p.query}
|
||||||
|
placeholder="Name or topic"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(e) => p.onQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{p.categories.length > 0 ? (
|
||||||
|
<div role="group" aria-label="Browse experts by category" className="chips">
|
||||||
|
{p.categories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={String(c.id)}
|
||||||
|
type="button"
|
||||||
|
className="chip"
|
||||||
|
aria-pressed={p.activeCategory === c.name}
|
||||||
|
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div aria-live="polite" className="search-results">
|
||||||
|
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
|
||||||
|
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
|
||||||
|
{p.status === "ready" && p.results.length === 0 ? (
|
||||||
|
<p className="search-note">No experts found.</p>
|
||||||
|
) : null}
|
||||||
|
{p.status === "ready" && p.results.length > 0 ? (
|
||||||
|
<ul className="rail-list">
|
||||||
|
{p.results.map((e) => (
|
||||||
|
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
|
||||||
|
{e.pictureUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
|
||||||
|
) : (
|
||||||
|
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
|
||||||
|
)}
|
||||||
|
<div className="rail-text">
|
||||||
|
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||||
|
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||||
|
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export function ExpertSearch() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
||||||
|
const [status, setStatus] = useState<SearchStatus>("idle");
|
||||||
|
const [results, setResults] = useState<ExpertResult[]>([]);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(window.matchMedia("(min-width: 960px)").matches);
|
||||||
|
let live = true;
|
||||||
|
fetchDiscover().then(
|
||||||
|
(d) => { if (live) setCategories(d.categories); },
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
return () => { live = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
const mine = ++seq.current;
|
||||||
|
if (!q && !category) {
|
||||||
|
setStatus("idle");
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("loading");
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
fetchExperts(q ? { q } : { category: category ?? undefined }).then(
|
||||||
|
(r) => { if (seq.current === mine) { setResults(r); setStatus("ready"); } },
|
||||||
|
() => { if (seq.current === mine) { setResults([]); setStatus("error"); } },
|
||||||
|
);
|
||||||
|
}, q ? DEBOUNCE_MS : 0);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [query, category]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpertSearchView
|
||||||
|
key={open ? "open" : "closed"}
|
||||||
|
defaultOpen={open}
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
categories={categories}
|
||||||
|
activeCategory={category}
|
||||||
|
onCategory={setCategory}
|
||||||
|
status={status}
|
||||||
|
results={results}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Browser-side reader for the same-origin GET /api/experts/search route.
|
||||||
|
import type { ExpertResult } from "./experts-search";
|
||||||
|
|
||||||
|
export class ExpertSearchError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("experts_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExperts(
|
||||||
|
query: { q?: string; category?: string },
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ExpertResult[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.q) params.set("q", query.q);
|
||||||
|
else if (query.category) params.set("category", query.category);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchImpl(`/api/experts/search?${params}`, { credentials: "same-origin" });
|
||||||
|
} catch {
|
||||||
|
throw new ExpertSearchError();
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new ExpertSearchError();
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
return Array.isArray(body.experts) ? (body.experts as ExpertResult[]) : [];
|
||||||
|
} catch {
|
||||||
|
throw new ExpertSearchError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { DISCOVER_BASE_URL } from "./env";
|
||||||
|
|
||||||
|
// Expert-only search and category browse over Minnect's PUBLIC expert list
|
||||||
|
// (GET /api/v3/experts). Never touches /api/v4/users or any user search.
|
||||||
|
//
|
||||||
|
// Upstream findings (probed 2026-10-19, unauthenticated):
|
||||||
|
// - /api/v1/individual/experts/search?query= ignores `query` (returns all
|
||||||
|
// experts) and its payload carries KYC/payment/rate fields. NOT used.
|
||||||
|
// - /api/v3/experts?search=<text>&limit=N filters by name/byline and returns a
|
||||||
|
// lean public payload (id, names, slug, byline, picture, categories). Used.
|
||||||
|
// - /api/v3/experts has no category filter and ignores `page`. Category browse
|
||||||
|
// therefore filters the first CATEGORY_POOL experts of the public list by
|
||||||
|
// category name (documented limitation, cached in memory for a few minutes).
|
||||||
|
|
||||||
|
export interface ExpertCategory {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertResult {
|
||||||
|
id: string | number;
|
||||||
|
slug: string | null;
|
||||||
|
displayName: string;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
byline: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
categories: ExpertCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpertSearchResult =
|
||||||
|
| { ok: true; experts: ExpertResult[] }
|
||||||
|
| { ok: false; status: 502; error: "experts_unavailable" };
|
||||||
|
|
||||||
|
export const SEARCH_LIMIT = 20;
|
||||||
|
export const CATEGORY_POOL = 300;
|
||||||
|
const CACHE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
type Rec = Record<string, unknown>;
|
||||||
|
const rec = (v: unknown): Rec =>
|
||||||
|
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
||||||
|
const str = (v: unknown): string | null =>
|
||||||
|
typeof v === "string" && v.trim() ? v : null;
|
||||||
|
const num = (v: unknown): number | null =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
|
const id = (v: unknown): string | number | null =>
|
||||||
|
typeof v === "string" && v ? v : typeof v === "number" ? v : null;
|
||||||
|
|
||||||
|
/** Explicit allow-list: nothing outside these fields can reach the browser. */
|
||||||
|
export function mapExpertResult(raw: unknown): ExpertResult | null {
|
||||||
|
const r = rec(raw);
|
||||||
|
const eid = id(r.id);
|
||||||
|
if (eid === null) return null;
|
||||||
|
const categories: ExpertCategory[] = [];
|
||||||
|
if (Array.isArray(r.categories)) {
|
||||||
|
for (const c of r.categories) {
|
||||||
|
const cr = rec(c);
|
||||||
|
const cid = id(cr.id);
|
||||||
|
const name = str(cr.name)?.trim();
|
||||||
|
if (cid !== null && name) categories.push({ id: cid, name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: eid,
|
||||||
|
slug: str(r.slug),
|
||||||
|
displayName: [str(r.first_name), str(r.last_name)].filter(Boolean).join(" "),
|
||||||
|
pictureUrl: str(r.url_picture),
|
||||||
|
byline: str(r.byline),
|
||||||
|
rating: num(r.rating),
|
||||||
|
categories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapExpertList(body: unknown): ExpertResult[] {
|
||||||
|
const data = rec(body).data;
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
const out: ExpertResult[] = [];
|
||||||
|
for (const e of data) {
|
||||||
|
const m = mapExpertResult(e);
|
||||||
|
if (m) out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNAVAILABLE: ExpertSearchResult = {
|
||||||
|
ok: false,
|
||||||
|
status: 502,
|
||||||
|
error: "experts_unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchList(
|
||||||
|
params: Record<string, string>,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
): Promise<ExpertResult[] | null> {
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const res = await fetchImpl(`${DISCOVER_BASE_URL}/api/v3/experts?${qs}`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return mapExpertList(await res.json());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free-text expert search by name/byline. Never throws. */
|
||||||
|
export async function searchExperts(
|
||||||
|
q: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ExpertSearchResult> {
|
||||||
|
const list = await fetchList(
|
||||||
|
{ search: q, limit: String(SEARCH_LIMIT), page: "1" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
return list ? { ok: true, experts: list.slice(0, SEARCH_LIMIT) } : UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool: { at: number; experts: ExpertResult[] } | null = null;
|
||||||
|
|
||||||
|
/** Test hook: forget the cached pool. */
|
||||||
|
export function resetCategoryPool(): void {
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase();
|
||||||
|
|
||||||
|
/** Experts (from the first CATEGORY_POOL of the public list) in a category, by name. */
|
||||||
|
export async function browseCategory(
|
||||||
|
category: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): Promise<ExpertSearchResult> {
|
||||||
|
if (!pool || now - pool.at > CACHE_MS) {
|
||||||
|
const list = await fetchList({ limit: String(CATEGORY_POOL), page: "1" }, fetchImpl);
|
||||||
|
if (!list) return UNAVAILABLE;
|
||||||
|
pool = { at: now, experts: list };
|
||||||
|
}
|
||||||
|
const want = norm(category);
|
||||||
|
const experts = pool.experts
|
||||||
|
.filter((e) => e.categories.some((c) => norm(c.name) === want))
|
||||||
|
.slice(0, SEARCH_LIMIT);
|
||||||
|
return { ok: true, experts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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: [], defaultOpen: true, ...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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user