Files
ctms/frontend/src/api/axios.test.ts
T
Cheng Zhou 6b55c18610
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
refactor(frontend): 按需加载页面并清理未使用代码
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
2026-07-16 14:58:47 +08:00

229 lines
8.0 KiB
TypeScript

import type { AxiosAdapter, AxiosError, InternalAxiosRequestConfig } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const errorMessage = vi.fn();
vi.mock("element-plus", () => ({
ElMessage: {
error: errorMessage,
},
}));
vi.mock("../utils/auth", () => ({
getToken: vi.fn(() => undefined),
}));
describe("api axios retry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
errorMessage.mockClear();
});
afterEach(async () => {
const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios");
setApiResponseCacheScope(null);
clearApiResponseCache();
vi.useRealTimers();
});
it("retries network failures 10 times at 30 second intervals before showing one error", async () => {
const api = (await import("./axios")).default;
const adapter = vi.fn<AxiosAdapter>(async (config) => {
const error = new Error("Network Error") as AxiosError;
error.config = config as InternalAxiosRequestConfig;
error.isAxiosError = true;
return Promise.reject(error);
});
const request = api.get("/api/v1/projects", { adapter }).catch((error) => error);
await vi.dynamicImportSettled();
expect(adapter).toHaveBeenCalledTimes(1);
expect(errorMessage).not.toHaveBeenCalled();
for (let retry = 1; retry < 10; retry += 1) {
await vi.advanceTimersByTimeAsync(30000);
expect(adapter).toHaveBeenCalledTimes(retry + 1);
expect(errorMessage).not.toHaveBeenCalled();
}
await vi.advanceTimersByTimeAsync(30000);
expect(adapter).toHaveBeenCalledTimes(11);
await expect(request).resolves.toMatchObject({ message: "Network Error" });
expect(errorMessage).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent apiGet calls for the same request", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [first, second] = await Promise.all([
apiGet("/api/v1/studies/", { adapter }),
apiGet("/api/v1/studies/", { adapter }),
]);
expect(adapter).toHaveBeenCalledTimes(1);
expect(first.data).toEqual({ ok: true });
expect(second.data).toEqual({ ok: true });
});
it("does not deduplicate requests when disabled by the caller", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await Promise.all([
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
});
it("does not attach the CTMS login token to public share requests", async () => {
const auth = await import("../utils/auth");
vi.mocked(auth.getToken).mockReturnValue("private-login-token");
const api = (await import("./axios")).default;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await api.get("/api/v1/collaboration/shares/metadata", {
adapter,
publicRequest: true,
headers: { "X-CTMS-Share-Token": "share-token" },
} as any);
const config = adapter.mock.calls[0]?.[0] as InternalAxiosRequestConfig;
expect(config.headers.Authorization).toBeUndefined();
expect(config.headers["X-CTMS-Share-Token"]).toBe("share-token");
vi.mocked(auth.getToken).mockReturnValue(null);
});
it("keeps request headers in the dedupe key when they can affect the response", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { accept: (config.headers as any)?.Accept },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [json, csv] = await Promise.all([
apiGet("/api/v1/reports", { adapter, headers: { Accept: "application/json" } }),
apiGet("/api/v1/reports", { adapter, headers: { Accept: "text/csv" } }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
expect(json.data).toEqual({ accept: "application/json" });
expect(csv.data).toEqual({ accept: "text/csv" });
});
it("serves configured apiGet calls from memory cache until ttl expires", async () => {
const { apiGet } = await import("./axios");
let sequence = 0;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: { etag: "v1" },
config: config as InternalAxiosRequestConfig,
}));
const first = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
const second = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(first.data).toEqual({ sequence: 1 });
expect(second.data).toEqual({ sequence: 1 });
expect(adapter).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
const third = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(third.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
it("clears cached GET responses after successful mutations", async () => {
const { apiGet, apiPost } = await import("./axios");
let sequence = 0;
const getAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const postAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { id: "study-1" },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(getAdapter).toHaveBeenCalledTimes(1);
await apiPost("/api/v1/studies/", { name: "Study" }, { adapter: postAdapter });
const afterMutation = await apiGet("/api/v1/studies/", {
adapter: getAdapter,
cache: { ttlMs: 1000, tags: ["studies"] },
});
expect(afterMutation.data).toEqual({ sequence: 2 });
expect(getAdapter).toHaveBeenCalledTimes(2);
expect(postAdapter).toHaveBeenCalledTimes(1);
});
it("does not write an in-flight GET response into cache after the cache generation changes", async () => {
const { apiGet, clearApiResponseCache } = await import("./axios");
let sequence = 0;
let resolveFirst: (() => void) | undefined;
const adapter = vi.fn<AxiosAdapter>((config) => {
sequence += 1;
const response = {
data: { sequence },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
};
if (sequence === 1) {
return new Promise((resolve) => {
resolveFirst = () => resolve(response);
});
}
return Promise.resolve(response);
});
const firstRequest = apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await vi.dynamicImportSettled();
expect(adapter).toHaveBeenCalledTimes(1);
clearApiResponseCache();
resolveFirst?.();
await expect(firstRequest).resolves.toMatchObject({ data: { sequence: 1 } });
const afterClear = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(afterClear.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
});