Compare commits

...

3 Commits

Author SHA1 Message Date
iven
1d443ab894 chore(mp): package.json 添加 test/test:watch script 2026-05-22 12:15:24 +08:00
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
iven
5816ebb5e6 perf(mp): 缓存优化 — restore 条件清理 + LRU + 差异化 TTL
- auth store: clearRequestCache 仅在 user/roles/patient 变更时调用
- ResponseCache: get() 命中时 delete+set 更新 Map 顺序(真 LRU)
- 告警 API 缓存 TTL 10s,通知未读数 TTL 10s,其余保持 60s 默认
2026-05-22 12:11:59 +08:00
6 changed files with 175 additions and 9 deletions

View File

@@ -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<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);
});
});
});

View File

@@ -12,6 +12,8 @@
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx}'",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run --config e2e/vitest.config.ts",
"dev:h5": "dotenv -e .env.h5 -- taro build --type h5 --watch",
"build:h5": "dotenv -e .env.h5 -- taro build --type h5"

View File

@@ -22,5 +22,5 @@ export async function listPatientAlerts(patientId: string, params?: { status?: s
page: params?.page ?? 1,
page_size: params?.page_size ?? 20,
status: params?.status,
});
}, 10_000);
}

View File

@@ -8,5 +8,5 @@ export const notificationService = {
markAllRead: () =>
api.put('/messages/read-all'),
getUnreadCount: () =>
api.get('/messages/unread-count'),
api.get('/messages/unread-count', undefined, 10_000),
};

View File

@@ -28,9 +28,17 @@ export class ResponseCache {
}
get<T>(url: string): T | null {
const entry = this.cache.get(this.cacheKey(url));
if (entry && Date.now() < entry.expiry) return entry.data as T;
return null;
const key = this.cacheKey(url);
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() >= entry.expiry) {
this.cache.delete(key);
return null;
}
// LRU: 命中时更新插入顺序,使该条目移到末尾
this.cache.delete(key);
this.cache.set(key, entry);
return entry.data as T;
}
getInflight<T>(url: string): Promise<T> | null {

View File

@@ -109,9 +109,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setCachedPatientId(currentPatient.id);
}
// 状态有变化时清理请求缓存,避免返回过期数据
clearRequestCache();
// 跳过未变更的 set()
const cur = get();
const userChanged = cur.user?.id !== user?.id;
@@ -119,6 +116,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const patientChanged = cur.currentPatient?.id !== currentPatient?.id;
if (!userChanged && !rolesChanged && !patientChanged) return;
// 状态有变化时清理请求缓存,避免返回过期数据
clearRequestCache();
set({ user, roles, currentPatient });
},