/** * TokenManager 单元测试 * * 覆盖重点是竞态与自激循环 —— 这些是线上最难复现、最容易写错的部分, * 必须用假定时器 + 假时钟才能真正验证(真实等待 270 秒不现实)。 * token.ts 不碰 DOM,因此 vitest 的 node 环境即可运行。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { configureTokenManager, setHostToken, getToken, hasProvider, ensureFresh, refreshNow, forceRefresh, resetTokenManager, isProtectedPath, } from '../src/token'; import { logger } from '../src/logger'; /** 从「现在」起推进假时钟(同时触发期间到期的定时器、并冲刷微任务) */ const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms); beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); resetTokenManager(); }); afterEach(() => { resetTokenManager(); vi.useRealTimers(); vi.restoreAllMocks(); }); describe('isProtectedPath - 与后端 SdkAuthFilter 对齐', () => { const cases: Array<[string, boolean]> = [ // 受保护:/ai/**(后端 SdkAuthFilter 拦截) ['http://localhost:9090/ai/chat?message=hi', true], ['http://localhost:9090/ai/chat/stream?message=hi', true], ['http://localhost:9090/ai/sdk/conversation/list', true], ['http://localhost:9090/ai/conversation/123/messages', true], // 显式放行:/ai/system-config/**(SecurityConfig 中 permitAll,不该带 Token) ['http://localhost:9090/ai/system-config/disclaimer', false], // 受保护:无尾斜杠写法与 /feedback、/attachment/upload ['http://localhost:9090/feedback', true], ['http://localhost:9090/feedback?x=1', true], ['http://localhost:9090/attachment/upload', true], // 不受保护 ['http://localhost:9090/category/tree', false], ['http://localhost:9090/category/list', false], ['http://localhost:9090/conversation/list', false], // 路径里只是「含」/ai/ 但实际不在 /ai/ 下时不能误判: // 旧实现用 includes('/ai/'),会把 SDK Token 白白发给非 SDK 端点 ['http://localhost:9090/conversation/list?ref=/ai/chat', false], // 无法解析的 URL 一律视为不受保护 ['not a url', false], ]; it.each(cases)('%s → %s', (url, expected) => { expect(isProtectedPath(url)).toBe(expected); }); }); describe('临期主动刷新', () => { it('expiresIn=20 时约在 18 秒后自动刷新(skew = 总寿命的 10%)', async () => { const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 20 })); configureTokenManager({ token: 'token-1', expiresIn: 20, getToken: provider }); await tick(17_000); expect(provider).not.toHaveBeenCalled(); // 未到临期点,不该提前打扰 provider await tick(1_000); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-2'); }); it('expiresIn=300 时临期阈值自适应为 30s,不会退化成每次请求都刷新', async () => { let calls = 0; const provider = vi.fn(async () => { calls++; return { token: `token-${calls}`, expiresIn: 300 }; }); configureTokenManager({ token: 'token-0', expiresIn: 300, getToken: provider }); // 后端允许的最短 ttl 恰好是 300s。若临期阈值固定为 5 分钟, // 300s 的 Token 会永远处于临期态 —— 定时器延迟算成 0,形成秒级无限刷新。 await tick(271_000); expect(provider).toHaveBeenCalledTimes(1); // 刷新后 Token 是全新的,连续 10 次请求前检查都不该再次刷新 for (let i = 0; i < 10; i++) await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); // 再推进到第二个临期点:只应多刷新一次,而不是成百上千次 await tick(300_000); expect(provider).toHaveBeenCalledTimes(2); }); it('未配置 getToken 时不排定时器,也不因临期而报错', async () => { configureTokenManager({ token: 'token-1', expiresIn: 300 }); expect(vi.getTimerCount()).toBe(0); await tick(271_000); await ensureFresh(); expect(getToken()).toBe('token-1'); }); it('未提供 expiresIn 时不排定时器(只能靠 401 驱动)', () => { const provider = vi.fn(async () => 'whatever'); configureTokenManager({ token: 'token-1', getToken: provider }); expect(vi.getTimerCount()).toBe(0); expect(hasProvider()).toBe(true); }); it('连成功刷新但 provider 返回同一个 Token 时,静默窗口内不再重复调用', async () => { const provider = vi.fn(async () => 'same-token'); configureTokenManager({ token: 'same-token', expiresIn: 300, getToken: provider }); await tick(271_000); // 定时器触发一次刷新 expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('same-token'); // provider 换了半天还是同一个 Token,说明它已失效:静默窗口内不再打扰 await refreshNow('request'); expect(provider).toHaveBeenCalledTimes(1); }); it('自主刷新连续失败 3 次后停掉定时器链', async () => { const provider = vi.fn(async () => { throw new Error('provider 挂了'); }); vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider }); await tick(271_000); // 第 1 次失败 await tick(61_000); // 退避 60s 后第 2 次 await tick(61_000); // 第 3 次 const afterThree = provider.mock.calls.length; expect(afterThree).toBe(3); // 已达上限:定时器链停止,不再有任何自主调用 await tick(600_000); expect(provider).toHaveBeenCalledTimes(afterThree); }); }); describe('single-flight 与并发', () => { it('并发请求共享同一次刷新,provider 只被调用一次', async () => { let settleProvider!: (value: unknown) => void; const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; })); // expiresIn=2 → 临期阈值 200ms → 定时器在 1800ms 触发自主刷新 configureTokenManager({ token: 'token-0', expiresIn: 2, getToken: provider }); await tick(1_800); expect(provider).toHaveBeenCalledTimes(1); // 自主刷新已在途(provider 尚未 settle) // 刷新在途期间三个并发请求,都应搭上同一次刷新而不是各发一次 const inFlight = [ensureFresh(), ensureFresh(), ensureFresh()]; settleProvider({ token: 'token-1', expiresIn: 2 }); await Promise.all(inFlight); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-1'); }); it('自主刷新有最小间隔节流,不会在同一个窗口内反复打点', async () => { const provider = vi.fn(async () => ({ token: `t-${Date.now()}`, expiresIn: 300 })); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider }); await refreshNow('timer'); expect(provider).toHaveBeenCalledTimes(1); // 5 秒内再次请求自主刷新 → 被节流 await tick(1_000); await refreshNow('timer'); expect(provider).toHaveBeenCalledTimes(1); await tick(5_000); await refreshNow('timer'); expect(provider).toHaveBeenCalledTimes(2); }); }); describe('provider 返回值与异常处理', () => { const invalidProviders: Array<[string, () => unknown]> = [ ['空字符串', () => ''], ['纯空白字符串', () => ' '], ['null', () => null], ['数字', () => 123], ['缺少 token 字段的对象', () => ({ expiresIn: 300 })], ['token 为空串的对象', () => ({ token: '' })], ['同步抛错', () => { throw new Error('boom'); }], ]; it.each(invalidProviders)('provider 返回%s时保持原 Token 不变', async (_name, impl) => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: impl as never }); await refreshNow('request'); // 绝不能变成空串 —— 那会让请求带上「Bearer 」空值,白跑一次往返 expect(getToken()).toBe('token-ok'); }); it('provider 返回 reject 时保持原 Token 不变', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => { throw new Error('网络不可达'); }); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider }); await refreshNow('request'); expect(getToken()).toBe('token-ok'); }); it('provider 永不 settle 时有超时兜底,不会挂起整个 SDK', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(() => new Promise(() => { /* 永不 settle */ })); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider }); const pending = refreshNow('request'); await tick(10_000); // PROVIDER_TIMEOUT_MS await expect(pending).resolves.toBeNull(); expect(getToken()).toBe('token-ok'); }); it('刷新失败后刷新锁被清空,且退避窗口一过就能成功刷新', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); let shouldFail = true; const provider = vi.fn(async () => { if (shouldFail) throw new Error('首次失败'); return { token: 'token-new', expiresIn: 300 }; }); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider }); await expect(refreshNow('request')).resolves.toBeNull(); // 退避窗口内(60s):即使 provider 已恢复也不再尝试,先安静下来 shouldFail = false; await expect(refreshNow('request')).resolves.toBeNull(); expect(provider).toHaveBeenCalledTimes(1); // 退避窗口过后:刷新锁确实被清空了,能正常成功 await tick(60_000); await expect(refreshNow('request')).resolves.toBe('token-new'); expect(getToken()).toBe('token-new'); }); it('失败上报有 30 秒同码冷却,且退避窗口过后可再次上报', async () => { const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => { throw new Error('provider 挂了'); }); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider }); await refreshNow('request'); await refreshNow('request'); await refreshNow('request'); expect(spy).toHaveBeenCalledTimes(1); // 同码冷却 + 退避窗口内不再尝试 expect(spy).toHaveBeenLastCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed'); // 60s 后同时越过「退避窗口(60s)」与「上报冷却(30s)」,可以再试一次并再次上报 await tick(60_000); await refreshNow('request'); expect(spy).toHaveBeenCalledTimes(2); }); }); describe('宿主主动更新 Token', () => { it('setToken 立即生效,并按新有效期重排主动刷新', async () => { const provider = vi.fn(async () => ({ token: 'token-from-provider', expiresIn: 300 })); configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider }); setHostToken('token-host', 7200); expect(getToken()).toBe('token-host'); // 新有效期 7200s、临期阈值封顶 5 分钟 → 临期点 6900s await tick(6_890_000); expect(provider).not.toHaveBeenCalled(); await tick(10_000); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-from-provider'); }); it('刷新在途时 setToken,在途返回的旧值不会覆盖宿主刚推来的新值', async () => { let settleProvider!: (value: unknown) => void; const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; })); configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider }); const pending = refreshNow('request'); setHostToken('token-host', 300); // 宿主推送新值 settleProvider('token-from-provider'); // 在途刷新带着「旧」结果返回 await pending; expect(getToken()).toBe('token-host'); }); it('setToken 后定时器只保留一个(不会因为重复排期而叠加)', () => { const provider = vi.fn(async () => 'x'); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider }); expect(vi.getTimerCount()).toBe(1); setHostToken('token-2', 300); setHostToken('token-3', 300); expect(vi.getTimerCount()).toBe(1); expect(getToken()).toBe('token-3'); }); it('传入无效 Token 时忽略并保持原值', () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); configureTokenManager({ token: 'token-ok', expiresIn: 300 }); setHostToken(''); setHostToken(' '); expect(getToken()).toBe('token-ok'); }); it('refreshToken() 仅在真的换到不同 Token 时返回 true', async () => { let next = 'token-1'; configureTokenManager({ token: 'token-1', getToken: async () => next }); await expect(forceRefresh()).resolves.toBe(false); // 换回来的还是同一个 next = 'token-2'; await expect(forceRefresh()).resolves.toBe(true); expect(getToken()).toBe('token-2'); }); it('未配置 getToken 时 refreshToken() 返回 false 而不报错', async () => { configureTokenManager({ token: 'token-1', expiresIn: 300 }); await expect(forceRefresh()).resolves.toBe(false); }); }); describe('destroy 后的状态清理', () => { it('清空 Token 与定时器,且在途刷新结算后不会复活状态', async () => { let settleProvider!: (value: unknown) => void; const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; })); configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider }); expect(vi.getTimerCount()).toBe(1); // 主动刷新定时器 const pending = refreshNow('request'); resetTokenManager(); expect(getToken()).toBeNull(); settleProvider({ token: 'token-late', expiresIn: 300 }); await pending; // 在途结果必须被丢弃,且不能重新排上主动刷新定时器 expect(getToken()).toBeNull(); await tick(600_000); // 推进远超临期点 expect(provider).toHaveBeenCalledTimes(1); // 只有那一次在途调用,没有新的自主刷新 expect(getToken()).toBeNull(); expect(vi.getTimerCount()).toBe(0); }); it('destroy 后 ensureFresh / refreshNow / setToken 均为安全空操作', async () => { await expect(ensureFresh()).resolves.toBeUndefined(); await expect(refreshNow('request')).resolves.toBeNull(); setHostToken('token-after-destroy'); // 不应抛错,也不该写入 expect(getToken()).toBeNull(); }); it('重新 configure 后可正常重入(destroy → init 场景)', async () => { const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 })); configureTokenManager({ token: 'token-1', expiresIn: 300 }); resetTokenManager(); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider }); await tick(271_000); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-2'); }); }); describe('配置项健壮性', () => { it('expiresIn 超过 24 小时上限时按上限钳制(防 setTimeout 溢出与单位误传)', async () => { const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 })); // 7200000 是把「毫秒」误当「秒」传的典型值 configureTokenManager({ token: 'token-1', expiresIn: 7_200_000, getToken: provider }); expect(spy).toHaveBeenCalledWith(expect.any(String), undefined, 'config_invalid'); // 上限 86400s、临期阈值封顶 5 分钟 → 临期点 86100s(而不是 7200000s) await tick(86_000_000); expect(provider).not.toHaveBeenCalled(); await tick(100_000); expect(provider).toHaveBeenCalledTimes(1); }); it('expiresIn 为 0 或负数时视为无过期信息(不排定时器)', () => { const provider = vi.fn(async () => 'x'); configureTokenManager({ token: 'token-1', expiresIn: 0, getToken: provider }); expect(vi.getTimerCount()).toBe(0); configureTokenManager({ token: 'token-1', expiresIn: -5, getToken: provider }); expect(vi.getTimerCount()).toBe(0); }); it('token 为空白串时视为未配置(等首个受保护请求自举)', () => { const provider = vi.fn(async () => 'x'); configureTokenManager({ token: ' ', expiresIn: 300, getToken: provider }); expect(getToken()).toBeNull(); expect(vi.getTimerCount()).toBe(0); expect(hasProvider()).toBe(true); }); it('自举:只配 getToken 不配 token 时,请求前检查即换来 Token', async () => { const provider = vi.fn(async () => ({ token: 'bootstrapped', expiresIn: 300 })); configureTokenManager({ getToken: provider }); expect(getToken()).toBeNull(); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('bootstrapped'); }); it('自举失败时不抛错,保持无 Token 状态', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => { throw new Error('宿主后端不可用'); }); configureTokenManager({ getToken: provider }); await expect(ensureFresh()).resolves.toBeUndefined(); expect(getToken()).toBeNull(); }); it('刷新失败后的退避窗口内,不再让每个请求都多等一次刷新', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => { throw new Error('宿主后端挂了'); }); // 无 token → 每次 ensureFresh 都会尝试自举,最容易暴露「每个请求都等一次超时」的问题 configureTokenManager({ getToken: provider }); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); // 退避窗口内:请求前检查直接放行,不再等刷新(否则 provider 挂死时每个请求都要多等 10s) await ensureFresh(); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); // 退避窗口过后恢复尝试 await tick(60_000); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(2); }); it('configureTokenManager 会清空上一轮状态(重复 init 不叠加)', () => { const provider = vi.fn(async () => 'x'); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider }); configureTokenManager({ token: 'token-2', expiresIn: 300 }); expect(getToken()).toBe('token-2'); expect(hasProvider()).toBe(false); expect(vi.getTimerCount()).toBe(0); }); }); describe("tokenStrategy: 'always' - 每请求前都向宿主取一次", () => { it('Token 新鲜时仍会取(SDK 不推算过期时间)', async () => { const provider = vi.fn(async () => ({ token: 'token-new', expiresIn: 7200 })); configureTokenManager({ token: 'token-old', expiresIn: 7200, getToken: provider, tokenStrategy: 'always' }); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-new'); // 第二次请求前同样会取 —— 这正是该模式与 'expiry' 的核心差别 await ensureFresh(); expect(provider).toHaveBeenCalledTimes(2); }); it('provider 反复返回同一 Token 也每次照取(静默窗口在该模式不生效)', async () => { const provider = vi.fn(async () => 'token-ok'); configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' }); await ensureFresh(); await ensureFresh(); await ensureFresh(); // 'expiry' 模式下第二次就会被静默窗口拦下;'always' 模式下宿主返回缓存 Token 是常态,不该拦 expect(provider).toHaveBeenCalledTimes(3); }); it('不排定时器:该模式不推算过期时间', async () => { const provider = vi.fn(async () => ({ token: 'x', expiresIn: 300 })); configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider, tokenStrategy: 'always' }); expect(vi.getTimerCount()).toBe(0); // 推进远超有效期的时长,也不该有自主刷新发生 await tick(10_000_000); expect(provider).not.toHaveBeenCalled(); }); it('不传 expiresIn 也能每请求前取(这正是要解决的缺口)', async () => { const provider = vi.fn(async () => ({ token: 'token-new' })); configureTokenManager({ getToken: provider, tokenStrategy: 'always' }); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); expect(getToken()).toBe('token-new'); }); it('取 Token 失败时沿用现有 Token 继续,且退避窗口内不再取', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); const provider = vi.fn(async () => { throw new Error('宿主后端挂了'); }); configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' }); await ensureFresh(); // 取不到新 Token 不该把旧 Token 清掉 —— 请求照常发出,由 401 兜底 expect(getToken()).toBe('token-ok'); expect(provider).toHaveBeenCalledTimes(1); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(1); // 熔断窗口内不再取 await tick(60_000); await ensureFresh(); expect(provider).toHaveBeenCalledTimes(2); }); }); describe('tokenUrl - 用地址代替 getToken 回调', () => { /** 构造返回给定响应的 fetch stub */ const stubFetch = (payload: unknown, opts: { ok?: boolean; status?: number } = {}) => { const fn = vi.fn(async () => ({ ok: opts.ok ?? true, status: opts.status ?? 200, json: async () => payload, })); vi.stubGlobal('fetch', fn); return fn; }; afterEach(() => { vi.unstubAllGlobals(); }); it('解析扁平的 { token, expiresIn } 响应,且用 same-origin 取 Cookie', async () => { const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 3600 }); configureTokenManager({ token: 'token-old', tokenUrl: 'https://host.example.com/api/sdk-token' }); await refreshNow('request'); expect(getToken()).toBe('token-from-url'); expect(fetchMock).toHaveBeenCalledWith( 'https://host.example.com/api/sdk-token', expect.objectContaining({ method: 'GET', credentials: 'same-origin' }) ); }); it('解析 { success, token, expiresIn, roles } 封装响应', async () => { stubFetch({ success: true, token: 'token-wrapped', expiresIn: 7200, roles: [{ id: '1', name: '客服' }] }); configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' }); await refreshNow('request'); expect(getToken()).toBe('token-wrapped'); }); it('HTTP 非 2xx 时视为刷新失败,保持原 Token', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); stubFetch({ message: 'not found' }, { ok: false, status: 404 }); configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' }); await expect(refreshNow('request')).resolves.toBeNull(); expect(getToken()).toBe('token-old'); }); it('响应缺少 token 字段时视为刷新失败,保持原 Token', async () => { vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); stubFetch({ success: true }); configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' }); await expect(refreshNow('request')).resolves.toBeNull(); expect(getToken()).toBe('token-old'); }); it('与 getToken 同时配置时 getToken 优先,不请求 tokenUrl', async () => { const fetchMock = stubFetch({ token: 'never-used' }); const provider = vi.fn(async () => ({ token: 'token-from-callback' })); configureTokenManager({ token: 'token-old', getToken: provider, tokenUrl: '/api/sdk-token' }); await refreshNow('request'); expect(provider).toHaveBeenCalledTimes(1); expect(fetchMock).not.toHaveBeenCalled(); expect(getToken()).toBe('token-from-callback'); }); it('配合 always 策略时可做到每请求前都走 URL 取 Token', async () => { const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 7200 }); configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token', tokenStrategy: 'always' }); await ensureFresh(); await ensureFresh(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); }); describe('forceRefresh 绕过自动触发的两道闸', () => { it('能绕过刷新失败后的退避(熔断)窗口', async () => { const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ }); let shouldFail = true; const provider = vi.fn(async () => { if (shouldFail) throw new Error('先失败一次'); return { token: 'token-new', expiresIn: 300 }; }); configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider }); await refreshNow('request'); // 失败 → 进入 60s 退避 expect(spy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed'); shouldFail = false; // 自动路径此时会被熔断拦下,但宿主显式要求刷新应当放行 await expect(refreshNow('request')).resolves.toBeNull(); await expect(forceRefresh()).resolves.toBe(true); expect(getToken()).toBe('token-new'); }); });