import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchWorkbenchProjectPresentation, parsePlaceSearchConfiguration, fetchWorkbenchProjects, clearWorkbenchProjectCache, findOwnedWorkbenchProject, parseProjectMapExtent } from "./project-client"; describe("workbench project client", () => { it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => { const clock = vi.spyOn(Date, "now").mockReturnValue(100); const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 })); vi.stubGlobal("fetch", fetch); const provider = async () => "owner-token"; await Promise.all([fetchWorkbenchProjects(provider), fetchWorkbenchProjects(provider)]); for (let i = 0; i < 10; i++) await fetchWorkbenchProjects(provider); expect(fetch).toHaveBeenCalledTimes(1); await fetchWorkbenchProjects(provider, undefined, true); expect(fetch).toHaveBeenCalledTimes(2); clock.mockReturnValue(300_101); await fetchWorkbenchProjects(provider); expect(fetch).toHaveBeenCalledTimes(3); clearWorkbenchProjectCache(provider); await fetchWorkbenchProjects(provider); expect(fetch).toHaveBeenCalledTimes(4); }); it("uses the explicitly selected project for each request without rediscovering the demo project", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const getAccessToken = vi.fn(async () => "token"); const project = { project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" }; const scoped = await findOwnedWorkbenchProject({ project, getAccessToken }); const other = await findOwnedWorkbenchProject({ project: { ...project, project_id: "demo" }, getAccessToken }); expect(scoped).toEqual({ project, accessToken: "token" }); expect(other.project.project_id).toBe("demo"); expect(fetchMock).not.toHaveBeenCalled(); }); it("loads all authorized project pages and excludes inactive projects", async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ items: [ { project_id: "old", gs_workspace: "old", status: "inactive" }, { project_id: "demo", gs_workspace: "tjwater_next", status: "active" } ], total: 3, limit: 1000, offset: 0 }))) .mockResolvedValueOnce(new Response(JSON.stringify({ items: [ { project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" } ], total: 3, limit: 1000, offset: 2 }))); vi.stubGlobal("fetch", fetchMock); expect((await fetchWorkbenchProjects()).map((project) => project.project_id)).toEqual(["demo", "fengyang"]); expect(String(fetchMock.mock.calls[1][0])).toContain("offset=2"); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("selects the active project owned by the configured GeoServer workspace", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ items: [ { project_id: "inactive", gs_workspace: "tjwater_next", status: "disabled" }, { project_id: "supply", gs_workspace: "tjwater_next", status: "active" } ], total: 2, limit: 1000, offset: 0 }), { status: 200 })); vi.stubGlobal("fetch", fetchMock); const result = await findOwnedWorkbenchProject(async () => "token"); expect(result.project.project_id).toBe("supply"); const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("Authorization")).toBe("Bearer token"); expect(headers.get("X-Project-Id")).toBeNull(); }); it("does not infer a project when the authorized list has no workspace match", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ items: [], total: 0, limit: 1000, offset: 0 }), { status: 200 }))); await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目"); }); it("shares project lookup while cancelling only the caller that aborts", async () => { const projectPage = JSON.stringify({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }); let resolveFetch!: (response: Response) => void; const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((resolve, reject) => { resolveFetch = resolve; init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }) ); vi.stubGlobal("fetch", fetchMock); const getAccessToken = async () => null; const controller = new AbortController(); const cancelled = findOwnedWorkbenchProject(getAccessToken, controller.signal); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); const active = findOwnedWorkbenchProject(getAccessToken); controller.abort(); await expect(cancelled).rejects.toMatchObject({ name: "AbortError" }); resolveFetch(new Response(projectPage, { status: 200 })); await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("refreshes tokens, expires project metadata and evicts failed shared lookups", async () => { const clock = vi.spyOn(Date, "now").mockReturnValue(0); const page = { items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }; const fetchMock = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ detail: "temporary" }), { status: 503 })) .mockImplementation(async () => new Response(JSON.stringify(page), { status: 200 })); vi.stubGlobal("fetch", fetchMock); let token = "first"; const provider = vi.fn(async () => token); await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("temporary"); await findOwnedWorkbenchProject(provider); token = "renewed"; expect((await findOwnedWorkbenchProject(provider)).accessToken).toBe("renewed"); expect(fetchMock).toHaveBeenCalledTimes(2); clock.mockReturnValue(5 * 60_000); await findOwnedWorkbenchProject(provider); expect(fetchMock).toHaveBeenCalledTimes(3); expect(new Headers(fetchMock.mock.calls[2][1].headers).get("Authorization")).toBe("Bearer renewed"); await findOwnedWorkbenchProject(async () => "another-identity"); expect(fetchMock).toHaveBeenCalledTimes(4); }); it("loads the authoritative project extent and access identity independently", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = new URL(String(input)).pathname; if (path === "/api/v1/projects") { return new Response(JSON.stringify({ items: [{ project_id: "supply", name: "北区供水项目", code: "north-supply", gs_workspace: "tjwater_next", status: "active", project_role: "member" }], total: 1, limit: 1000, offset: 0 }), { status: 200 }); } const headers = new Headers(init?.headers); expect(headers.get("Authorization")).toBe("Bearer token"); expect(headers.get("X-Project-Id")).toBe("supply"); if (path === "/api/v1/projects/current/metadata") { return new Response(JSON.stringify({ project_id: "supply", name: "北区供水项目", code: "north-supply", gs_workspace: "tjwater_next", map_extent: { bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23], zoom: 13 }, status: "active", project_role: "member" }), { status: 200 }); } return new Response(JSON.stringify({ user_id: "user-1", username: "north.operator", system_role: "user", is_system_admin: false, project_id: "supply", project_role: "member", permissions: ["webgis.view"] }), { status: 200 }); }); vi.stubGlobal("fetch", fetchMock); const presentation = await fetchWorkbenchProjectPresentation(async () => "token"); expect(presentation).toMatchObject({ projectId: "supply", projectName: "北区供水项目", username: "north.operator", projectRole: "member", metadataAvailable: true, accessContextAvailable: true, networkView: { bbox3857: [13358338.895, 3503549.844, 13369470.85, 3516410.23] } }); }); it("rejects malformed or reversed project extents without losing the fallback path", () => { expect(parseProjectMapExtent({ bbox: [10, 20, 5, 30] })).toBeNull(); expect(parseProjectMapExtent({ bbox: [0, 0, 30_000_000, 1] })).toBeNull(); expect(parseProjectMapExtent({ xmin: 1, ymin: 2, xmax: 3, ymax: 4 })).toEqual({ bbox3857: [1, 2, 3, 4] }); }); }); describe("project place search configuration", () => { it("reads configured regions and preserves unrelated map configuration", () => { const config = { enabled: true, provider: "tianditu", admin_code: "341126", region: "凤阳县" }; expect(parsePlaceSearchConfiguration({ legend: "existing", place_search: config })).toEqual(config); }); it.each([undefined, {}, { place_search: null }, { place_search: { enabled: false } }, { place_search: { enabled: true, provider: "tianditu", admin_code: "invalid", region: "凤阳县" } } ])("disables absent or invalid configuration: %j", (config) => { expect(parsePlaceSearchConfiguration(config)).toBeNull(); }); });