本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

625 lines
25 KiB

/**
* 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');
});
});