Appearance
业务模块与端到端
本页对应学习路径的阶段八,然后把前面所有阶段接成三条端到端路径,最后给出练习和 验证资料。
阶段八:业务模块与页面
场景
页面要「创建用户」,用户名重复时在表单里内联提示。
先写最直觉的那版
ts
// 幼稚版:页面直接调
try {
await http.post("/users", { name });
} catch (e) {
if (e.status === 409) setFieldError("name", "用户名已存在");
}它塌在哪
页面被迫知道两件它不该知道的事:接口 URL 和 HTTP 状态码的业务含义。 接口改路径要全局搜索;同一个 409 在三个页面各翻译一遍,措辞还不一致。
现在的写法
业务模块夹在中间,把传输概念翻译成领域概念:
ts
export async function createUser(input: CreateUserInput): Promise<User> {
try {
// silent 关掉全局 Toast,因为 409 要显示在表单字段旁边。
// 注意它只关展示,不关上报——监控里照样看得到。
return await http.post<User, CreateUserInput>("/users", input, {
errorMode: "silent",
});
} catch (error) {
// 只翻译自己认识的那一个状态码,其余原样抛出去。
if (error instanceof HttpError && error.status === 409) {
throw new UserAlreadyExistsError(error); // 原错误挂在 cause 上
}
throw error;
}
}页面只 catch UserAlreadyExistsError,完全不需要知道 HTTP 是什么。
最容易写错的是把 catch 写宽——网络断了也提示用户换个名字。只翻译认识的,其余 原样往上抛。
职责划分
| 层 | 负责 | 不负责 |
|---|---|---|
| 页面 | 领域概念、UI 反馈 | URL、HTTP 方法、状态码 |
| 业务模块 | URL、方法、类型、silent/retry 策略、领域错误 | 传输细节、认证 |
| HTTP 核心 | 传输、协议、错误分类、认证、生命周期 | 用户文案、业务语义 |
端到端:三条路径
前面每个阶段各看一段,这里把它们接起来。
普通成功
text
页面 createUser() → users.ts → post() 补齐 method/url/data
→ request() 指定选 response.data → execute() 建立逻辑请求、开 Loading
→ 请求链 Auth(附令牌) → RequestControl(计数+1) → 网络
→ 响应链 RequestControl(清理) → Envelope(解包成 User) → Auth(非 401 放行)
→ select(response) 取出 User → finally 关 Loading → 页面得到 UserGET 撞上 503 后重试成功
text
send() 第一次 → 503 → 归一化 HttpError(http, 503)
→ retry() 判定可重试 → 检查总预算 → 等待带抖动的退避
→ send() 第二次 → 200 → Envelope 解包 → 返回结果
整个过程 Loading 只开关一次,不触发任何错误回调401 刷新后重放
text
业务请求带旧令牌 → 401 → Auth 响应拦截器启动或等待共享刷新
→ 刷新实例(独立、withCredentials)拿到新 Access Token → credentialVersion +1
→ 原请求标记 __authRetry → 重新灌回实例,走完整拦截器链(这时换上新令牌)
→ 200 → Envelope 解包 → execute() 返回结果
逻辑 Loading 只开关一次,物理尝试计数为 2练习
每次只加一个能力,并为它写一个能跑的验证用例。 不要一次复制完整目录。
- 用原生 Axios 取一个用户,说清
response和response.data的区别 - 写
ApiEnvelope的格式校验函数,处理data: null和缺字段两种情况 - 写只有
get<Result>的最小入口 - 加
post<Result, Body>,验证请求体类型约束生效 - 加
raw(),比较两者返回值 - 把网络、超时、HTTP 错误统一成一个错误类型
- 接一个 Presenter,让同一个错误在两种语言下显示不同文案
- 加单请求取消,验证取消后不展示也不上报
- 加 Loading 计数,验证并发三个请求只开关一次
- 实现单飞刷新与重放,验证 10 个并发 401 只打一次刷新接口
第 10 项建议留到最后,它是唯一需要同时处理并发、代际和重入的练习。
验证资料
| 文件 | 覆盖 |
|---|---|
test/http-client.test.ts | 请求流程、认证、取消、Loading、重试 |
test/protocol-and-utilities.test.ts | 协议解包、错误分类、工具函数 |
test/auth-session-isolation.test.ts | 会话代际与链路隔离 |
test/failure-budgets.test.ts | 刷新冷却窗口、重试总预算 |
test/session-sync.test.ts | 跨标签页会话同步与事件屏障 |
test/users-module.test.ts | 业务领域错误转换 |
test/typecheck.ts | 配置白名单的类型约束 |
browser-tests/http-browser.spec.ts | 必须在真实浏览器验证的行为 |
bash
cd docs/projects/axios-http
pnpm install
pnpm check遇到读不懂的局部代码,先确定它属于哪个阶段,再单独分析。不要让一个底层类型声明 打断整条请求主线。
本页源码
构建时从 docs/projects/axios-http/ 的真实文件直读,和测试跑的是同一份。装配入口和 业务模块:
ts
/**
* HTTP 模块的对外门面:整个应用只从这里拿 `http` 实例和相关类型,不直接 import
* ./client、./auth 这些内部文件。
*
* 想读懂实现,入口是 client.ts 的文件头——那里画了一次请求的完整路径。
*/
import { createHttpClient } from "./client";
// 类型和实例共用一个入口,调用方不需要记住哪个东西在哪个文件里。
// 类型导出没有运行时存在,所以本模块真正的运行时导出仍然只有 http 一个。
export type {
CreateHttpClientOptions,
ErrorBehavior,
ErrorMode,
HttpClient,
HttpRequestConfig,
HttpRetryOptions,
LoadingBehavior,
RetryBehavior,
} from "./client";
export type { AuthAdapter, AuthBehavior } from "./auth";
// 下面这段是接入示例,不是最终形态。实际项目里 baseURL 换成
// import.meta.env.VITE_API_BASE_URL,并把注释掉的四个回调按需接上 UI 和监控。
//
// onError 里的 presentApiError 是这套设计的分工体现:通用核心只产出稳定的错误分类,
// 一句用户文案都不写;文案全部由 adapters/error-presenter.ts 这个项目适配器决定。
export const http = createHttpClient({
baseURL: "/api",
timeout: 10_000,
// 在实际项目入口中接入 UI 和监控:
// showLoadingByDefault: false,
// onLoadingChange: (active) => (active ? spin.show() : spin.hide()),
// onError: (error) => message.error(presentApiError(error)),
// onReport: (error) => reportHttpError({
// name: error.name,
// kind: "kind" in error ? error.kind : "protocol",
// status: error.status,
// method: error.method,
// path: error.path,
// attempts: error.attempts,
// elapsedMs: error.elapsedMs,
// origin: error.origin,
// originMethod: error.originMethod,
// originPath: error.originPath,
// }),
});ts
/**
* 业务模块示例。它存在的目的是演示「调用方该怎么用这套封装」,尤其是下面 createUser
* 里那个 409 的处理——那是本封装最想表达的一种用法。
*
* 要点:传输层的错误分类(HttpError + status)不适合直接交给页面。页面关心的是
* 「这个用户名被占了」,不是「HTTP 409」。所以业务模块把它翻译成领域错误,页面
* 只 catch UserAlreadyExistsError,完全不需要知道 HTTP 是什么。
*/
import { http } from "../http";
import { HttpError } from "../http/errors";
export interface CreateUserInput {
name: string;
}
export interface User {
id: string;
name: string;
}
export class UserAlreadyExistsError extends Error {
constructor(cause: HttpError) {
super("User already exists", { cause });
this.name = "UserAlreadyExistsError";
}
}
export async function createUser(input: CreateUserInput): Promise<User> {
try {
// errorMode: "silent" 关掉全局 Toast,因为 409 要显示在表单的用户名字段旁边,
// 而不是飘一个全局提示。注意它只关展示,不关上报——监控里照样看得到。
return await http.post<User, CreateUserInput>("/users", input, {
errorMode: "silent",
});
} catch (error) {
// 只翻译自己认识的那一个状态码,其余原样抛出去。这一点很容易写错成 catch 住
// 所有错误都当成「已存在」,那样网络断了也会提示用户换个名字。
if (error instanceof HttpError && error.status === 409) {
// 原错误挂在 cause 上,排查时还能顺着找回 HTTP 层的现场。
throw new UserAlreadyExistsError(error);
}
throw error;
}
}测试与配置——上面验证资料表格里的全部文件:
ts
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";
import axios, { AxiosHeaders } from "axios";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type AuthAdapter } from "../src/api/http/auth";
import {
createMemoryAuthSession,
type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { presentApiError } from "../src/api/http/adapters/error-presenter";
import { createHttpClient } from "../src/api/http/client";
import {
ApiEnvelopeFormatError,
HttpError,
} from "../src/api/http/errors";
import { fetchFile } from "../src/api/http/transfer";
type TestHandler = (
request: IncomingMessage,
response: ServerResponse,
) => void | Promise<void>;
const openServers = new Set<Server>();
function sendJson(response: ServerResponse, status: number, body?: unknown) {
response.statusCode = status;
if (body === undefined) {
response.end();
return;
}
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(body));
}
function delay(milliseconds: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function readBody(request: IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
async function startServer(handler: TestHandler) {
const server = createServer((request, response) => {
Promise.resolve(handler(request, response)).catch((error: unknown) => {
sendJson(response, 500, {
message: error instanceof Error ? error.message : "test server failed",
});
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
openServers.add(server);
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
function createTestAuth(baseURL: string, session: AuthSession) {
return createBearerAuthAdapter({
baseURL,
getAccessToken: session.getAccessToken,
setAccessToken: session.setAccessToken,
expireSession() {
session.clearSession();
session.onExpired();
},
selectAccessToken(response) {
const envelope = readApiEnvelope(response.data);
const result = envelope?.data;
if (
!envelope?.hasData ||
!result ||
typeof result !== "object" ||
!("accessToken" in result) ||
typeof result.accessToken !== "string"
) {
throw new ApiEnvelopeFormatError(response.status, response.data);
}
return result.accessToken;
},
});
}
afterEach(async () => {
await Promise.all(
[...openServers].map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
server.closeAllConnections();
}),
),
);
openServers.clear();
});
describe("core HTTP Interface", () => {
it("unwraps successful envelopes and preserves request-body types", async () => {
const baseURL = await startServer(async (request, response) => {
const input = JSON.parse(await readBody(request)) as { name: string };
sendJson(response, 200, {
code: 0,
message: "ok",
data: { id: 1, name: input.name },
});
});
const http = createHttpClient({ baseURL });
await expect(
http.post<{ id: number; name: string }, { name: string }>("/users", {
name: "Ada",
}),
).resolves.toEqual({ id: 1, name: "Ada" });
});
it("uses the HTTP status for success and treats envelope code as metadata", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 200, {
code: 40001,
message: "metadata only",
data: { id: 1 },
});
});
const http = createHttpClient({ baseURL });
await expect(http.get("/users/1")).resolves.toEqual({ id: 1 });
});
it("requires code, message, and data in a successful envelope", async () => {
const baseURL = await startServer((request, response) => {
const valid = {
code: 0,
message: "ok",
data: true,
};
const missingField = request.url?.slice(9) as
| "code"
| "message"
| "data";
const body = { ...valid } as Partial<typeof valid>;
delete body[missingField];
sendJson(response, 200, body);
});
const http = createHttpClient({ baseURL });
for (const field of ["code", "message", "data"]) {
await expect(http.get(`/missing-${field}`)).rejects.toBeInstanceOf(
ApiEnvelopeFormatError,
);
}
});
it("accepts null data and bypasses the envelope for 204 responses", async () => {
const baseURL = await startServer((request, response) => {
if (request.url === "/empty") {
sendJson(response, 204);
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: null,
});
});
const http = createHttpClient({ baseURL });
await expect(http.get("/nullable")).resolves.toBeNull();
await expect(http.delete<void>("/empty")).resolves.toBeUndefined();
});
it("does not retry a format error and reports it once", async () => {
let requestCount = 0;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
sendJson(response, 200, {
code: 0,
message: "missing data",
});
});
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
await expect(
http.get("/broken", {
retry: { retries: 2, baseDelayMs: 1 },
}),
).rejects.toBeInstanceOf(ApiEnvelopeFormatError);
expect(requestCount).toBe(1);
expect(onError).toHaveBeenCalledTimes(1);
expect(onReport).toHaveBeenCalledTimes(1);
expect(onReport.mock.calls[0]?.[0]).toMatchObject({
method: "GET",
path: "/broken",
attempts: 1,
});
});
it("keeps raw response data and headers when the caller opts out of the protocol", async () => {
const baseURL = await startServer((_request, response) => {
response.statusCode = 200;
response.setHeader("x-report-version", "7");
response.end("raw report");
});
const http = createHttpClient({ baseURL });
const response = await http.raw<string>({
method: "get",
url: "/report",
});
expect(response.data).toBe("raw report");
expect(response.headers["x-report-version"]).toBe("7");
});
it("reports global errors once and leaves silent errors to the caller", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 500, {
code: 50001,
message: "temporary failure",
data: null,
});
});
const onError = vi.fn();
const http = createHttpClient({ baseURL, onError });
await expect(http.get("/global")).rejects.toBeInstanceOf(HttpError);
await expect(
http.get("/silent", { errorMode: "silent" }),
).rejects.toBeInstanceOf(HttpError);
expect(onError).toHaveBeenCalledTimes(1);
});
it("keeps project protocol metadata out of generic HTTP errors", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 409, {
code: 40001,
message: "用户名已存在",
data: null,
});
});
const http = createHttpClient({ baseURL });
const error = await http.get("/users").catch((reason: unknown) => reason);
expect(error).toBeInstanceOf(HttpError);
expect(error).toMatchObject({
kind: "http",
status: 409,
message: "HTTP request failed with status 409",
});
expect(presentApiError(error as HttpError)).toBe("用户名已存在");
expect(error).not.toHaveProperty("businessCode");
expect(error).not.toHaveProperty("details");
});
it("rejects absolute and protocol-relative business URLs before networking", async () => {
let requestCount = 0;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
const absoluteError = await http
.get(`${baseURL}/absolute`, { errorMode: "silent" })
.catch((error: unknown) => error);
expect(absoluteError).toMatchObject({ kind: "configuration" });
expect(absoluteError).toHaveProperty("cause");
await expect(
http.get("//example.test/protocol-relative", { errorMode: "silent" }),
).rejects.toMatchObject({ kind: "configuration" });
expect(requestCount).toBe(0);
expect(onError).not.toHaveBeenCalled();
expect(onReport).toHaveBeenCalledTimes(2);
expect(onReport.mock.calls.map(([error]) => error)).toEqual([
expect.objectContaining({ path: "/absolute", attempts: 0 }),
expect.objectContaining({ path: "/protocol-relative", attempts: 0 }),
]);
});
});
describe("request control and logical loading", () => {
it("retries an explicitly enabled safe read after a temporary failure", async () => {
let requestCount = 0;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
if (requestCount < 3) {
sendJson(response, 503, {
code: 50300,
message: "busy",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: "ready",
});
});
const http = createHttpClient({ baseURL });
await expect(
http.get<string>("/report", {
retry: { retries: 2, baseDelayMs: 1 },
}),
).resolves.toBe("ready");
expect(requestCount).toBe(3);
});
it("keeps one loading interval open for concurrent logical requests", async () => {
let requestCount = 0;
const baseURL = await startServer(async (_request, response) => {
requestCount += 1;
await delay(requestCount === 1 ? 60 : 10);
sendJson(response, 200, {
code: 0,
message: "ok",
data: requestCount,
});
});
const events: string[] = [];
const http = createHttpClient({
baseURL,
onLoadingChange: (active) => events.push(active ? "open" : "close"),
});
const first = http.get("/slow", { showLoading: true });
const second = http.get("/fast", { showLoading: true });
await second;
expect(events).toEqual(["open"]);
await first;
expect(events).toEqual(["open", "close"]);
});
it("supports a project loading default while allowing request overrides", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const events: string[] = [];
const http = createHttpClient({
baseURL,
showLoadingByDefault: true,
onLoadingChange: (active) => events.push(active ? "open" : "close"),
});
await http.get("/default");
await http.get("/disabled", { showLoading: false });
expect(events).toEqual(["open", "close"]);
});
it("closes loading after both failure and cancellation", async () => {
const baseURL = await startServer(async (request, response) => {
if (request.url === "/slow") {
await delay(100);
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
return;
}
sendJson(response, 500, {
code: 50000,
message: "internal",
data: null,
});
});
const events: string[] = [];
const http = createHttpClient({
baseURL,
onLoadingChange: (active) => events.push(active ? "open" : "close"),
});
await expect(
http.get("/failed", { showLoading: true, errorMode: "silent" }),
).rejects.toMatchObject({ status: 500 });
const controller = new AbortController();
const canceled = http.get("/slow", {
showLoading: true,
signal: controller.signal,
});
controller.abort();
await expect(canceled).rejects.toMatchObject({ kind: "cancel" });
expect(events).toEqual(["open", "close", "open", "close"]);
});
it("sends two direct requests even when their method and URL are identical", async () => {
let requestCount = 0;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const http = createHttpClient({ baseURL });
await expect(
Promise.all([http.get("/search"), http.get("/search")]),
).resolves.toEqual([true, true]);
expect(requestCount).toBe(2);
});
it("does not send a request whose signal was already canceled", async () => {
let requestCount = 0;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const controller = new AbortController();
controller.abort("left page");
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
await expect(
http.get("/never-sent", { signal: controller.signal }),
).rejects.toMatchObject({ kind: "cancel" });
expect(requestCount).toBe(0);
expect(onError).not.toHaveBeenCalled();
expect(onReport).not.toHaveBeenCalled();
});
it("keeps retry disabled by default and never retries writes or 4xx errors", async () => {
const requestCounts = new Map<string, number>();
const baseURL = await startServer((request, response) => {
const url = request.url ?? "";
requestCounts.set(url, (requestCounts.get(url) ?? 0) + 1);
sendJson(response, url === "/client-error" ? 409 : 503, {
code: 1,
message: "failed",
data: null,
});
});
const http = createHttpClient({ baseURL });
await expect(
http.get("/default", { errorMode: "silent" }),
).rejects.toMatchObject({ status: 503 });
await expect(
http.post("/write", {}, {
errorMode: "silent",
retry: { retries: 2, baseDelayMs: 1 },
}),
).rejects.toMatchObject({ status: 503 });
await expect(
http.get("/client-error", {
errorMode: "silent",
retry: { retries: 2, baseDelayMs: 1 },
}),
).rejects.toMatchObject({ status: 409 });
expect(Object.fromEntries(requestCounts)).toEqual({
"/default": 1,
"/write": 1,
"/client-error": 1,
});
});
it("reports only the final failure after retry exhaustion", async () => {
let requestCount = 0;
let reportedError: HttpError | ApiEnvelopeFormatError | undefined;
const baseURL = await startServer((_request, response) => {
requestCount += 1;
sendJson(response, 503, {
code: 50300,
message: "internal details",
data: null,
});
});
const onError = vi.fn((error: HttpError | ApiEnvelopeFormatError) => {
reportedError = error;
});
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
await expect(
http.get("/temporary?token=must-not-leak", {
retry: { retries: 2, baseDelayMs: 1 },
}),
).rejects.toMatchObject({
kind: "http",
status: 503,
message: "HTTP request failed with status 503",
});
expect(requestCount).toBe(3);
expect(onError).toHaveBeenCalledTimes(1);
expect(onReport).toHaveBeenCalledTimes(1);
expect(onReport).toHaveBeenCalledWith(reportedError);
expect(reportedError).toMatchObject({
method: "GET",
path: "/temporary",
attempts: 3,
});
expect(reportedError?.elapsedMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(reportedError)).not.toContain("must-not-leak");
});
it("keeps reporting a failure when request-level display is silent", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 409, {
code: 40900,
message: "conflict",
data: null,
});
});
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
await expect(
http.post("/users", {}, { errorMode: "silent" }),
).rejects.toMatchObject({ kind: "http", status: 409 });
expect(onError).not.toHaveBeenCalled();
expect(onReport).toHaveBeenCalledTimes(1);
});
it("preserves the request failure when display or report callbacks throw", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 500, {
code: 50000,
message: "internal",
data: null,
});
});
const onError = vi.fn(() => {
throw new Error("display callback failed");
});
const onReport = vi.fn(() => {
throw new Error("report callback failed");
});
const http = createHttpClient({ baseURL, onError, onReport });
await expect(http.get("/failed")).rejects.toMatchObject({
kind: "http",
status: 500,
});
expect(onReport).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledTimes(1);
});
it("cancels all currently active business requests", async () => {
const baseURL = await startServer(async (_request, response) => {
await delay(100);
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const http = createHttpClient({ baseURL });
const request = http.get("/slow", { errorMode: "silent" });
http.cancelAll();
await expect(request).rejects.toMatchObject({ kind: "cancel" });
});
});
describe("file transfer integration", () => {
it("supports a POST export through the Axios Blob path", async () => {
let receivedMethod: string | undefined;
let receivedBody: string | undefined;
const baseURL = await startServer(async (request, response) => {
receivedMethod = request.method;
receivedBody = await readBody(request);
response.statusCode = 200;
response.setHeader(
"content-disposition",
"attachment; filename*=UTF-8''report.csv",
);
response.setHeader("content-type", "text/csv");
response.end("name\nAda");
});
const http = createHttpClient({ baseURL });
const file = await fetchFile(http, "/exports/users", {
method: "post",
data: { active: true },
});
expect(receivedMethod).toBe("POST");
expect(JSON.parse(receivedBody ?? "")).toEqual({ active: true });
expect(file.filename).toBe("report.csv");
});
});
describe("authentication capability", () => {
it("displays and reports an unhandled 401 when no auth adapter is installed", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 401, {
code: 40100,
message: "sign in required",
data: null,
});
});
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, onError, onReport });
await expect(http.get("/profile")).rejects.toMatchObject({
kind: "http",
status: 401,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onReport).toHaveBeenCalledTimes(1);
});
it("applies the current credential through a generic auth adapter", async () => {
const baseURL = await startServer((request, response) => {
sendJson(response, 200, {
code: 0,
message: "ok",
data: request.headers.authorization,
});
});
const auth: AuthAdapter = {
applyCredential(config) {
config.headers.set("Authorization", "Bearer current");
},
refreshCredential: vi.fn(),
shouldExpireSession: () => false,
expireSession: vi.fn(),
};
const http = createHttpClient({ baseURL, auth });
await expect(http.get("/profile")).resolves.toBe("Bearer current");
});
it("refreshes once for ten concurrent 401 responses and replays all requests", async () => {
let protectedCount = 0;
let refreshCount = 0;
let refreshAuthorization: string | undefined;
let releaseRefresh!: () => void;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const baseURL = await startServer(async (request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
refreshAuthorization = request.headers.authorization;
await refreshGate;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
protectedCount += 1;
if (request.headers.authorization !== "Bearer fresh") {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: { allowed: true },
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const requests = Array.from({ length: 10 }, () =>
http.get<{ allowed: boolean }>("/protected"),
);
await vi.waitFor(() => {
expect(protectedCount).toBe(10);
expect(refreshCount).toBe(1);
});
releaseRefresh();
await expect(Promise.all(requests)).resolves.toEqual(
Array.from({ length: 10 }, () => ({ allowed: true })),
);
expect(refreshCount).toBe(1);
expect(protectedCount).toBe(20);
expect(session.getAccessToken()).toBe("fresh");
expect(refreshAuthorization).toBeUndefined();
});
it("clears and expires a failed concurrent refresh only once", async () => {
let protectedCount = 0;
let refreshCount = 0;
let releaseRefresh!: () => void;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const baseURL = await startServer(async (request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
await refreshGate;
sendJson(response, 401, {
code: 40100,
message: "refresh expired",
data: null,
});
return;
}
protectedCount += 1;
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
});
const session: AuthSession = {
getAccessToken: vi.fn(() => "expired"),
setAccessToken: vi.fn(),
clearSession: vi.fn(),
onExpired: vi.fn(),
};
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
onError,
onReport,
});
const requests = Array.from({ length: 10 }, () =>
http.get("/protected"),
);
await vi.waitFor(() => {
expect(protectedCount).toBe(10);
expect(refreshCount).toBe(1);
});
releaseRefresh();
const results = await Promise.allSettled(requests);
expect(results.every((result) => result.status === "rejected")).toBe(true);
expect(session.clearSession).toHaveBeenCalledTimes(1);
expect(session.onExpired).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
expect(onReport).toHaveBeenCalledTimes(10);
});
it("allows a newly established session to refresh after the previous session failed", async () => {
let refreshCount = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
if (refreshCount === 1) {
sendJson(response, 401, {
code: 40100,
message: "refresh expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh-second-session" },
});
return;
}
if (
request.headers.authorization === "Bearer new-login-token" ||
request.headers.authorization === "Bearer fresh-second-session"
) {
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
return;
}
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired-first-session",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
await expect(http.get("/protected")).rejects.toMatchObject({
status: 401,
});
expect(refreshCount).toBe(1);
expect(session.onExpired).toHaveBeenCalledTimes(1);
session.setAccessToken("new-login-token");
http.resetAuthState();
await expect(http.get("/profile")).resolves.toBe(true);
session.setAccessToken("expired-second-session");
await expect(http.get("/protected")).resolves.toBe(true);
expect(refreshCount).toBe(2);
expect(session.getAccessToken()).toBe("fresh-second-session");
expect(session.onExpired).toHaveBeenCalledTimes(1);
});
it("discards a stale refresh so it cannot overwrite a newer login", async () => {
let resolveRefreshArrived!: (response: ServerResponse) => void;
const refreshArrived = new Promise<ServerResponse>((resolve) => {
resolveRefreshArrived = resolve;
});
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
// 挂起刷新响应模拟慢网络:测试在这个窗口里完成重新登录。
resolveRefreshArrived(response);
return;
}
if (request.headers.authorization === "Bearer old") {
sendJson(response, 401, { code: 40100, message: "expired", data: null });
return;
}
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const inflight = http.get<{ ok: boolean }>("/protected");
const refreshResponse = await refreshArrived;
// 刷新在途期间用户重新登录,进入新会话。
session.setAccessToken("relogin");
http.resetAuthState();
// 旧会话的刷新此刻才回来,带着一个服务端仍然认可的令牌。
sendJson(refreshResponse, 200, {
code: 0,
message: "ok",
data: { accessToken: "stale-refresh" },
});
await expect(inflight).resolves.toEqual({ ok: true });
expect(session.getAccessToken()).toBe("relogin");
});
it("does not resurrect a session when a stale refresh lands after logout", async () => {
let resolveRefreshArrived!: (response: ServerResponse) => void;
const refreshArrived = new Promise<ServerResponse>((resolve) => {
resolveRefreshArrived = resolve;
});
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
resolveRefreshArrived(response);
return;
}
// stale-refresh 是服务端真实签发的有效令牌,业务端点会认它——
// 「登出后被复活」的危险正在于此,令牌无效的话这个 bug 反而会被 401 掩盖。
if (request.headers.authorization === "Bearer stale-refresh") {
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const inflight = http.get("/protected");
const refreshResponse = await refreshArrived;
// 刷新在途期间用户登出。登出与登录一样是会话边界,同样要开新代际。
session.clearSession();
http.resetAuthState();
sendJson(refreshResponse, 200, {
code: 0,
message: "ok",
data: { accessToken: "stale-refresh" },
});
await inflight.catch(() => undefined);
expect(session.getAccessToken()).toBeNull();
});
it("waits for an in-flight refresh to settle before a session boundary", async () => {
// 会话边界动作(登录、登出请求)发出前用它排空在途刷新:JS 拦不住刷新响应的
// Set-Cookie,代际只护住内存侧;只有让旧响应先落地,边界动作拿到的 Cookie
// 才是最后写入的那份,不会被旧会话的轮换响应回盖。
let resolveRefreshArrived!: (response: ServerResponse) => void;
const refreshArrived = new Promise<ServerResponse>((resolve) => {
resolveRefreshArrived = resolve;
});
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
resolveRefreshArrived(response);
return;
}
if (request.headers.authorization === "Bearer old") {
sendJson(response, 401, { code: 40100, message: "expired", data: null });
return;
}
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const inflight = http.get<{ ok: boolean }>("/protected");
const refreshResponse = await refreshArrived;
let settled = false;
const waiting = http.waitForRefreshSettled().then(() => {
settled = true;
});
// 刷新仍在途:不得提前放行
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
sendJson(refreshResponse, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
await waiting;
// 放行时新凭证已提交完成,边界动作可以安全发出
expect(session.getAccessToken()).toBe("fresh");
await expect(inflight).resolves.toEqual({ ok: true });
});
it("resolves immediately without an in-flight refresh and swallows refresh failures", async () => {
let resolveRefreshArrived!: (response: ServerResponse) => void;
const refreshArrived = new Promise<ServerResponse>((resolve) => {
resolveRefreshArrived = resolve;
});
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
resolveRefreshArrived(response);
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
// 无在途刷新:立即落定
await http.waitForRefreshSettled();
const inflight = http.get("/protected").catch(() => undefined);
const refreshResponse = await refreshArrived;
const waiting = http.waitForRefreshSettled();
// 刷新以失败收场(503 走熔断不终结):等待只关心「已落定」,不复抛刷新的错误
sendJson(refreshResponse, 503, { code: 50300, message: "unavailable", data: null });
await expect(waiting).resolves.toBeUndefined();
await inflight;
expect(session.getAccessToken()).toBe("old");
});
it("blocks new refreshes during runAuthTransition and replays with the boundary credential", async () => {
// 光排空不够:排空返回后、边界动作的往返期间,新 401 仍可能起新刷新,
// 其 Set-Cookie 晚到会回盖边界动作写的 Cookie。闸内产生的 401 必须等闸,
// 且闸后复查版本——边界已换代就跳过刷新直接重放。
let refreshHits = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
refreshHits += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "via-refresh" },
});
return;
}
if (request.headers.authorization === "Bearer boundary-fresh") {
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
let releaseAction!: () => void;
const actionGate = new Promise<void>((resolve) => {
releaseAction = resolve;
});
const transition = http.runAuthTransition(async () => {
await actionGate;
// 模拟登录成功的写入:新凭证 + 开新代际
session.setAccessToken("boundary-fresh");
http.resetAuthState();
return "login-result";
});
// 边界进行中撞上 401:不得启动新刷新
const inflight = http.get<{ ok: boolean }>("/protected");
await delay(20);
expect(refreshHits).toBe(0);
releaseAction();
await expect(transition).resolves.toBe("login-result");
// 闸放开后复查版本:凭证已换代,跳过刷新,用新凭证重放
await expect(inflight).resolves.toEqual({ ok: true });
expect(refreshHits).toBe(0);
});
it("drains the in-flight refresh before running the transition action", async () => {
let resolveRefreshArrived!: (response: ServerResponse) => void;
const refreshArrived = new Promise<ServerResponse>((resolve) => {
resolveRefreshArrived = resolve;
});
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
resolveRefreshArrived(response);
return;
}
if (request.headers.authorization === "Bearer old") {
sendJson(response, 401, { code: 40100, message: "expired", data: null });
return;
}
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const inflight = http.get<{ ok: boolean }>("/protected");
const refreshResponse = await refreshArrived;
const order: string[] = [];
const transition = http.runAuthTransition(async () => {
order.push(`action:${session.getAccessToken()}`);
});
// 在途刷新未落定:边界动作不得开始执行
await delay(20);
expect(order).toEqual([]);
sendJson(refreshResponse, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
await transition;
// 动作看到的是刷新提交后的世界,它的写入必然排在旧刷新之后
expect(order).toEqual(["action:fresh"]);
await expect(inflight).resolves.toEqual({ ok: true });
});
it("releases the transition gate and rethrows when the action fails", async () => {
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
if (request.headers.authorization === "Bearer fresh") {
sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
// 登录请求本身失败等场景:错误原样冒泡,不吞
await expect(
http.runAuthTransition(async () => {
throw new Error("login failed");
}),
).rejects.toThrow("login failed");
// 闸已释放:老会话继续,401 照常走刷新恢复
await expect(http.get<{ ok: boolean }>("/protected")).resolves.toEqual({
ok: true,
});
expect(session.getAccessToken()).toBe("fresh");
});
it("lets the adapter decide which refresh failure ends the session", async () => {
// OAuth 式后端用 400 + invalid_grant(而非 401)表示 Refresh Token 失效。
// 「哪种失败意味着凭证已死」是后端契约,换个判定就能接入,不改引擎。
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
sendJson(response, 400, {
code: "invalid_grant",
message: "refresh token revoked",
data: null,
});
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
const session = createMemoryAuthSession({
initialAccessToken: "old",
onExpired: vi.fn(),
});
const auth = {
...createTestAuth(baseURL, session),
shouldExpireSession: (error: unknown) =>
axios.isAxiosError(error) &&
error.response?.status === 400 &&
(error.response.data as { code?: string } | undefined)?.code ===
"invalid_grant",
};
const http = createHttpClient({ baseURL, auth });
await http.get("/protected").catch(() => undefined);
expect(session.getAccessToken()).toBeNull();
expect(session.onExpired).toHaveBeenCalledTimes(1);
});
it("keeps the session on a concurrent network refresh failure without repeated display", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
});
const refreshError = new axios.AxiosError(
"offline",
"ERR_NETWORK",
{
headers: new AxiosHeaders(),
method: "post",
url: "/auth/refresh?token=must-not-leak",
},
);
const auth: AuthAdapter = {
applyCredential(config) {
config.headers.set("Authorization", "Bearer expired");
},
refreshCredential: vi.fn(async () => {
throw refreshError;
}),
shouldExpireSession: () => false,
expireSession: vi.fn(),
};
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, auth, onError, onReport });
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => http.get("/protected")),
);
expect(results).toHaveLength(10);
expect(
results.every(
(result) =>
result.status === "rejected" &&
result.reason instanceof HttpError &&
result.reason.kind === "network",
),
).toBe(true);
expect(auth.refreshCredential).toHaveBeenCalledTimes(1);
// 网络错说明刷新端点「暂时无法回答」,不是凭证失效:会话必须保留(D-65)。
expect(auth.expireSession).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
expect(onReport).toHaveBeenCalledTimes(10);
expect(onReport.mock.calls.map(([error]) => error)).toEqual(
Array.from({ length: 10 }, () =>
expect.objectContaining({
origin: "auth-refresh",
originMethod: "POST",
originPath: "/auth/refresh",
path: "/protected",
}),
),
);
});
it("keeps the session on a 5xx refresh failure and recovers after the cooldown", async () => {
let refreshCount = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
if (refreshCount === 1) {
sendJson(response, 503, {
code: 50300,
message: "refresh unavailable",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
if (request.headers.authorization === "Bearer fresh") {
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
return;
}
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
refreshCooldownMs: 150,
});
// 刷新端点 503:请求失败,但这是端点的暂时故障而不是凭证失效,会话必须保留。
await expect(http.get("/protected")).rejects.toMatchObject({ status: 503 });
expect(refreshCount).toBe(1);
expect(session.onExpired).not.toHaveBeenCalled();
expect(session.getAccessToken()).toBe("expired");
// 冷却窗口内:熔断复用上次的失败,不再打刷新端点。
await expect(http.get("/protected")).rejects.toMatchObject({ status: 503 });
expect(refreshCount).toBe(1);
// 冷却结束:放行一次新的刷新,成功后静默恢复——全程没有踢过登录。
await new Promise((resolve) => setTimeout(resolve, 200));
await expect(http.get("/protected")).resolves.toBe(true);
expect(refreshCount).toBe(2);
expect(session.onExpired).not.toHaveBeenCalled();
expect(session.getAccessToken()).toBe("fresh");
});
it("does not refresh again when a replay also returns 401", async () => {
let protectedCount = 0;
let refreshCount = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
protectedCount += 1;
sendJson(response, 401, {
code: 40100,
message: "still expired",
data: null,
});
});
const onExpired = vi.fn();
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired,
});
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
onError,
onReport,
});
await expect(http.get("/protected")).rejects.toMatchObject({
kind: "http",
status: 401,
method: "GET",
path: "/protected",
attempts: 2,
});
expect(refreshCount).toBe(1);
expect(protectedCount).toBe(2);
expect(onExpired).toHaveBeenCalledTimes(1);
expect(session.getAccessToken()).toBeNull();
expect(onError).not.toHaveBeenCalled();
expect(onReport).toHaveBeenCalledTimes(1);
});
it("keeps one loading interval around refresh and replay", async () => {
let protectedCount = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
protectedCount += 1;
if (request.headers.authorization !== "Bearer fresh") {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const events: string[] = [];
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
onLoadingChange: (active) => events.push(active ? "open" : "close"),
});
await expect(
http.get("/protected", { showLoading: true }),
).resolves.toBe(true);
expect(protectedCount).toBe(2);
expect(events).toEqual(["open", "close"]);
});
it("holds new requests until an active refresh has completed", async () => {
let refreshCount = 0;
let waitingRequestCount = 0;
let releaseRefresh!: () => void;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const baseURL = await startServer(async (request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
await refreshGate;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
if (request.url === "/waiting") {
waitingRequestCount += 1;
}
if (request.headers.authorization !== "Bearer fresh") {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const first = http.get("/first");
await vi.waitFor(() => {
expect(refreshCount).toBe(1);
});
const waiting = http.get("/waiting");
await delay(20);
expect(waitingRequestCount).toBe(0);
releaseRefresh();
await expect(Promise.all([first, waiting])).resolves.toEqual([true, true]);
expect(waitingRequestCount).toBe(1);
});
it("replays a late old-credential 401 without refreshing a second time", async () => {
let refreshCount = 0;
let releaseLateResponse!: () => void;
const lateResponseGate = new Promise<void>((resolve) => {
releaseLateResponse = resolve;
});
let slowAttempt = 0;
const baseURL = await startServer(async (request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
if (request.url === "/slow") {
slowAttempt += 1;
if (slowAttempt === 1) {
await lateResponseGate;
}
}
if (request.headers.authorization !== "Bearer fresh") {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: true,
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const slow = http.get("/slow");
await expect(http.get("/trigger")).resolves.toBe(true);
expect(refreshCount).toBe(1);
releaseLateResponse();
await expect(slow).resolves.toBe(true);
expect(refreshCount).toBe(1);
expect(slowAttempt).toBe(2);
});
it("does not send a request canceled while it is waiting for refresh", async () => {
let refreshCount = 0;
let waitingRequestCount = 0;
let releaseRefresh!: () => void;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const baseURL = await startServer(async (request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
await refreshGate;
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "fresh" },
});
return;
}
if (request.url === "/waiting") {
waitingRequestCount += 1;
}
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
});
const session = createMemoryAuthSession({
initialAccessToken: "expired",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL,
auth: createTestAuth(baseURL, session),
});
const first = http.get("/first");
await vi.waitFor(() => {
expect(refreshCount).toBe(1);
});
const controller = new AbortController();
const waiting = http.get("/waiting", {
signal: controller.signal,
errorMode: "silent",
});
controller.abort();
releaseRefresh();
await expect(waiting).rejects.toMatchObject({ kind: "cancel" });
await expect(first).rejects.toMatchObject({ status: 401 });
expect(waitingRequestCount).toBe(0);
});
it("leaves the session unchanged for a skipAuth request that returns 401", async () => {
const baseURL = await startServer((_request, response) => {
sendJson(response, 401, {
code: 40100,
message: "invalid login",
data: null,
});
});
const auth: AuthAdapter = {
applyCredential: vi.fn(),
refreshCredential: vi.fn(),
shouldExpireSession: () => false,
expireSession: vi.fn(),
};
const onError = vi.fn();
const onReport = vi.fn();
const http = createHttpClient({ baseURL, auth, onError, onReport });
await expect(
http.post("/login", undefined, { skipAuth: true }),
).rejects.toMatchObject({ kind: "http", status: 401 });
expect(auth.applyCredential).not.toHaveBeenCalled();
expect(auth.refreshCredential).not.toHaveBeenCalled();
expect(auth.expireSession).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
expect(onReport).toHaveBeenCalledTimes(1);
});
});ts
import axios from "axios";
import { describe, expect, it, vi } from "vitest";
import {
presentApiError,
readApiErrorMessage,
} from "../src/api/http/adapters/error-presenter";
import {
ApiEnvelopeFormatError,
assignRequestErrorContext,
HttpError,
normalizeHttpError,
} from "../src/api/http/errors";
import * as publicHttpEntry from "../src/api/http/index";
import { retry } from "../src/api/http/retry";
import { readDownloadFilename } from "../src/api/http/transfer";
describe("application HTTP entry", () => {
it("exposes only the configured http instance", () => {
expect(Object.keys(publicHttpEntry)).toEqual(["http"]);
});
});
describe("response protocol", () => {
it("keeps developer descriptions in the core and user copy in the presenter", async () => {
const error = await normalizeHttpError(
new axios.AxiosError("offline", "ERR_NETWORK"),
);
expect(error.message).toBe("Network request failed");
expect(presentApiError(error)).toBe("网络异常,请检查连接后重试");
});
it("keeps all user-facing fallback copy inside the presenter", async () => {
const timeout = await normalizeHttpError(
new axios.AxiosError("slow", "ETIMEDOUT"),
);
const canceled = await normalizeHttpError(
new axios.CanceledError("left page"),
);
const server = await normalizeHttpError({
isAxiosError: true,
response: {
status: 503,
data: { internal: "do not display" },
},
});
const unknown = await normalizeHttpError(new Error("boom"));
const format = new ApiEnvelopeFormatError(200, { broken: true });
expect([
timeout.message,
canceled.message,
server.message,
unknown.message,
format.message,
]).toEqual([
"Request timed out",
"Request canceled",
"HTTP request failed with status 503",
"Unknown request failure",
"API response does not match the expected envelope",
]);
expect([
presentApiError(timeout),
presentApiError(canceled),
presentApiError(server),
presentApiError(unknown),
presentApiError(format),
]).toEqual([
"请求超时,请稍后重试",
"请求已取消",
"服务暂时不可用,请稍后重试",
"请求失败,请稍后重试",
"接口返回格式异常,请稍后重试",
]);
});
it("keeps malformed response data out of default error serialization", () => {
const responseData = {
secret: "PII-IN-BODY",
};
const error = new ApiEnvelopeFormatError(200, responseData);
expect(error.responseData).toBe(responseData);
expect(Object.keys(error)).not.toContain("responseData");
expect(JSON.stringify(error)).not.toContain("PII-IN-BODY");
});
it("keeps server-provided display copy out of default error serialization", async () => {
const error = await normalizeHttpError(
{
isAxiosError: true,
response: {
status: 400,
data: {
code: 40010,
message: "字段 idCard=110101199001011234 校验失败",
data: null,
},
},
},
{ readErrorMessage: readApiErrorMessage },
);
const contextual = assignRequestErrorContext(error, {
method: "POST",
path: "/users",
attempts: 1,
elapsedMs: 12,
origin: "business",
});
expect(error.presentationHint).toBe(
"字段 idCard=110101199001011234 校验失败",
);
// 写入请求上下文后展示文案仍可用,但依然不进入默认序列化
expect(presentApiError(contextual)).toBe(
"字段 idCard=110101199001011234 校验失败",
);
expect(Object.keys(error)).not.toContain("presentationHint");
expect(JSON.stringify(error)).not.toContain("110101199001011234");
expect(JSON.stringify(contextual)).not.toContain("110101199001011234");
});
it("writes the request context into the original error instead of rebuilding it", async () => {
const cause = {
isAxiosError: true,
response: { status: 500, data: null },
};
const httpError = await normalizeHttpError(cause);
const responseData = { broken: true };
const formatError = new ApiEnvelopeFormatError(200, responseData);
const context = {
method: "GET",
path: "/report",
attempts: 2,
elapsedMs: 34,
origin: "business",
} as const;
// 重建会换掉对象身份,任何按身份建立的关联(WeakSet、WeakMap)都会失效,
// 而且每新增一个载荷字段都要在重建处补一次拷贝,漏掉就是静默丢失。
expect(assignRequestErrorContext(httpError, context)).toBe(httpError);
expect(assignRequestErrorContext(formatError, context)).toBe(formatError);
expect(httpError.cause).toBe(cause);
expect(formatError.responseData).toBe(responseData);
// 请求上下文属于安全字段,必须留在默认序列化里供上报使用。
expect(JSON.parse(JSON.stringify(httpError))).toMatchObject(context);
expect(JSON.parse(JSON.stringify(formatError))).toMatchObject(context);
});
it("reads an API error carried by a Blob response", async () => {
const error = {
isAxiosError: true,
response: {
status: 400,
data: new Blob(
[
JSON.stringify({
code: 40012,
message: "文件格式错误",
data: null,
}),
],
{ type: "application/json" },
),
},
};
const normalized = await normalizeHttpError(error, {
readErrorMessage: readApiErrorMessage,
});
expect(normalized).toMatchObject({
kind: "http",
status: 400,
message: "HTTP request failed with status 400",
});
expect(presentApiError(normalized)).toBe("文件格式错误");
});
it("classifies client-side failures and preserves their original cause", async () => {
const failures = [
{
error: new axios.AxiosError("offline", "ERR_NETWORK"),
kind: "network",
},
{
error: new axios.AxiosError("slow", "ETIMEDOUT"),
kind: "timeout",
},
{
error: new axios.CanceledError("left page"),
kind: "cancel",
},
{
error: new Error("unexpected"),
kind: "unknown",
},
] as const;
for (const { error, kind } of failures) {
const normalized = await normalizeHttpError(error);
expect(normalized.kind).toBe(kind);
expect(normalized.cause).toBe(error);
}
});
it("separates Axios configuration failures from network and unknown failures", async () => {
const badOption = new axios.AxiosError(
"bad option",
axios.AxiosError.ERR_BAD_OPTION,
);
const invalidUrl = new axios.AxiosError(
"invalid URL",
axios.AxiosError.ERR_INVALID_URL,
);
const unclassified = new axios.AxiosError(
"adapter failed",
"ERR_UNCLASSIFIED",
);
const configuration = await normalizeHttpError(badOption);
const invalidConfiguration = await normalizeHttpError(invalidUrl);
const unknown = await normalizeHttpError(unclassified);
expect(configuration).toMatchObject({
kind: "configuration",
message: "Request configuration failed",
cause: badOption,
});
expect(invalidConfiguration.kind).toBe("configuration");
expect(unknown.kind).toBe("unknown");
expect(presentApiError(configuration)).toBe(
"请求配置错误,请联系管理员",
);
expect(Object.keys(configuration)).not.toContain("cause");
});
});
describe("retry", () => {
it("retries temporary failures up to the configured limit", async () => {
const task = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(
new HttpError({ kind: "network", message: "offline" }),
)
.mockRejectedValueOnce(
new HttpError({ kind: "http", status: 503, message: "busy" }),
)
.mockResolvedValue("ok");
await expect(retry(task, { retries: 2, baseDelay: 1 })).resolves.toBe("ok");
expect(task).toHaveBeenCalledTimes(3);
});
it("does not start a retry wait that would overrun the total budget", async () => {
const task = vi.fn(async () => {
throw new HttpError({ kind: "network", message: "offline" });
});
const startedAt = Date.now();
await expect(
retry(task, { retries: 5, baseDelay: 40, totalTimeoutMs: 250 }),
).rejects.toMatchObject({ kind: "network" });
const elapsed = Date.now() - startedAt;
// 无预算时会跑满 6 次尝试、约 1.2 秒退避
expect(task.mock.calls.length).toBeLessThan(6);
expect(elapsed).toBeLessThan(600);
});
it("stops a retry wait when its signal is aborted", async () => {
const controller = new AbortController();
const task = vi.fn(async () => {
throw new HttpError({ kind: "network", message: "offline" });
});
const result = retry(task, {
retries: 2,
baseDelay: 100,
signal: controller.signal,
});
controller.abort("left page");
await expect(result).rejects.toMatchObject({
kind: "cancel",
message: "Request canceled",
});
expect(task).toHaveBeenCalledTimes(1);
});
});
describe("file transfer utilities", () => {
it("prefers UTF-8 filenames and removes unsafe path characters", () => {
expect(
readDownloadFilename(
"attachment; filename*=UTF-8''%E6%9C%88%E6%8A%A5%2F2026.xlsx",
),
).toBe("月报_2026.xlsx");
});
it("uses a safe fallback when no filename is returned", () => {
expect(readDownloadFilename(undefined, "../report.csv")).toBe("_report.csv");
});
});ts
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMemoryAuthSession,
type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { createHttpClient } from "../src/api/http/client";
import { ApiEnvelopeFormatError } from "../src/api/http/errors";
type ServerHit = { method: string; url: string; authorization?: string };
const openServers = new Set<Server>();
function sendJson(response: ServerResponse, status: number, body: unknown) {
response.statusCode = status;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(body));
}
function delay(milliseconds: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function startServer(
handler: (
request: IncomingMessage,
response: ServerResponse,
) => void | Promise<void>,
) {
const server = createServer((request, response) => {
Promise.resolve(handler(request, response)).catch(() => {
sendJson(response, 500, { message: "test server failed" });
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
openServers.add(server);
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
function createTestAuth(baseURL: string, session: AuthSession) {
return createBearerAuthAdapter({
baseURL,
getAccessToken: session.getAccessToken,
setAccessToken: session.setAccessToken,
expireSession() {
session.clearSession();
session.onExpired();
},
selectAccessToken(response) {
const envelope = readApiEnvelope(response.data);
const result = envelope?.data;
if (
!envelope?.hasData ||
!result ||
typeof result !== "object" ||
!("accessToken" in result) ||
typeof result.accessToken !== "string"
) {
throw new ApiEnvelopeFormatError(response.status, response.data);
}
return result.accessToken;
},
});
}
/**
* 建立一个「首次刷新被挂起、随后失败」的会话场景。
* 释放挂起前,测试可以模拟显式重新登录。
*/
async function startExpiringSessionServer(options: {
secondRefreshSucceeds: boolean;
}) {
const hits: ServerHit[] = [];
let releaseFirstRefresh: (() => void) | undefined;
const firstRefreshGate = new Promise<void>((resolve) => {
releaseFirstRefresh = resolve;
});
let refreshCount = 0;
const baseURL = await startServer(async (request, response) => {
hits.push({
method: request.method ?? "",
url: request.url ?? "",
authorization: request.headers.authorization,
});
if (request.url === "/auth/refresh") {
refreshCount += 1;
if (refreshCount === 1) {
await firstRefreshGate;
sendJson(response, 401, {
code: 40100,
message: "refresh expired",
data: null,
});
return;
}
if (options.secondRefreshSucceeds) {
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "rotated-token" },
});
return;
}
sendJson(response, 401, {
code: 40100,
message: "refresh expired",
data: null,
});
return;
}
if (
request.headers.authorization === "Bearer new-login-token" ||
request.headers.authorization === "Bearer rotated-token"
) {
sendJson(response, 200, { code: 0, message: "ok", data: "fresh-data" });
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
return {
baseURL,
hits,
refreshCount: () => refreshCount,
releaseFirstRefresh: () => releaseFirstRefresh?.(),
};
}
afterEach(async () => {
await Promise.all(
[...openServers].map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
server.closeAllConnections();
}),
),
);
openServers.clear();
});
describe("session isolation between the business client and the refresh client", () => {
it("never replays a refresh request through the business client", async () => {
const scenario = await startExpiringSessionServer({
secondRefreshSucceeds: true,
});
const session = createMemoryAuthSession({
initialAccessToken: "expired-first-session",
onExpired: vi.fn(),
});
const onError = vi.fn();
const http = createHttpClient({
baseURL: scenario.baseURL,
auth: createTestAuth(scenario.baseURL, session),
onError,
});
const expiredRequest = http.get("/protected").catch((error: unknown) => error);
await delay(120);
// 用户在刷新在途时完成显式登录
session.setAccessToken("new-login-token");
http.resetAuthState();
const afterLogin = http.get<string>("/profile");
await delay(60);
scenario.releaseFirstRefresh();
await expiredRequest;
await expect(afterLogin).resolves.toBe("fresh-data");
// 业务请求必须真的发出,而不是拿到刷新接口的响应体
expect(
scenario.hits.some((hit) => hit.method === "GET" && hit.url === "/profile"),
).toBe(true);
// 刷新端点只应由无凭证的刷新客户端访问
expect(
scenario.hits.filter(
(hit) => hit.url === "/auth/refresh" && hit.authorization !== undefined,
),
).toEqual([]);
expect(scenario.refreshCount()).toBe(1);
expect(onError).not.toHaveBeenCalled();
});
it("keeps a re-established session alive when the previous refresh finally fails", async () => {
const scenario = await startExpiringSessionServer({
secondRefreshSucceeds: false,
});
const onExpired = vi.fn();
const session = createMemoryAuthSession({
initialAccessToken: "expired-first-session",
onExpired,
});
const http = createHttpClient({
baseURL: scenario.baseURL,
auth: createTestAuth(scenario.baseURL, session),
});
const expiredRequest = http.get("/protected").catch((error: unknown) => error);
await delay(120);
session.setAccessToken("new-login-token");
http.resetAuthState();
const afterLogin = http.get<string>("/profile");
await delay(60);
scenario.releaseFirstRefresh();
await expiredRequest;
await expect(afterLogin).resolves.toBe("fresh-data");
// 上一会话的刷新失败不得再次失效已经重建的会话
expect(onExpired).not.toHaveBeenCalled();
expect(session.getAccessToken()).toBe("new-login-token");
});
it("continues a request that was already waiting for the previous session's refresh", async () => {
const scenario = await startExpiringSessionServer({
secondRefreshSucceeds: false,
});
const onExpired = vi.fn();
const session = createMemoryAuthSession({
initialAccessToken: "expired-first-session",
onExpired,
});
const http = createHttpClient({
baseURL: scenario.baseURL,
auth: createTestAuth(scenario.baseURL, session),
});
const expiredRequest = http.get("/protected").catch((error: unknown) => error);
await delay(120);
// 该请求先进入等待,登录发生在它等待期间
const waiting = http.get<string>("/profile");
await delay(60);
session.setAccessToken("new-login-token");
http.resetAuthState();
scenario.releaseFirstRefresh();
await expiredRequest;
await expect(waiting).resolves.toBe("fresh-data");
expect(onExpired).not.toHaveBeenCalled();
expect(session.getAccessToken()).toBe("new-login-token");
});
});ts
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMemoryAuthSession,
type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { createHttpClient } from "../src/api/http/client";
import { ApiEnvelopeFormatError } from "../src/api/http/errors";
const openServers = new Set<Server>();
function sendJson(response: ServerResponse, status: number, body: unknown) {
response.statusCode = status;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(body));
}
function delay(milliseconds: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function startServer(
handler: (
request: IncomingMessage,
response: ServerResponse,
) => void | Promise<void>,
) {
const server = createServer((request, response) => {
Promise.resolve(handler(request, response)).catch(() => {
sendJson(response, 500, { message: "test server failed" });
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
openServers.add(server);
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
function createTestAuth(baseURL: string, session: AuthSession) {
return createBearerAuthAdapter({
baseURL,
getAccessToken: session.getAccessToken,
setAccessToken: session.setAccessToken,
expireSession() {
session.clearSession();
session.onExpired();
},
selectAccessToken(response) {
const envelope = readApiEnvelope(response.data);
const result = envelope?.data;
if (
!envelope?.hasData ||
!result ||
typeof result !== "object" ||
!("accessToken" in result) ||
typeof result.accessToken !== "string"
) {
throw new ApiEnvelopeFormatError(response.status, response.data);
}
return result.accessToken;
},
});
}
/** 刷新端点只在第一次抖动失败,之后恢复正常。 */
async function startFlakyRefreshServer() {
let refreshCount = 0;
const baseURL = await startServer((request, response) => {
if (request.url === "/auth/refresh") {
refreshCount += 1;
if (refreshCount === 1) {
sendJson(response, 500, { message: "refresh endpoint hiccup" });
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: "good-token" },
});
return;
}
if (request.headers.authorization === "Bearer good-token") {
sendJson(response, 200, { code: 0, message: "ok", data: "fresh-data" });
return;
}
sendJson(response, 401, { code: 40100, message: "expired", data: null });
});
return { baseURL, refreshCount: () => refreshCount };
}
afterEach(async () => {
await Promise.all(
[...openServers].map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
server.closeAllConnections();
}),
),
);
openServers.clear();
});
describe("refresh failure cooldown", () => {
it("reuses the cached refresh failure inside the cooldown window", async () => {
const scenario = await startFlakyRefreshServer();
const onExpired = vi.fn();
const session = createMemoryAuthSession({
initialAccessToken: "stale-token",
onExpired,
});
const http = createHttpClient({
baseURL: scenario.baseURL,
auth: createTestAuth(scenario.baseURL, session),
refreshCooldownMs: 10_000,
});
await expect(http.get("/protected")).rejects.toMatchObject({
kind: "http",
status: 500,
origin: "auth-refresh",
});
expect(scenario.refreshCount()).toBe(1);
await expect(http.get("/protected")).rejects.toMatchObject({
kind: "http",
status: 500,
});
// 冷却期内不得再打刷新端点;端点抖动不是凭证失效,会话必须原样保留(D-65)
expect(scenario.refreshCount()).toBe(1);
expect(onExpired).not.toHaveBeenCalled();
});
it("retries the refresh once the cooldown has elapsed", async () => {
const scenario = await startFlakyRefreshServer();
const session = createMemoryAuthSession({
initialAccessToken: "stale-token",
onExpired: vi.fn(),
});
const http = createHttpClient({
baseURL: scenario.baseURL,
auth: createTestAuth(scenario.baseURL, session),
refreshCooldownMs: 40,
});
await expect(http.get("/protected")).rejects.toMatchObject({ status: 500 });
expect(scenario.refreshCount()).toBe(1);
await delay(80);
// 刷新端点恢复后客户端必须自愈,而不是终身锁死在失败缓存上
await expect(http.get<string>("/protected")).resolves.toBe("fresh-data");
expect(scenario.refreshCount()).toBe(2);
expect(session.getAccessToken()).toBe("good-token");
});
});
describe("retry total budget", () => {
it("stops retrying a safe read once the budget cannot fit another attempt", async () => {
let hits = 0;
const baseURL = await startServer((_request, response) => {
hits += 1;
sendJson(response, 503, { code: 50300, message: "busy", data: null });
});
const http = createHttpClient({ baseURL });
const startedAt = Date.now();
await expect(
http.get("/report", {
retry: { retries: 5, baseDelayMs: 40, totalTimeoutMs: 250 },
}),
).rejects.toMatchObject({ kind: "http", status: 503 });
const elapsed = Date.now() - startedAt;
// 不设预算时会跑满 6 次尝试、约 1.2 秒退避
expect(hits).toBeLessThan(6);
expect(elapsed).toBeLessThan(600);
});
});ts
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createSessionSync,
type SessionSyncEvent,
} from "../src/api/session-sync";
interface TestSession {
accessToken: string;
}
/** 每个用例独立频道,避免 BroadcastChannel 跨用例串音。 */
let channelSequence = 0;
function nextChannelName() {
channelSequence += 1;
return `session-sync-test-${channelSequence}`;
}
let cleanups: Array<() => void> = [];
function createRecorder() {
const endedEvents: number[] = [];
const updatedSessions: TestSession[] = [];
return {
endedEvents,
updatedSessions,
handlers: {
onSessionEnded() {
endedEvents.push(endedEvents.length + 1);
},
onSessionUpdated(session: TestSession) {
updatedSessions.push(session);
},
},
};
}
function openSync(channelName: string, recorder = createRecorder()) {
const sync = createSessionSync<TestSession>(channelName, recorder.handlers);
cleanups.push(() => sync.dispose());
return { recorder, sync };
}
/** 旁路观察者:直接监听原始频道,用来断言线上报文和注入伪造事件。 */
function openProbe(channelName: string) {
const events: Array<SessionSyncEvent<TestSession>> = [];
const channel = new BroadcastChannel(channelName);
channel.onmessage = (event: MessageEvent) => {
events.push(event.data as SessionSyncEvent<TestSession>);
};
cleanups.push(() => channel.close());
return { channel, events };
}
async function until(predicate: () => boolean, timeoutMs = 1000) {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("等待条件超时");
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
afterEach(() => {
for (const cleanup of cleanups) {
cleanup();
}
cleanups = [];
vi.unstubAllGlobals();
});
describe("createSessionSync", () => {
it("把会话终结事件广播给其他实例,且不回传给发布者自己", async () => {
const channelName = nextChannelName();
const publisher = openSync(channelName);
const receiver = openSync(channelName);
publisher.sync.publishSessionEnded();
await until(() => receiver.recorder.endedEvents.length === 1);
expect(publisher.recorder.endedEvents).toHaveLength(0);
});
it("会话更新事件携带完整会话数据", async () => {
const channelName = nextChannelName();
const publisher = openSync(channelName);
const receiver = openSync(channelName);
publisher.sync.publishSessionUpdated({ accessToken: "token-1" });
await until(() => receiver.recorder.updatedSessions.length === 1);
expect(receiver.recorder.updatedSessions[0]).toEqual({
accessToken: "token-1",
});
});
it("乱序到达的过期事件被丢弃", async () => {
const channelName = nextChannelName();
const receiver = openSync(channelName);
const probe = openProbe(channelName);
const freshAt = Date.now() + 5000;
probe.channel.postMessage({
sentAt: freshAt,
sourceId: "peer",
type: "session-ended",
});
// 时间戳落后于已处理事件的旧事件必须被丢弃
probe.channel.postMessage({
sentAt: 1,
session: { accessToken: "stale" },
sourceId: "peer",
type: "session-updated",
});
// 收尾标记:等它被处理,就能确认前一条旧事件已经走完了判定
probe.channel.postMessage({
sentAt: freshAt + 1,
sourceId: "peer",
type: "session-ended",
});
await until(() => receiver.recorder.endedEvents.length === 2);
expect(receiver.recorder.updatedSessions).toHaveLength(0);
});
it("同一时间戳同一来源的重复投递只处理一次", async () => {
const channelName = nextChannelName();
const receiver = openSync(channelName);
const probe = openProbe(channelName);
const sentAt = Date.now() + 5000;
probe.channel.postMessage({ sentAt, sourceId: "dup", type: "session-ended" });
probe.channel.postMessage({ sentAt, sourceId: "dup", type: "session-ended" });
probe.channel.postMessage({
sentAt: sentAt + 1,
sourceId: "other",
type: "session-ended",
});
await until(() => receiver.recorder.endedEvents.length === 2);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(receiver.recorder.endedEvents).toHaveLength(2);
});
it("同毫秒的终结与更新冲突,两种到达顺序都收敛为终结", async () => {
const sentAt = Date.now() + 5000;
// 顺序一:先终结后更新——更新在全序中更旧,必须被丢弃
const channelA = nextChannelName();
const receiverA = openSync(channelA);
const probeA = openProbe(channelA);
probeA.channel.postMessage({ sentAt, sourceId: "tab-a", type: "session-ended" });
probeA.channel.postMessage({
sentAt,
session: { accessToken: "stale" },
sourceId: "tab-b",
type: "session-updated",
});
// 收尾标记:等它被处理即可确认前两条走完了判定
probeA.channel.postMessage({
sentAt: sentAt + 1,
sourceId: "tab-a",
type: "session-ended",
});
await until(() => receiverA.recorder.endedEvents.length === 2);
expect(receiverA.recorder.updatedSessions).toHaveLength(0);
// 顺序二:先更新后终结——终结在全序中更新,必须被接受,净效果同样是终结
const channelB = nextChannelName();
const receiverB = openSync(channelB);
const probeB = openProbe(channelB);
probeB.channel.postMessage({
sentAt,
session: { accessToken: "stale" },
sourceId: "tab-b",
type: "session-updated",
});
probeB.channel.postMessage({ sentAt, sourceId: "tab-a", type: "session-ended" });
await until(() => receiverB.recorder.endedEvents.length === 1);
});
it("同毫秒不同来源的两个更新按确定性规则收敛到同一份会话", async () => {
const sentAt = Date.now() + 5000;
// 顺序一:来源序高的先到,序低的后到必须被丢弃
const channelA = nextChannelName();
const receiverA = openSync(channelA);
const probeA = openProbe(channelA);
probeA.channel.postMessage({
sentAt,
session: { accessToken: "session-bb" },
sourceId: "tab-bb",
type: "session-updated",
});
probeA.channel.postMessage({
sentAt,
session: { accessToken: "session-aa" },
sourceId: "tab-aa",
type: "session-updated",
});
probeA.channel.postMessage({
sentAt: sentAt + 1,
sourceId: "tab-bb",
type: "session-ended",
});
await until(() => receiverA.recorder.endedEvents.length === 1);
expect(receiverA.recorder.updatedSessions.map((s) => s.accessToken)).toEqual([
"session-bb",
]);
// 顺序二:序低的先到、序高的后到,最终生效的必须是同一份(序高者)
const channelB = nextChannelName();
const receiverB = openSync(channelB);
const probeB = openProbe(channelB);
probeB.channel.postMessage({
sentAt,
session: { accessToken: "session-aa" },
sourceId: "tab-aa",
type: "session-updated",
});
probeB.channel.postMessage({
sentAt,
session: { accessToken: "session-bb" },
sourceId: "tab-bb",
type: "session-updated",
});
await until(() => receiverB.recorder.updatedSessions.length === 2);
expect(receiverB.recorder.updatedSessions.at(-1)?.accessToken).toBe("session-bb");
});
it("dispose 之后既不再接收也不再发布", async () => {
const channelName = nextChannelName();
const alive = openSync(channelName);
const disposed = openSync(channelName);
const probe = openProbe(channelName);
disposed.sync.dispose();
alive.sync.publishSessionEnded();
await until(() => probe.events.length === 1);
expect(disposed.recorder.endedEvents).toHaveLength(0);
// 已释放实例的发布应当静默失效,而不是抛错或继续上线
disposed.sync.publishSessionUpdated({ accessToken: "ghost" });
probe.channel.postMessage({
sentAt: Date.now() + 60_000,
sourceId: "marker",
type: "session-ended",
});
await until(() => alive.recorder.endedEvents.length === 1);
expect(alive.recorder.updatedSessions).toHaveLength(0);
});
it("连续发布的事件时间戳严格递增", async () => {
const channelName = nextChannelName();
const publisher = openSync(channelName);
const probe = openProbe(channelName);
publisher.sync.publishSessionEnded();
publisher.sync.publishSessionEnded();
await until(() => probe.events.length === 2);
expect(probe.events[1]!.sentAt).toBeGreaterThan(probe.events[0]!.sentAt);
expect(probe.events[0]!.sourceId).toBe(probe.events[1]!.sourceId);
});
it("环境缺少 BroadcastChannel 时降级为空实现", () => {
vi.stubGlobal("BroadcastChannel", undefined);
const recorder = createRecorder();
const sync = createSessionSync<TestSession>("no-channel", recorder.handlers);
expect(() => {
sync.publishSessionUpdated({ accessToken: "noop" });
sync.publishSessionEnded();
sync.dispose();
}).not.toThrow();
});
});ts
import { beforeEach, expect, it, vi } from "vitest";
import { HttpError } from "../src/api/http/errors";
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}));
vi.mock("../src/api/http", () => ({
http: { post },
}));
import {
createUser,
UserAlreadyExistsError,
} from "../src/api/modules/users";
beforeEach(() => {
post.mockReset();
});
it("converts a silent 409 into a user-domain error", async () => {
const httpError = new HttpError({
kind: "http",
status: 409,
message: "HTTP request failed with status 409",
});
post.mockRejectedValue(httpError);
await expect(createUser({ name: "Ada" })).rejects.toMatchObject({
name: "UserAlreadyExistsError",
cause: httpError,
});
expect(post).toHaveBeenCalledWith(
"/users",
{ name: "Ada" },
{ errorMode: "silent" },
);
expect(UserAlreadyExistsError).toBeDefined();
});
it("keeps other silent failures available for page-level feedback", async () => {
const httpError = new HttpError({
kind: "http",
status: 500,
message: "HTTP request failed with status 500",
});
post.mockRejectedValue(httpError);
await expect(createUser({ name: "Ada" })).rejects.toBe(httpError);
});ts
import type { AxiosResponse } from "axios";
import type { HttpClient } from "../src/api/http/client";
// The entry re-exports types so call sites need a single import path.
import type { HttpClient as EntryHttpClient } from "../src/api/http/index";
// @ts-expect-error The entry exposes no factory, so no second client can be built.
import { createHttpClient as hiddenFactory } from "../src/api/http/index";
interface User {
id: number;
name: string;
}
interface CreateUserInput {
name: string;
}
declare const http: HttpClient;
if (false) {
const user: Promise<User> = http.get<User>("/users/1");
const createdUser: Promise<User> = http.post<User, CreateUserInput>(
"/users",
{
name: "Ada",
},
);
// @ts-expect-error CreateUserInput.name must be a string.
http.post<User, CreateUserInput>("/users", { name: 123 });
// @ts-expect-error Swapping Result and Body makes the request body invalid.
http.post<CreateUserInput, User>("/users", { name: "Ada" });
// @ts-expect-error Request sharing is not part of the Axios core.
http.get<User>("/users/1", { dedupe: true });
// @ts-expect-error A business request cannot replace the trusted base URL.
http.get<User>("/users/1", { baseURL: "https://evil.example" });
// @ts-expect-error Cookie policy is fixed by the client factory.
http.get<User>("/users/1", { withCredentials: true });
// @ts-expect-error Success is decided by HTTP status, not by the call site.
http.get<User>("/users/1", { validateStatus: () => true });
// @ts-expect-error The transport layer belongs to this module.
http.get<User>("/users/1", { adapter: () => Promise.reject(new Error()) });
// @ts-expect-error Response bodies must reach the envelope adapter unmodified.
http.get<User>("/users/1", { transformResponse: [] });
// @ts-expect-error Serialization is configured once by the client factory.
http.get<User>("/users/1", { paramsSerializer: () => "" });
// 白名单内的请求描述字段仍然可用。
const allowed: Promise<Blob> = http.get<Blob>("/reports/1", {
params: { format: "pdf" },
headers: { "x-trace": "1" },
responseType: "blob",
timeout: 30_000,
signal: new AbortController().signal,
onDownloadProgress: () => {},
});
const rawResponse: Promise<AxiosResponse<string, CreateUserInput>> = http.raw<
string,
CreateUserInput
>({
method: "post",
url: "/reports",
data: { name: "Ada" },
});
http.resetAuthState();
void user;
void createdUser;
void rawResponse;
void allowed;
void hiddenFactory;
void (undefined as unknown as EntryHttpClient);
}ts
import {
createServer,
type Server,
type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test, type Page } from "@playwright/test";
const appOrigin = "http://localhost:5181";
let apiServer: Server;
let apiBaseURL: string;
let currentAccessToken = "";
let refreshCount = 0;
const refreshCookies: Array<string | undefined> = [];
const businessCookies: Array<string | undefined> = [];
let uploadContentType = "";
let uploadBody = "";
const browserErrors = new WeakMap<Page, string[]>();
function writeCors(response: ServerResponse) {
response.setHeader("access-control-allow-origin", appOrigin);
response.setHeader("access-control-allow-credentials", "true");
response.setHeader(
"access-control-allow-headers",
"authorization, content-type",
);
response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
response.setHeader(
"access-control-expose-headers",
"content-disposition, content-type",
);
}
function sendJson(
response: ServerResponse,
status: number,
body: unknown,
) {
writeCors(response);
response.statusCode = status;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(body));
}
test.beforeAll(async () => {
apiServer = createServer(async (request, response) => {
writeCors(response);
if (request.method === "OPTIONS") {
response.statusCode = 204;
response.end();
return;
}
if (request.url === "/auth/seed") {
response.setHeader(
"set-cookie",
"refresh=r1; HttpOnly; Secure; SameSite=Lax; Path=/",
);
response.statusCode = 204;
response.end();
return;
}
if (request.url === "/auth/refresh") {
refreshCount += 1;
refreshCookies.push(request.headers.cookie);
currentAccessToken = `access-${refreshCount}`;
response.setHeader(
"set-cookie",
`refresh=r${refreshCount + 1}; HttpOnly; Secure; SameSite=Lax; Path=/`,
);
sendJson(response, 200, {
code: 0,
message: "ok",
data: { accessToken: currentAccessToken },
});
return;
}
if (request.url === "/business") {
businessCookies.push(request.headers.cookie);
if (
request.headers.authorization !== `Bearer ${currentAccessToken}` ||
!currentAccessToken
) {
sendJson(response, 401, {
code: 40100,
message: "expired",
data: null,
});
return;
}
sendJson(response, 200, {
code: 0,
message: "ok",
data: currentAccessToken,
});
return;
}
if (request.url === "/file") {
response.statusCode = 200;
response.setHeader("content-type", "text/csv");
response.setHeader(
"content-disposition",
"attachment; filename*=UTF-8''%E6%9C%88%E6%8A%A5.csv",
);
response.end("name\nAda");
return;
}
if (request.url === "/slow-file") {
await new Promise((resolve) => setTimeout(resolve, 150));
response.statusCode = 200;
response.setHeader("content-type", "application/octet-stream");
response.end("slow");
return;
}
if (request.url === "/file-error") {
sendJson(response, 400, {
code: 40012,
message: "文件格式错误",
data: null,
});
return;
}
if (request.url === "/upload") {
uploadContentType = request.headers["content-type"] ?? "";
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
uploadBody = Buffer.concat(chunks).toString("utf8");
sendJson(response, 200, {
code: 0,
message: "ok",
data: { uploaded: true },
});
return;
}
if (request.url === "/slow-upload") {
await new Promise((resolve) => setTimeout(resolve, 150));
sendJson(response, 200, {
code: 0,
message: "ok",
data: { uploaded: true },
});
return;
}
response.statusCode = 404;
response.end();
});
await new Promise<void>((resolve) => {
apiServer.listen(0, "localhost", resolve);
});
const address = apiServer.address() as AddressInfo;
apiBaseURL = `http://localhost:${address.port}`;
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) => {
apiServer.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
apiServer.closeAllConnections();
});
});
test.beforeEach(async ({ page }) => {
const errors: string[] = [];
browserErrors.set(page, errors);
page.on("console", (message) => {
if (
message.type() === "error" &&
!message.text().startsWith("Failed to load resource:")
) {
errors.push(message.text());
}
});
page.on("pageerror", (error) => {
errors.push(error.message);
});
await page.goto("/browser/");
await expect(page.locator("main")).toHaveText("Axios browser verification");
});
test.afterEach(async ({ page }) => {
expect(browserErrors.get(page)).toEqual([]);
});
test("keeps refresh cookies out of JavaScript and business requests", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.runAuth(baseURL);
}, apiBaseURL);
expect(result).toEqual({
first: "access-1",
second: "access-2",
expireCount: 0,
visibleCookie: "",
});
expect(refreshCookies).toEqual(["refresh=r1", "refresh=r2"]);
expect(businessCookies.every((cookie) => cookie === undefined)).toBe(true);
});
test("downloads a Blob, sanitizes its name, and always revokes its URL", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.downloadBlob(baseURL);
}, apiBaseURL);
expect(result).toEqual({
filename: "月报.csv",
size: 8,
created: ["8"],
revoked: ["blob:test-file"],
clicked: 1,
anchors: 0,
});
});
test("uses a browser direct download without leaving an anchor behind", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.downloadDirect(baseURL);
}, apiBaseURL);
expect(result.clickedHref).toBe(`${apiBaseURL}/large-file`);
expect(result.clickedDownload).toBe("_report.csv");
expect(result.anchors).toBe(0);
});
test("rejects executable direct-download URLs before creating an anchor", async ({
page,
}) => {
const result = await page.evaluate(() => {
return window.browserHarness.rejectUnsafeDirectDownload();
});
expect(result).toEqual({
kind: "configuration",
anchors: 0,
});
await page.waitForTimeout(50);
expect(
await page.evaluate(
() =>
(
window as typeof window & {
__unsafeDownloadExecuted?: number;
}
).__unsafeDownloadExecuted,
),
).toBe(0);
});
test("cancels a Blob download without global display or file creation", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.cancelBlob(baseURL);
}, apiBaseURL);
expect(result).toEqual({
kind: "cancel",
errorCount: 0,
anchors: 0,
});
});
test("recovers a JSON API error returned through a Blob response", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.readBlobError(baseURL);
}, apiBaseURL);
expect(result).toEqual({
kind: "http",
status: 400,
message: "HTTP request failed with status 400",
displayed: ["文件格式错误"],
});
});
test("lets the browser generate a multipart boundary and sends all fields", async ({
page,
}) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.upload(baseURL);
}, apiBaseURL);
expect(result).toEqual({ uploaded: true });
expect(uploadContentType).toMatch(
/^multipart\/form-data;\s*boundary=.+/i,
);
expect(uploadBody).toContain('name="file"; filename="hello.txt"');
expect(uploadBody).toContain("hello file");
expect(uploadBody).toContain('name="title"');
expect(uploadBody).toContain("greeting");
});
test("cancels an upload without displaying a global error", async ({ page }) => {
const result = await page.evaluate(async (baseURL) => {
return window.browserHarness.cancelUpload(baseURL);
}, apiBaseURL);
expect(result).toEqual({
kind: "cancel",
errorCount: 0,
});
});ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./browser-tests",
fullyParallel: false,
workers: 1,
use: {
baseURL: "http://localhost:5181",
channel: "chrome",
headless: true,
},
webServer: {
command: "pnpm vite --host localhost --port 5181 --strictPort",
url: "http://localhost:5181/browser/",
reuseExistingServer: false,
},
});json
{
"name": "axios-http-module-poc",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:browser": "playwright test",
"check": "pnpm typecheck && pnpm test && pnpm test:browser"
},
"packageManager": "pnpm@10.26.2",
"dependencies": {
"axios": "^1.18.1"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/node": "^26.1.1",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}