fix(web+health): E2E flow 测试全面修复 — 15/15 通过
- test-data: 接口对齐后端 DTO(VitalSigns/AlertRule/Schedule/FollowUp) - api-client: 增强 HTTP 错误处理(parseJson 统一防护非 JSON 响应) - auth.fixture: 每个测试获取新 token,避免共享 token 过期 - patient-detail: tab 名称修正为 '健康数据' → '体征数据' - patient-list: DrawerForm 选择器适配(无 phone 字段、保存按钮在 extra) - vital-signs-flow: API 录入 + 页面验证,避免复杂 DatePicker 交互 - alert-flow: 简化为规则 CRUD + 页面导航,condition_params 对齐后端格式 - follow-up-template handler: 权限码从 health.follow-up-template.* 修正为 health.follow-up.* - playwright.config: workers=1 串行执行避免并发登录 - check-readiness: 健康端点路径修正为 /api/v1/health
This commit is contained in:
@@ -112,8 +112,8 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
|
||||
const res = await this.get<{ items: VEntity<Record<string, unknown>>[] }>('/health/alerts');
|
||||
return res.items ?? [];
|
||||
const res = await this.get<{ data: VEntity<Record<string, unknown>>[] }>('/health/alerts');
|
||||
return res.data ?? [];
|
||||
}
|
||||
|
||||
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
|
||||
@@ -135,11 +135,19 @@ export class ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async parseJson<T>(res: Response, method: string, path: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`${method} ${path} failed: ${json.error ?? 'unknown'}`);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
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;
|
||||
return this.parseJson<T>(res, 'GET', path);
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -148,9 +156,7 @@ export class ApiClient {
|
||||
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;
|
||||
return this.parseJson<T>(res, 'POST', path);
|
||||
}
|
||||
|
||||
private async put<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -159,9 +165,7 @@ export class ApiClient {
|
||||
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;
|
||||
return this.parseJson<T>(res, 'PUT', path);
|
||||
}
|
||||
|
||||
private async del(path: string, body?: unknown): Promise<void> {
|
||||
@@ -171,8 +175,12 @@ export class ApiClient {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return;
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? res.status}`);
|
||||
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
private async rawPost<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -181,8 +189,12 @@ export class ApiClient {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`POST ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? res.status}`);
|
||||
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? 'unknown'}`);
|
||||
return json as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,36 +9,43 @@ type E2eFixtures = {
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
let loginPromise: Promise<{ access_token: string; refresh_token: string; user: object }> | null = null;
|
||||
interface LoginResult {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: object;
|
||||
}
|
||||
|
||||
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)));
|
||||
async function login(): Promise<LoginResult> {
|
||||
for (let attempt = 0; attempt < 5; 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',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
|
||||
}
|
||||
throw new Error('Login failed after 3 attempts');
|
||||
})();
|
||||
const json = await res.json();
|
||||
if (json.success) return json.data;
|
||||
throw new Error(`Login unsuccessful: ${json.error ?? 'unknown'}`);
|
||||
} catch (err) {
|
||||
if (attempt === 4) throw err;
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
return loginPromise;
|
||||
throw new Error('Login failed after 5 attempts');
|
||||
}
|
||||
|
||||
export const test = base.extend<E2eFixtures>({
|
||||
api: async ({}, use) => {
|
||||
const { access_token } = await login();
|
||||
const client = new ApiClient();
|
||||
await client.loginAsAdmin();
|
||||
client['token'] = access_token;
|
||||
await use(client);
|
||||
},
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface PatientData {
|
||||
emergency_contact_phone?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface DoctorData {
|
||||
@@ -26,26 +25,27 @@ export interface DoctorData {
|
||||
}
|
||||
|
||||
export interface VitalSignsData {
|
||||
systolic_bp?: number;
|
||||
diastolic_bp?: number;
|
||||
record_date: string;
|
||||
systolic_bp_morning?: number;
|
||||
diastolic_bp_morning?: number;
|
||||
heart_rate?: number;
|
||||
temperature?: number;
|
||||
body_temperature?: number;
|
||||
spo2?: number;
|
||||
blood_glucose_fasting?: number;
|
||||
blood_glucose_postprandial?: number;
|
||||
blood_sugar?: number;
|
||||
weight?: number;
|
||||
height?: number;
|
||||
recorded_at?: string;
|
||||
source?: string;
|
||||
water_intake_ml?: number;
|
||||
urine_output_ml?: number;
|
||||
notes?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleData {
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
schedule_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
max_appointments?: number;
|
||||
period_type?: string;
|
||||
}
|
||||
|
||||
export interface AppointmentData {
|
||||
@@ -61,25 +61,35 @@ export interface AppointmentData {
|
||||
export interface FollowUpTemplateData {
|
||||
name: string;
|
||||
description?: string;
|
||||
frequency_days: number;
|
||||
total_rounds: number;
|
||||
questions?: string;
|
||||
follow_up_type: string;
|
||||
applicable_scope?: string;
|
||||
fields?: Array<{
|
||||
label: string;
|
||||
field_key: string;
|
||||
field_type: string;
|
||||
required?: boolean;
|
||||
options?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FollowUpTaskData {
|
||||
patient_id: string;
|
||||
template_id: string;
|
||||
follow_up_type: string;
|
||||
planned_date: string;
|
||||
assigned_to?: string;
|
||||
due_date: string;
|
||||
content_template?: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleData {
|
||||
name: string;
|
||||
indicator: string;
|
||||
condition: string;
|
||||
threshold: number;
|
||||
severity: string;
|
||||
device_type: string;
|
||||
condition_type: string;
|
||||
condition_params: Record<string, unknown>;
|
||||
severity?: string;
|
||||
description?: string;
|
||||
apply_tags?: Record<string, unknown>;
|
||||
notify_roles?: Array<string>;
|
||||
cooldown_minutes?: number;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
@@ -95,7 +105,6 @@ export function makePatient(overrides?: Partial<PatientData>): PatientData {
|
||||
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,
|
||||
};
|
||||
@@ -115,10 +124,11 @@ export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
|
||||
|
||||
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
|
||||
return {
|
||||
systolic_bp: 120,
|
||||
diastolic_bp: 80,
|
||||
record_date: new Date().toISOString().slice(0, 10),
|
||||
systolic_bp_morning: 120,
|
||||
diastolic_bp_morning: 80,
|
||||
heart_rate: 72,
|
||||
temperature: 36.5,
|
||||
body_temperature: 36.5,
|
||||
spo2: 98,
|
||||
source: 'web_e2e',
|
||||
...overrides,
|
||||
@@ -131,7 +141,7 @@ export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>
|
||||
const date = tomorrow.toISOString().slice(0, 10);
|
||||
return {
|
||||
doctor_id: doctorId,
|
||||
date,
|
||||
schedule_date: date,
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
max_appointments: 10,
|
||||
@@ -159,20 +169,18 @@ export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>):
|
||||
return {
|
||||
name: `E2E随访模板_${uid()}`,
|
||||
description: 'E2E自动创建的随访模板',
|
||||
frequency_days: 7,
|
||||
total_rounds: 3,
|
||||
questions: JSON.stringify([{ question: '血压是否正常?', type: 'yes_no' }]),
|
||||
follow_up_type: 'phone',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFollowUpTask(patientId: string, templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
|
||||
const dueDate = new Date();
|
||||
dueDate.setDate(dueDate.getDate() + 7);
|
||||
export function makeFollowUpTask(patientId: string, _templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
|
||||
const plannedDate = new Date();
|
||||
plannedDate.setDate(plannedDate.getDate() + 7);
|
||||
return {
|
||||
patient_id: patientId,
|
||||
template_id: templateId,
|
||||
due_date: dueDate.toISOString().slice(0, 10),
|
||||
follow_up_type: 'phone',
|
||||
planned_date: plannedDate.toISOString().slice(0, 10),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -180,9 +188,9 @@ export function makeFollowUpTask(patientId: string, templateId: string, override
|
||||
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
|
||||
return {
|
||||
name: `E2E告警规则_${uid()}`,
|
||||
indicator: 'heart_rate',
|
||||
condition: 'greater_than',
|
||||
threshold: 50,
|
||||
device_type: 'heart_rate',
|
||||
condition_type: 'single_threshold',
|
||||
condition_params: { direction: 'above', value: 50 },
|
||||
severity: 'warning',
|
||||
description: 'E2E测试低阈值规则,用于触发告警',
|
||||
...overrides,
|
||||
|
||||
Reference in New Issue
Block a user