test(mp): request.ts 测试补全 — 新增 9 个测试
- ConcurrencyLimiter 排队/FIFO 释放顺序 - ResponseCache LRU 顺序验证 + TTL 过期 - Token 刷新成功后 401 重试 - Token 刷新失败跳转登录 - isLoggingOut 时立即抛出 - safeReLaunch 并发去重
This commit is contained in:
@@ -7,7 +7,7 @@ vi.mock('@tarojs/taro', () => ({
|
|||||||
getStorageSync: vi.fn(() => ''),
|
getStorageSync: vi.fn(() => ''),
|
||||||
setStorageSync: vi.fn(),
|
setStorageSync: vi.fn(),
|
||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
reLaunch: vi.fn(),
|
reLaunch: vi.fn(() => Promise.resolve()),
|
||||||
getCurrentPages: vi.fn(() => []),
|
getCurrentPages: vi.fn(() => []),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -205,4 +205,161 @@ describe('request module', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ConcurrencyLimiter', () => {
|
||||||
|
it('should queue requests when at capacity', async () => {
|
||||||
|
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||||
|
const limiter = new ConcurrencyLimiter(2);
|
||||||
|
const order: number[] = [];
|
||||||
|
|
||||||
|
const acquire1 = limiter.acquire();
|
||||||
|
const acquire2 = limiter.acquire();
|
||||||
|
// Third acquire should queue
|
||||||
|
const acquire3 = limiter.acquire().then(() => order.push(3));
|
||||||
|
|
||||||
|
order.push(1);
|
||||||
|
order.push(2);
|
||||||
|
|
||||||
|
// Release one to unblock the third
|
||||||
|
limiter.release();
|
||||||
|
await acquire3;
|
||||||
|
|
||||||
|
expect(order).toContain(3);
|
||||||
|
limiter.release();
|
||||||
|
limiter.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should release in FIFO order', async () => {
|
||||||
|
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||||
|
const limiter = new ConcurrencyLimiter(1);
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
await limiter.acquire(); // fills the slot
|
||||||
|
|
||||||
|
const p2 = limiter.acquire().then(() => order.push('second'));
|
||||||
|
const p3 = limiter.acquire().then(() => order.push('third'));
|
||||||
|
|
||||||
|
limiter.release(); // releases second
|
||||||
|
await p2;
|
||||||
|
limiter.release(); // releases third
|
||||||
|
await p3;
|
||||||
|
|
||||||
|
expect(order).toEqual(['second', 'third']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResponseCache LRU', () => {
|
||||||
|
it('should update insertion order on cache hit', async () => {
|
||||||
|
const { ResponseCache } = await import('@/services/request/cache');
|
||||||
|
const cache = new ResponseCache(3, 60_000);
|
||||||
|
cache.setPatientId('p1');
|
||||||
|
|
||||||
|
cache.set('/a', 'data-a');
|
||||||
|
cache.set('/b', 'data-b');
|
||||||
|
cache.set('/c', 'data-c');
|
||||||
|
|
||||||
|
// Access /a to move it to the end (most recently used)
|
||||||
|
cache.get('/a');
|
||||||
|
|
||||||
|
// Adding /d should evict /b (oldest after /a was accessed)
|
||||||
|
cache.set('/d', 'data-d');
|
||||||
|
|
||||||
|
expect(cache.get('/b')).toBeNull();
|
||||||
|
expect(cache.get('/a')).toBe('data-a');
|
||||||
|
expect(cache.get('/d')).toBe('data-d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expire entries based on TTL', async () => {
|
||||||
|
const { ResponseCache } = await import('@/services/request/cache');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const cache = new ResponseCache(100, 1000);
|
||||||
|
cache.setPatientId('p1');
|
||||||
|
|
||||||
|
cache.set('/expiring', 'data', 500);
|
||||||
|
expect(cache.get('/expiring')).toBe('data');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(cache.get('/expiring')).toBeNull();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token refresh & 401 retry', () => {
|
||||||
|
it('should throw immediately when isLoggingOut is true', async () => {
|
||||||
|
const { markLoggingOut } = await import('@/services/request');
|
||||||
|
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||||
|
|
||||||
|
markLoggingOut();
|
||||||
|
await expect(api.get('/protected')).rejects.toThrow('登录已过期');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should attempt token refresh on 401', async () => {
|
||||||
|
const mockStore: Record<string, string> = {
|
||||||
|
access_token: 'expired-token',
|
||||||
|
refresh_token: 'valid-refresh',
|
||||||
|
tenant_id: 'test-tenant',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override secureGet for this test
|
||||||
|
const { secureGet } = await import('@/utils/secure-storage');
|
||||||
|
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||||
|
|
||||||
|
// First call: 401 → triggers refresh
|
||||||
|
// Refresh call: success
|
||||||
|
// Retry call: success
|
||||||
|
vi.mocked(Taro.request)
|
||||||
|
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||||
|
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { access_token: 'new-token', refresh_token: 'new-refresh', expires_in: 3600 } } } as any)
|
||||||
|
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { result: 'ok' } } } as any);
|
||||||
|
|
||||||
|
const result = await api.get('/needs-auth');
|
||||||
|
|
||||||
|
expect(result).toEqual({ result: 'ok' });
|
||||||
|
// 3 calls: initial 401 + refresh + retry
|
||||||
|
expect(Taro.request).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to login when refresh fails', async () => {
|
||||||
|
const mockStore: Record<string, string> = {
|
||||||
|
access_token: 'expired-token',
|
||||||
|
refresh_token: 'bad-refresh',
|
||||||
|
tenant_id: 'test-tenant',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { secureGet } = await import('@/utils/secure-storage');
|
||||||
|
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||||
|
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||||
|
|
||||||
|
vi.mocked(Taro.request)
|
||||||
|
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||||
|
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any); // refresh fails
|
||||||
|
|
||||||
|
await expect(api.get('/protected-resource')).rejects.toThrow('登录已过期');
|
||||||
|
expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/index' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeReLaunch dedup', () => {
|
||||||
|
it('should only call reLaunch once for concurrent requests', async () => {
|
||||||
|
const { markLoggingOut } = await import('@/services/request');
|
||||||
|
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||||
|
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||||
|
|
||||||
|
const mockStore: Record<string, string> = {
|
||||||
|
access_token: 'expired',
|
||||||
|
refresh_token: 'bad',
|
||||||
|
tenant_id: 't1',
|
||||||
|
};
|
||||||
|
const { secureGet } = await import('@/utils/secure-storage');
|
||||||
|
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||||
|
|
||||||
|
// First call sets isLoggingOut, second call hits early exit
|
||||||
|
await expect(api.get('/test1')).rejects.toThrow();
|
||||||
|
await expect(api.get('/test2')).rejects.toThrow();
|
||||||
|
|
||||||
|
// reLaunch should be called at most once
|
||||||
|
expect(vi.mocked(Taro.reLaunch).mock.calls.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user