diff --git a/apps/miniprogram/__tests__/services/consultation.test.ts b/apps/miniprogram/__tests__/services/consultation.test.ts new file mode 100644 index 0000000..ccbb9f0 --- /dev/null +++ b/apps/miniprogram/__tests__/services/consultation.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import '../helpers/mock-api'; + +import { api } from '@/services/request'; +import { + listConsultations, + getSession, + listMessages, + sendMessage, + markSessionRead, +} from '@/services/consultation'; + +describe('consultation service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listConsultations', () => { + it('calls api.get with correct path', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: [], total: 0 }); + + await listConsultations(); + + expect(api.get).toHaveBeenCalledWith('/health/consultation-sessions', undefined); + }); + + it('passes pagination params', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: [], total: 0 }); + + await listConsultations({ page: 2, page_size: 10 }); + + expect(api.get).toHaveBeenCalledWith('/health/consultation-sessions', { + page: 2, + page_size: 10, + }); + }); + }); + + describe('getSession', () => { + it('calls api.get with session id', async () => { + const mock = { id: 's-1', status: 'active' } as any; + vi.mocked(api.get).mockResolvedValueOnce(mock); + + const result = await getSession('s-1'); + + expect(api.get).toHaveBeenCalledWith('/health/consultation-sessions/s-1'); + expect(result).toEqual(mock); + }); + }); + + describe('listMessages', () => { + it('calls api.get with session id and params', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: [], total: 0 }); + + await listMessages('s-1', { page: 1, page_size: 20 }); + + expect(api.get).toHaveBeenCalledWith( + '/health/consultation-sessions/s-1/messages', + { page: 1, page_size: 20 }, + ); + }); + + it('passes after_id for incremental loading', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: [], total: 0 }); + + await listMessages('s-1', { after_id: 'm-50' }); + + expect(api.get).toHaveBeenCalledWith( + '/health/consultation-sessions/s-1/messages', + expect.objectContaining({ after_id: 'm-50' }), + ); + }); + }); + + describe('sendMessage', () => { + it('calls api.post with session_id, content_type and content', async () => { + const mockMsg = { id: 'm-1', content: 'hello' } as any; + vi.mocked(api.post).mockResolvedValueOnce(mockMsg); + + const result = await sendMessage('s-1', 'hello'); + + expect(api.post).toHaveBeenCalledWith('/health/consultation-messages', { + session_id: 's-1', + content_type: 'text', + content: 'hello', + }); + expect(result).toEqual(mockMsg); + }); + + it('supports custom content_type', async () => { + vi.mocked(api.post).mockResolvedValueOnce({} as any); + + await sendMessage('s-1', '', 'html'); + + expect(api.post).toHaveBeenCalledWith('/health/consultation-messages', { + session_id: 's-1', + content_type: 'html', + content: '', + }); + }); + }); + + describe('markSessionRead', () => { + it('calls api.put with session id', async () => { + vi.mocked(api.put).mockResolvedValueOnce(undefined); + + await markSessionRead('s-1'); + + expect(api.put).toHaveBeenCalledWith('/health/consultation-sessions/s-1/read'); + }); + }); +}); diff --git a/apps/miniprogram/__tests__/services/health.test.ts b/apps/miniprogram/__tests__/services/health.test.ts new file mode 100644 index 0000000..ae80216 --- /dev/null +++ b/apps/miniprogram/__tests__/services/health.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import '../helpers/mock-api'; + +import { api } from '@/services/request'; +import { + inputVitalSign, + getTodaySummary, + getTrend, + listDailyMonitoring, + createDailyMonitoring, + findThreshold, + DEFAULT_THRESHOLDS, +} from '@/services/health'; + +describe('health service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getTodaySummary', () => { + it('calls api.get with correct path', async () => { + const mock = { blood_pressure: { systolic: 120, diastolic: 80, status: 'normal' } }; + vi.mocked(api.get).mockResolvedValueOnce(mock); + + const result = await getTodaySummary(); + + expect(api.get).toHaveBeenCalledWith('/health/vital-signs/today', {}); + expect(result).toEqual(mock); + }); + + it('passes patient_id when provided', async () => { + vi.mocked(api.get).mockResolvedValueOnce({}); + + await getTodaySummary('p-123'); + + expect(api.get).toHaveBeenCalledWith('/health/vital-signs/today', { patient_id: 'p-123' }); + }); + }); + + describe('inputVitalSign', () => { + it('maps blood_pressure to morning fields', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { + indicator_type: 'blood_pressure', + value: 0, + extra: { systolic: 130, diastolic: 85 }, + }); + + expect(api.post).toHaveBeenCalledWith( + '/health/patients/p-1/vital-signs', + expect.objectContaining({ + systolic_bp_morning: 130, + diastolic_bp_morning: 85, + }), + ); + }); + + it('maps blood_pressure_evening to evening fields', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { + indicator_type: 'blood_pressure_evening', + value: 0, + extra: { systolic: 125, diastolic: 80 }, + }); + + expect(api.post).toHaveBeenCalledWith( + '/health/patients/p-1/vital-signs', + expect.objectContaining({ + systolic_bp_evening: 125, + diastolic_bp_evening: 80, + }), + ); + }); + + it('maps heart_rate and rounds value', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { indicator_type: 'heart_rate', value: 72.5 }); + + expect(api.post).toHaveBeenCalledWith( + '/health/patients/p-1/vital-signs', + expect.objectContaining({ heart_rate: 73 }), + ); + }); + + it('maps spo2 and rounds value', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { indicator_type: 'spo2', value: 98.7 }); + + expect(api.post).toHaveBeenCalledWith( + '/health/patients/p-1/vital-signs', + expect.objectContaining({ spo2: 99 }), + ); + }); + + it('includes notes when provided', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { indicator_type: 'weight', value: 65, note: '饭后' }); + + expect(api.post).toHaveBeenCalledWith( + '/health/patients/p-1/vital-signs', + expect.objectContaining({ weight: 65, notes: '饭后' }), + ); + }); + + it('includes record_date as today', async () => { + vi.mocked(api.post).mockResolvedValueOnce({}); + + await inputVitalSign('p-1', { indicator_type: 'weight', value: 65 }); + + const callData = vi.mocked(api.post).mock.calls[0][1] as Record; + expect(callData.record_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('getTrend', () => { + it('calls api.get with indicator and range', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ indicator: 'systolic_bp', data_points: [] }); + + await getTrend('systolic_bp', '7d'); + + expect(api.get).toHaveBeenCalledWith('/health/vital-signs/trend', { + indicator: 'systolic_bp', + range: '7d', + }); + }); + }); + + describe('listDailyMonitoring', () => { + it('calls api.get with patient id and pagination', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: [], total: 0 }); + + await listDailyMonitoring('p-1', { page: 2, page_size: 10 }); + + expect(api.get).toHaveBeenCalledWith( + '/health/patients/p-1/daily-monitoring', + { page: 2, page_size: 10 }, + ); + }); + }); + + describe('createDailyMonitoring', () => { + it('calls api.post with monitoring data', async () => { + const input = { + patient_id: 'p-1', + record_date: '2026-05-13', + morning_bp_systolic: 130, + morning_bp_diastolic: 85, + }; + vi.mocked(api.post).mockResolvedValueOnce({ id: 'dm-1', ...input }); + + await createDailyMonitoring(input); + + expect(api.post).toHaveBeenCalledWith('/health/daily-monitoring', input); + }); + }); + + describe('findThreshold', () => { + it('finds matching active threshold', () => { + const result = findThreshold(DEFAULT_THRESHOLDS, 'systolic_bp', 'high', 'warning'); + expect(result).toBeDefined(); + expect(result!.threshold_value).toBe(140); + }); + + it('returns undefined for inactive threshold', () => { + const thresholds = [{ ...DEFAULT_THRESHOLDS[0], is_active: false }]; + const result = findThreshold(thresholds, 'systolic_bp', 'high', 'warning'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no match', () => { + const result = findThreshold(DEFAULT_THRESHOLDS, 'unknown_indicator', 'high'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/apps/miniprogram/__tests__/services/request.test.ts b/apps/miniprogram/__tests__/services/request.test.ts new file mode 100644 index 0000000..282ca71 --- /dev/null +++ b/apps/miniprogram/__tests__/services/request.test.ts @@ -0,0 +1,151 @@ +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 = { 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 } from '@/services/request'; + +describe('request module', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearRequestCache(); + }); + + 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('参数错误'); + }); + }); +}); diff --git a/apps/miniprogram/__tests__/setup.ts b/apps/miniprogram/__tests__/setup.ts new file mode 100644 index 0000000..8337dcc --- /dev/null +++ b/apps/miniprogram/__tests__/setup.ts @@ -0,0 +1,23 @@ +// Global setup for Taro runtime compatibility in tests +// Mock @tarojs/taro before any module loads it +import { vi } from 'vitest'; + +vi.mock('@tarojs/taro', () => ({ + default: { + getStorageSync: vi.fn(() => ''), + setStorageSync: vi.fn(), + removeStorageSync: vi.fn(), + showToast: vi.fn(), + hideToast: vi.fn(), + showLoading: vi.fn(), + hideLoading: vi.fn(), + request: vi.fn(), + reLaunch: vi.fn(), + navigateTo: vi.fn(), + redirectTo: vi.fn(), + switchTab: vi.fn(), + getCurrentPages: vi.fn(() => []), + }, +})); + +vi.mock('@tarojs/runtime', () => ({})); diff --git a/apps/miniprogram/vitest.config.ts b/apps/miniprogram/vitest.config.ts index 5bd72c4..5773965 100644 --- a/apps/miniprogram/vitest.config.ts +++ b/apps/miniprogram/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ environment: 'node', include: ['__tests__/**/*.test.ts'], globals: true, + setupFiles: ['__tests__/setup.ts'], }, resolve: { alias: {