6b55c18610
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。 - 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。 - 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。 - 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。 - 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。 - 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。 - 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。 - 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。 - 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。 - 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。 - 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
229 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|