220 lines
9.6 KiB
TypeScript
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();
|
|
});
|
|
});
|