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 = { 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 = { 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 = { 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 = { 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); }); }); });