功能(桌面端):增加在线辅助本地缓存
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-08 20:45:08 +08:00
parent b73f23c1eb
commit 76f2d9f22a
20 changed files with 926 additions and 50 deletions
+153 -1
View File
@@ -34,10 +34,14 @@ vi.mock("../session/sessionManager", () => ({
describe("api axios retry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
errorMessage.mockClear();
});
afterEach(() => {
afterEach(async () => {
const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios");
setApiResponseCacheScope(null);
clearApiResponseCache();
vi.useRealTimers();
});
@@ -67,4 +71,152 @@ describe("api axios retry", () => {
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("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);
});
});