Files
next-tjwater-frontend/src/features/workbench/api/project-client.test.ts
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

220 lines
9.6 KiB
TypeScript

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<Response>((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();
});
});