From c81c3b73d0df67119d7e4426d52afa4d10ae5f42 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 22 May 2026 12:14:42 +0800 Subject: [PATCH] =?UTF-8?q?test(mp):=20request.ts=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20=E2=80=94=20=E6=96=B0=E5=A2=9E=209=20?= =?UTF-8?q?=E4=B8=AA=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConcurrencyLimiter 排队/FIFO 释放顺序 - ResponseCache LRU 顺序验证 + TTL 过期 - Token 刷新成功后 401 重试 - Token 刷新失败跳转登录 - isLoggingOut 时立即抛出 - safeReLaunch 并发去重 --- .../__tests__/services/request.test.ts | 159 +++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/apps/miniprogram/__tests__/services/request.test.ts b/apps/miniprogram/__tests__/services/request.test.ts index fca70ed..d500b6e 100644 --- a/apps/miniprogram/__tests__/services/request.test.ts +++ b/apps/miniprogram/__tests__/services/request.test.ts @@ -7,7 +7,7 @@ vi.mock('@tarojs/taro', () => ({ getStorageSync: vi.fn(() => ''), setStorageSync: vi.fn(), showToast: vi.fn(), - reLaunch: vi.fn(), + reLaunch: vi.fn(() => Promise.resolve()), 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 = { + 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); + }); + }); });