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(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(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(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(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(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(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(async (config) => ({ data: { sequence: (sequence += 1) }, status: 200, statusText: "OK", headers: {}, config: config as InternalAxiosRequestConfig, })); const postAdapter = vi.fn(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((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); }); });