Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ describe("createHttpClient edge cases", () => {
expect(onAuthFailure).not.toHaveBeenCalled();
});

it("refresh 成功响应缺少 accessToken 时会触发鉴权失败且不重试原请求", async () => {
it("refresh 成功响应缺少 accessToken 时默认不鉴权失败(业务 Error 需自定义 isRefreshFailure)", async () => {
const tokenStore = createTokenStore("old-access", "old-refresh");
const onAuthFailure = vi.fn(async () => {});
const onError = vi.fn();
const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore));

const http = createHttpClient({
Expand All @@ -144,6 +145,7 @@ describe("createHttpClient edge cases", () => {
},
getAccessToken: tokenStore.getAccessToken,
onAuthFailure,
onError,
refreshAccessToken,
});

Expand All @@ -156,12 +158,13 @@ describe("createHttpClient edge cases", () => {
});

await expect(http.get("/profile")).rejects.toMatchObject({
message: "refreshToken 已失效,登录过期",
message: "missing accessToken",
});

expect(refreshAccessToken).toHaveBeenCalledTimes(1);
expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1);
expect(onAuthFailure).toHaveBeenCalledTimes(1);
expect(onAuthFailure).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
expect(tokenStore.setAccessToken).not.toHaveBeenCalled();
expect(tokenStore.setRefreshToken).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -253,7 +256,7 @@ describe("createHttpClient edge cases", () => {

await expect(http.get("/profile")).rejects.toMatchObject({
name: "Error",
message: "登录已失效,请重新登录",
message: "Login session has expired",
});

expect(refreshAccessToken).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -380,15 +383,15 @@ describe("createHttpClient edge cases", () => {
]);
});

it("refresh 错误命中 authFailureCodes 时会视为登录过期", async () => {
it("refresh 错误命中 refreshFailureCodes 时会视为登录过期", async () => {
const tokenStore = createTokenStore("old-access", "old-refresh");
const onAuthFailure = vi.fn(async () => {});

const http = createHttpClient({
axiosConfig: {
baseURL: "/api",
},
authFailureCodes: [1001002],
refreshFailureCodes: [1001002],
getAccessToken: tokenStore.getAccessToken,
onAuthFailure,
refreshAccessToken: createRefreshAccessToken(tokenStore),
Expand All @@ -403,13 +406,43 @@ describe("createHttpClient edge cases", () => {

await expect(http.get("/profile")).rejects.toMatchObject({
name: "Error",
message: "refreshToken 已失效,登录过期",
message: "Refresh token is invalid or expired",
});

expect(onAuthFailure).toHaveBeenCalledTimes(1);
});

it("refresh 抛出非 Error 异常时会触发鉴权失败(归一化后作为 refreshToken 失效处理)", async () => {
it("refresh 抛出非 AxiosError 时默认不触发 onAuthFailure", async () => {
const onAuthFailure = vi.fn(async () => {});
const onError = vi.fn();

const http = createHttpClient({
axiosConfig: {
baseURL: "/api",
},
getAccessToken: async () => "old-access",
onAuthFailure,
onError,
refreshAccessToken: async () => {
throw new TypeError("refresh broken");
},
});

queueAxiosError({ status: 401, data: { message: "unauthorized" } });

await expect(http.get("/profile")).rejects.toMatchObject({
message: "refresh broken",
});

expect(onAuthFailure).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: "refresh broken" }),
{ type: "refresh" },
);
});

it("自定义 isRefreshFailure 仍可把非 AxiosError 判为鉴权失败", async () => {
const onAuthFailure = vi.fn(async () => {});

const http = createHttpClient({
Expand All @@ -418,17 +451,98 @@ describe("createHttpClient edge cases", () => {
},
getAccessToken: async () => "old-access",
onAuthFailure,
isRefreshFailure: () => true,
refreshAccessToken: async () => {
throw { reason: "boom" };
throw new Error("business refresh failed");
},
});

queueAxiosError({ status: 401, data: { message: "unauthorized" } });

await expect(http.get("/profile")).rejects.toMatchObject({
message: "Refresh token is invalid or expired",
});

expect(onAuthFailure).toHaveBeenCalledTimes(1);
});

it("refresh 返回空 token 时触发鉴权失败且不进入冷却", async () => {
const onAuthFailure = vi.fn(async () => {});
const refreshAccessToken = vi
.fn()
.mockResolvedValueOnce("")
.mockResolvedValueOnce("new-access");

const http = createHttpClient({
axiosConfig: {
baseURL: "/api",
},
getAccessToken: async () => "old-access",
onAuthFailure,
refreshAccessToken,
refreshCooldownMs: 15000,
});

queueAxiosError({ status: 401, data: { message: "unauthorized" } });

await expect(http.get("/profile")).rejects.toMatchObject({
message: "refreshToken 已失效,登录过期",
message: "Refresh token is invalid or expired",
});
expect(onAuthFailure).toHaveBeenCalledTimes(1);
expect(refreshAccessToken).toHaveBeenCalledTimes(1);

// 空 token 刷新失败不应进入冷却,下一次 401 仍可再次 refresh
queueAxiosError({ status: 401, data: { message: "unauthorized" } });
queueCustomHandler(async (config) => ({
status: 200,
data: { ok: true, auth: config.headers?.Authorization },
config,
}));

const response = await http.get("/profile");
expect(response.data).toEqual({ ok: true, auth: "Bearer new-access" });
expect(refreshAccessToken).toHaveBeenCalledTimes(2);
});

it("冷却期内 getAccessToken 为空时不重试原请求,直接鉴权失败", async () => {
const tokenStore = createTokenStore("old-access", "old-refresh");
const onAuthFailure = vi.fn(async () => {});
const refreshAccessToken = vi.fn(async () => {
tokenStore.setAccessToken("new-access");
tokenStore.setRefreshToken("new-refresh");
return "new-access";
});

const http = createHttpClient({
axiosConfig: {
baseURL: "/api",
},
getAccessToken: tokenStore.getAccessToken,
onAuthFailure,
refreshAccessToken,
refreshCooldownMs: 15000,
});

queueAxiosError({ status: 401, data: { message: "unauthorized" } });
queueCustomHandler(async (config) => ({
status: 200,
data: { ok: true },
config,
}));

await http.get("/profile");
expect(refreshAccessToken).toHaveBeenCalledTimes(1);

// 模拟 logout:清空 token,但仍处于冷却期
tokenStore.clearAuth();

queueAxiosError({ status: 401, data: { message: "unauthorized" } });

await expect(http.get("/me")).rejects.toMatchObject({
message: "Login session has expired",
});

expect(refreshAccessToken).toHaveBeenCalledTimes(1); // 冷却内未再次 refresh
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(onAuthFailure).toHaveBeenCalledTimes(1);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { AxiosError } from "axios";
import type { InternalAxiosRequestConfig } from "axios";
import { AxiosError, AxiosHeaders } from "axios";
import { shouldSkipRefresh, defaultIsRefreshFailure } from "../utils/refresh";
import type { RequestRetryState } from "../types/common";

vi.mock("axios", async () => {
const actual = await vi.importActual<typeof import("axios")>("axios");
Expand All @@ -14,6 +16,15 @@ vi.mock("axios", async () => {
};
});

const createRequestConfig = (
config: Partial<InternalAxiosRequestConfig & RequestRetryState> = {},
): InternalAxiosRequestConfig & RequestRetryState => {
return {
headers: new AxiosHeaders(),
...config,
} as InternalAxiosRequestConfig & RequestRetryState;
};

const makeAxiosError = (
options: { status?: number; code?: number; response?: boolean } = {},
): AxiosError => {
Expand All @@ -24,7 +35,7 @@ const makeAxiosError = (
data: options.code !== undefined ? { code: options.code } : {},
headers: {},
statusText: "Error",
config: {} as any,
config: createRequestConfig(),
};
}
return error;
Expand All @@ -36,33 +47,65 @@ describe("shouldSkipRefresh", () => {
});

it("config.url 为 undefined → false", () => {
expect(shouldSkipRefresh(["/auth"], {} as any)).toBe(false);
expect(shouldSkipRefresh(["/auth"], createRequestConfig())).toBe(false);
});

it("URL exact 命中 skipRefreshUrl → true", () => {
expect(
shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/auth/login" })),
).toBe(true);
});

it("URL 包含 skipRefreshUrl → true", () => {
it("URL 以 prefix path 命中 skipRefreshUrl → true", () => {
expect(
shouldSkipRefresh(["/auth/login"], { url: "/api/auth/login" } as any),
shouldSkipRefresh(["/public"], createRequestConfig({ url: "/public/data" })),
).toBe(true);
expect(
shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/auth/login" })),
).toBe(true);
});

it("中间段/子串路径不会跳过刷新", () => {
expect(
shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/user/auth-history" })),
).toBe(false);
expect(
shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/authorization" })),
).toBe(false);
expect(
shouldSkipRefresh(
["/auth"],
createRequestConfig({ url: "/gateway/user/auth/session" }),
),
).toBe(false);
expect(
shouldSkipRefresh(
["/auth/login"],
createRequestConfig({ url: "/api/auth/login" }),
),
).toBe(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("URL 不包含任何 skipRefreshUrl → false", () => {
it("URL 不匹配任何 skipRefreshUrl → false", () => {
expect(
shouldSkipRefresh(["/auth/login"], { url: "/api/profile" } as any),
shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/api/profile" })),
).toBe(false);
});

it("skipRefreshUrls 为空数组 → 始终 false", () => {
expect(
shouldSkipRefresh([], { url: "/auth/login" } as any),
shouldSkipRefresh([], createRequestConfig({ url: "/auth/login" })),
).toBe(false);
});
});

describe("defaultIsRefreshFailure", () => {
const baseOptions = { unauthorizedStatusCode: 401, authFailureCodes: [1001002] };
const baseOptions = { unauthorizedStatusCode: 401, refreshFailureCodes: [1001002] };

it("非 AxiosError → true(业务错误视为鉴权失败)", () => {
expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(true);
it("非 AxiosError → false(编程/业务 Error 默认不视为鉴权失败)", () => {
expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(false);
expect(defaultIsRefreshFailure(new TypeError("boom"), baseOptions)).toBe(false);
expect(defaultIsRefreshFailure({ reason: "boom" }, baseOptions)).toBe(false);
});

it("AxiosError 无 response → false", () => {
Expand All @@ -81,13 +124,13 @@ describe("defaultIsRefreshFailure", () => {
expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), baseOptions)).toBe(true);
});

it("data.code 在 authFailureCodes 中 → true", () => {
it("data.code 在 refreshFailureCodes 中 → true", () => {
expect(
defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 1001002 }), baseOptions),
).toBe(true);
});

it("data.code 不在 authFailureCodes 中 → false", () => {
it("data.code 不在 refreshFailureCodes 中 → false", () => {
expect(
defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 999999 }), baseOptions),
).toBe(false);
Expand All @@ -100,7 +143,7 @@ describe("defaultIsRefreshFailure", () => {
});

it("自定义 unauthorizedStatusCode 生效", () => {
const options = { unauthorizedStatusCode: 498, authFailureCodes: [] };
const options = { unauthorizedStatusCode: 498, refreshFailureCodes: [] };
expect(defaultIsRefreshFailure(makeAxiosError({ status: 498 }), options)).toBe(true);
expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), options)).toBe(false);
});
Expand Down
Loading