测试: - secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界) - request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited) - mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败 UX 无障碍 (10 组件): - SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected - PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy - Loading/LoadingCard: role=status + aria-live=polite - EmptyState: role=status + aria-live=polite - ErrorState: role=alert + aria-live=assertive - TrendChart tooltip: role=tooltip + aria-live=polite - FormInput: aria-invalid + aria-label 焦点管理: - 新增 _focus-ring.scss mixin (focus + focus-visible) - 5 组件 SCSS 应用 focus-ring
209 lines
6.8 KiB
TypeScript
209 lines
6.8 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(),
|
|
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' }),
|
|
);
|
|
});
|
|
});
|
|
});
|