Files
hms/apps/miniprogram/__tests__/services/request.test.ts
iven c81c3b73d0 test(mp): request.ts 测试补全 — 新增 9 个测试
- ConcurrencyLimiter 排队/FIFO 释放顺序
- ResponseCache LRU 顺序验证 + TTL 过期
- Token 刷新成功后 401 重试
- Token 刷新失败跳转登录
- isLoggingOut 时立即抛出
- safeReLaunch 并发去重
2026-05-22 12:14:42 +08:00

366 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock @tarojs/taro
vi.mock('@tarojs/taro', () => ({
default: {
request: vi.fn(),
getStorageSync: vi.fn(() => ''),
setStorageSync: vi.fn(),
showToast: vi.fn(),
reLaunch: vi.fn(() => Promise.resolve()),
getCurrentPages: vi.fn(() => []),
},
}));
// Mock secure-storage to use simple values
vi.mock('@/utils/secure-storage', () => ({
secureGet: vi.fn((key: string) => {
const store: Record<string, string> = { access_token: 'test-token', tenant_id: 'test-tenant' };
return store[key] || '';
}),
secureSet: vi.fn(),
secureRemove: vi.fn(),
}));
import Taro from '@tarojs/taro';
import { api, clearRequestCache, resetForTesting, setCachedPatientId } from '@/services/request';
describe('request module', () => {
beforeEach(() => {
vi.clearAllMocks();
resetForTesting();
});
describe('api.get', () => {
it('should call Taro.request with correct method and url', async () => {
const mockData = { success: true, data: { items: [] } };
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: mockData } as any);
const result = await api.get('/health/patients', { page: 1 });
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining('/health/patients?page=1'),
method: 'GET',
}),
);
});
it('should build query string from params', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: null } } as any);
await api.get('/test', { foo: 'bar', baz: 42 });
const call = vi.mocked(Taro.request).mock.calls[0][0];
expect(call.url).toContain('foo=bar');
expect(call.url).toContain('baz=42');
});
it('should skip undefined and empty params', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: null } } as any);
await api.get('/test', { foo: 'bar', empty: '', missing: undefined });
const call = vi.mocked(Taro.request).mock.calls[0][0];
expect(call.url).toContain('foo=bar');
expect(call.url).not.toContain('empty');
expect(call.url).not.toContain('missing');
});
it('should cache GET responses', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
const r1 = await api.get('/cached');
const r2 = await api.get('/cached');
expect(Taro.request).toHaveBeenCalledTimes(1);
expect(r1).toEqual(r2);
});
});
describe('api.post', () => {
it('should send POST with body', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: 'new' } } } as any);
await api.post('/health/patients', { name: 'Test' });
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
data: { name: 'Test' },
}),
);
});
});
describe('api.put', () => {
it('should send PUT with body', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
await api.put('/health/patients/p1', { name: 'Updated', version: 1 });
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({
method: 'PUT',
data: { name: 'Updated', version: 1 },
}),
);
});
});
describe('api.delete', () => {
it('should send DELETE request', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: null } } as any);
await api.delete('/health/patients/p1');
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'DELETE' }),
);
});
});
describe('error handling', () => {
it('should throw on 403 with permission error', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 403, data: {} } as any);
await expect(api.get('/forbidden')).rejects.toThrow('权限不足');
});
it('should throw on 500 with server error', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 500, data: {} } as any);
await expect(api.get('/error')).rejects.toThrow('服务器错误');
});
it('should throw on network error', async () => {
vi.mocked(Taro.request).mockRejectedValue({ errMsg: 'request:fail timeout' });
await expect(api.get('/timeout')).rejects.toThrow('网络超时');
});
it('should throw on API failure with message', async () => {
vi.mocked(Taro.request).mockResolvedValue({
statusCode: 200,
data: { success: false, message: '参数错误' },
} as any);
await expect(api.get('/bad-params')).rejects.toThrow('参数错误');
});
});
describe('ResponseCache', () => {
it('should cache GET responses and return cached on second call', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
await api.get('/cached-test');
await api.get('/cached-test');
expect(Taro.request).toHaveBeenCalledTimes(1);
});
it('should not cache POST requests', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
await api.post('/no-cache', { a: 1 });
await api.post('/no-cache', { a: 1 });
expect(Taro.request).toHaveBeenCalledTimes(2);
});
it('clearRequestCache should clear cached entries', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
await api.get('/clear-test');
clearRequestCache();
await api.get('/clear-test');
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('setCachedPatientId', () => {
it('should isolate cache entries by patient ID', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
setCachedPatientId('patient-A');
await api.get('/health/data');
setCachedPatientId('patient-B');
await api.get('/health/data');
// 不同 patient ID 应各自发请求(缓存隔离)
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('requestUnlimited', () => {
it('should bypass concurrency limiter', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: 'ok' } } as any);
const { requestUnlimited } = await import('@/services/request');
await requestUnlimited('GET', '/health/test');
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET' }),
);
});
});
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);
});
});
});