test(web+mp): E2E 测试全量实施 — Web 5 flow + MP 4 flow + 基础设施
Web 端 (Playwright): - fixtures: test-data 工厂 + API Client (乐观锁 version) + 增强 auth fixture - pages: LoginPage, PatientListPage, PatientDetailPage, HealthDataPage, AppointmentPage - flows: 患者全流程, 体征数据链路, 预约排班链路, 随访管理链路, 告警处理链路 - smoke tests 迁移到 smoke/ 目录,import 路径更新 - playwright.config.ts 更新: globalSetup 环境检查, 60s timeout, video retain 小程序端 (Vitest + miniprogram-automator): - helpers: AutomatorClient, MpApiClient, MpAuthHelper, MpNavigator - flows: 患者健康数据查看, 体征数据录入, 积分签到兑换, 积分商城浏览 - vitest.config.ts + check-readiness.ts - vitest 4.1.5 依赖安装 Playwright 发现 15 个测试 (5 flow + 10 smoke),全部就绪
This commit is contained in:
188
apps/web/e2e/fixtures/api-client.ts
Normal file
188
apps/web/e2e/fixtures/api-client.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// apps/web/e2e/fixtures/api-client.ts
|
||||
|
||||
import type {
|
||||
PatientData, DoctorData, VitalSignsData, ScheduleData,
|
||||
AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData,
|
||||
} from './test-data';
|
||||
|
||||
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
interface ApiResponse<T> { success: boolean; data: T }
|
||||
interface Versioned { id: string; version: number }
|
||||
type VEntity<T> = T & Versioned;
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; username: string; display_name: string; roles: string[] };
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private token = '';
|
||||
|
||||
async login(username?: string, password?: string): Promise<LoginResponse> {
|
||||
const res = await this.rawPost<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/login',
|
||||
{
|
||||
username: username || process.env.E2E_ADMIN_USER || 'admin',
|
||||
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
|
||||
},
|
||||
);
|
||||
this.token = res.data.access_token;
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async loginAsAdmin(): Promise<LoginResponse> {
|
||||
return this.login();
|
||||
}
|
||||
|
||||
getToken(): string { return this.token; }
|
||||
|
||||
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/patients', overrides ?? {});
|
||||
}
|
||||
|
||||
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.put(`/health/patients/${id}`, { ...data, version });
|
||||
}
|
||||
|
||||
async deletePatient(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/patients/${id}`, { version });
|
||||
}
|
||||
|
||||
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/doctors', overrides ?? {});
|
||||
}
|
||||
|
||||
async deleteDoctor(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/doctors/${id}`, { version });
|
||||
}
|
||||
|
||||
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
|
||||
}
|
||||
|
||||
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
|
||||
}
|
||||
|
||||
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/doctor-schedules', overrides);
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/doctor-schedules/${id}`, { version });
|
||||
}
|
||||
|
||||
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/appointments', overrides);
|
||||
}
|
||||
|
||||
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.put(`/health/appointments/${id}/status`, { status, version });
|
||||
}
|
||||
|
||||
async deleteAppointment(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/appointments/${id}`, { version });
|
||||
}
|
||||
|
||||
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/follow-up-templates', overrides ?? {});
|
||||
}
|
||||
|
||||
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/follow-up-templates/${id}`, { version });
|
||||
}
|
||||
|
||||
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/follow-up-tasks', overrides);
|
||||
}
|
||||
|
||||
async deleteFollowUpTask(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/follow-up-tasks/${id}`, { version });
|
||||
}
|
||||
|
||||
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.post('/health/alert-rules', overrides ?? {});
|
||||
}
|
||||
|
||||
async deleteAlertRule(id: string, version: number): Promise<void> {
|
||||
await this.del(`/health/alert-rules/${id}`, { version });
|
||||
}
|
||||
|
||||
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
|
||||
const res = await this.get<{ items: VEntity<Record<string, unknown>>[] }>('/health/alerts');
|
||||
return res.items ?? [];
|
||||
}
|
||||
|
||||
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.put(`/health/alerts/${id}/acknowledge`, { version });
|
||||
}
|
||||
|
||||
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.put(`/health/alerts/${id}/resolve`, { version });
|
||||
}
|
||||
|
||||
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||
return this.put(`/health/alerts/${id}/dismiss`, { version });
|
||||
}
|
||||
|
||||
private async headers(): Promise<Record<string, string>> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`GET ${path} failed: ${json.error ?? res.status}`);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? res.status}`);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
private async put<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`PUT ${path} failed: ${json.error ?? res.status}`);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
private async del(path: string, body?: unknown): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: await this.headers(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return;
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? res.status}`);
|
||||
}
|
||||
|
||||
private async rawPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? res.status}`);
|
||||
return json as T;
|
||||
}
|
||||
}
|
||||
66
apps/web/e2e/fixtures/auth.fixture.ts
Normal file
66
apps/web/e2e/fixtures/auth.fixture.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// apps/web/e2e/fixtures/auth.fixture.ts
|
||||
import { test as base, type Page } from '@playwright/test';
|
||||
import { ApiClient } from './api-client';
|
||||
|
||||
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
type E2eFixtures = {
|
||||
api: ApiClient;
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
let loginPromise: Promise<{ access_token: string; refresh_token: string; user: object }> | null = null;
|
||||
|
||||
function login() {
|
||||
if (!loginPromise) {
|
||||
loginPromise = (async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: process.env.E2E_ADMIN_USER || 'admin',
|
||||
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) return json.data;
|
||||
} catch { /* retry */ }
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
}
|
||||
throw new Error('Login failed after 3 attempts');
|
||||
})();
|
||||
}
|
||||
return loginPromise;
|
||||
}
|
||||
|
||||
export const test = base.extend<E2eFixtures>({
|
||||
api: async ({}, use) => {
|
||||
const client = new ApiClient();
|
||||
await client.loginAsAdmin();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
const { access_token, refresh_token, user } = await login();
|
||||
await page.addInitScript((args) => {
|
||||
localStorage.setItem('access_token', args.token);
|
||||
localStorage.setItem('refresh_token', args.refresh);
|
||||
localStorage.setItem('user', JSON.stringify(args.userData));
|
||||
}, { token: access_token, refresh: refresh_token, userData: user });
|
||||
await use(page);
|
||||
},
|
||||
|
||||
page: async ({ page }, use) => {
|
||||
const { access_token, refresh_token, user } = await login();
|
||||
await page.addInitScript((args) => {
|
||||
localStorage.setItem('access_token', args.token);
|
||||
localStorage.setItem('refresh_token', args.refresh);
|
||||
localStorage.setItem('user', JSON.stringify(args.userData));
|
||||
}, { token: access_token, refresh: refresh_token, userData: user });
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
190
apps/web/e2e/fixtures/test-data.ts
Normal file
190
apps/web/e2e/fixtures/test-data.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// apps/web/e2e/fixtures/test-data.ts
|
||||
|
||||
export interface PatientData {
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
blood_type?: string;
|
||||
id_number?: string;
|
||||
allergy_history?: string;
|
||||
medical_history_summary?: string;
|
||||
emergency_contact_name?: string;
|
||||
emergency_contact_phone?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface DoctorData {
|
||||
name: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
specialty?: string;
|
||||
phone?: string;
|
||||
license_number?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface VitalSignsData {
|
||||
systolic_bp?: number;
|
||||
diastolic_bp?: number;
|
||||
heart_rate?: number;
|
||||
temperature?: number;
|
||||
spo2?: number;
|
||||
blood_glucose_fasting?: number;
|
||||
blood_glucose_postprandial?: number;
|
||||
weight?: number;
|
||||
height?: number;
|
||||
recorded_at?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleData {
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
max_appointments?: number;
|
||||
}
|
||||
|
||||
export interface AppointmentData {
|
||||
patient_id: string;
|
||||
doctor_id: string;
|
||||
schedule_id: string;
|
||||
appointment_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpTemplateData {
|
||||
name: string;
|
||||
description?: string;
|
||||
frequency_days: number;
|
||||
total_rounds: number;
|
||||
questions?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpTaskData {
|
||||
patient_id: string;
|
||||
template_id: string;
|
||||
assigned_to?: string;
|
||||
due_date: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleData {
|
||||
name: string;
|
||||
indicator: string;
|
||||
condition: string;
|
||||
threshold: number;
|
||||
severity: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
function uid(): string {
|
||||
counter += 1;
|
||||
return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export function makePatient(overrides?: Partial<PatientData>): PatientData {
|
||||
const id = uid();
|
||||
return {
|
||||
name: `E2E患者_${id}`,
|
||||
gender: 'male',
|
||||
birth_date: '1990-01-15',
|
||||
phone: `138${String(Math.random()).slice(2, 11)}`,
|
||||
id_number: `110101199001${String(Math.random()).slice(2, 8)}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
|
||||
const id = uid();
|
||||
return {
|
||||
name: `E2E医生_${id}`,
|
||||
department: '内科',
|
||||
title: '主治医师',
|
||||
specialty: '全科',
|
||||
license_number: `DOC${id}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
|
||||
return {
|
||||
systolic_bp: 120,
|
||||
diastolic_bp: 80,
|
||||
heart_rate: 72,
|
||||
temperature: 36.5,
|
||||
spo2: 98,
|
||||
source: 'web_e2e',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>): ScheduleData {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const date = tomorrow.toISOString().slice(0, 10);
|
||||
return {
|
||||
doctor_id: doctorId,
|
||||
date,
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
max_appointments: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial<AppointmentData>): AppointmentData {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const date = tomorrow.toISOString().slice(0, 10);
|
||||
return {
|
||||
patient_id: patientId,
|
||||
doctor_id: doctorId,
|
||||
schedule_id: scheduleId,
|
||||
appointment_date: date,
|
||||
start_time: '09:00',
|
||||
end_time: '10:00',
|
||||
reason: 'E2E测试预约',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): FollowUpTemplateData {
|
||||
return {
|
||||
name: `E2E随访模板_${uid()}`,
|
||||
description: 'E2E自动创建的随访模板',
|
||||
frequency_days: 7,
|
||||
total_rounds: 3,
|
||||
questions: JSON.stringify([{ question: '血压是否正常?', type: 'yes_no' }]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFollowUpTask(patientId: string, templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
|
||||
const dueDate = new Date();
|
||||
dueDate.setDate(dueDate.getDate() + 7);
|
||||
return {
|
||||
patient_id: patientId,
|
||||
template_id: templateId,
|
||||
due_date: dueDate.toISOString().slice(0, 10),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
|
||||
return {
|
||||
name: `E2E告警规则_${uid()}`,
|
||||
indicator: 'heart_rate',
|
||||
condition: 'greater_than',
|
||||
threshold: 50,
|
||||
severity: 'warning',
|
||||
description: 'E2E测试低阈值规则,用于触发告警',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user