import { afterEach, describe, expect, it, vi } from "vitest"; import { searchPlaces } from "./place-search-client"; const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" }; const results = { items: [item], total: 1, truncated: false, region: "奉贤区" }; afterEach(() => vi.restoreAllMocks()); describe("Fengxian place search client", () => { it("uses the new endpoint with trimmed keywords, identity and project headers", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results)); expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("http://127.0.0.1:8000/api/v1/place-search-requests"); expect(init?.method).toBe("POST"); expect(JSON.parse(init?.body as string)).toEqual({ keyword: "南桥路" }); const headers = new Headers(init?.headers); expect(headers.get("Authorization")).toBe("Bearer read-token"); expect(headers.get("X-Project-Id")).toBe("project-1"); expect(headers.get("Content-Type")).toBe("application/json"); }); it("accepts empty search results", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 })); expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]); }); it("accepts at most ten Fengxian candidates", async () => { const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true }; vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped)); expect(await searchPlaces("南桥路", "project-1")).toEqual(capped); }); it.each([ { ...results, items: [{ ...item, longitude: 181 }] }, { ...results, items: [{ ...item, latitude: 91 }] }, { ...results, items: [item, item] }, { ...results, region: "北京市" }, { ...results, items: [{ ...item, district: "徐汇区" }] }, { ...results, total: 0 }, { ...results, total: 11 }, { ...results, items: Array.from({ length: 11 }, (_, index) => ({ ...item, id: `place-${index}` })) }, { status: "0", location: { lon: "121", lat: "31" } } ])("rejects malformed, mismatched or legacy responses: %j", async (payload) => { vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload)); await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果"); }); it("does not fetch blank keywords", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); await expect(searchPlaces(" ", "project-1")).rejects.toThrow("请输入"); expect(fetchMock).not.toHaveBeenCalled(); }); it.each([403, 502, 503, 504])("preserves server failures for HTTP %s", async (status) => { vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "查询暂不可用" }, { status })); await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("查询暂不可用"); }); it("does not fetch after cancellation during token refresh", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); const controller = new AbortController(); await expect(searchPlaces("南桥路", "project-1", async () => { controller.abort(); return "read-token"; }, controller.signal)).rejects.toThrow(); expect(fetchMock).not.toHaveBeenCalled(); }); });