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: {