fix(workbench): share sync reads and retry failed invalidations

This commit is contained in:
2026-10-08 15:41:50 +08:00
parent 64ef058e0d
commit 7c6d06cb43
8 changed files with 274 additions and 74 deletions
@@ -6,7 +6,10 @@ import {
} from "./project-client";
describe("workbench project client", () => {
afterEach(() => vi.unstubAllGlobals());
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({
@@ -36,23 +39,20 @@ describe("workbench project client", () => {
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("does not share a caller-bound abort signal with other project consumers", async () => {
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
});
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
if (!init?.signal) {
return Promise.resolve(new Response(projectPage, { status: 200 }));
}
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
});
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();
@@ -63,8 +63,32 @@ describe("workbench project client", () => {
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 () => {