P0 #1: 媒体文件上传增加 MIME 类型白名单校验(jpeg/png/gif/webp/svg/mp4/webm/pdf) 和文件大小限制(10MB),扩展名使用白名单清理防止路径遍历攻击。 P0 #2: OAuth JWT 密钥从环境变量改为 State 注入,消除运行时 env::var 依赖, FHIR 路由中间件使用闭包捕获 jwt_secret 保持类型安全。
396 lines
14 KiB
Rust
396 lines
14 KiB
Rust
//! erp-health 预约排班集成测试
|
||
//!
|
||
//! 验证预约 CRUD、租户隔离、CAS 排班名额等核心行为。
|
||
//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。
|
||
//! 预约创建依赖患者 + 医护档案 + 排班三条前置数据。
|
||
|
||
use erp_core::crypto::PiiCrypto;
|
||
use erp_core::events::EventBus;
|
||
use erp_health::dto::appointment_dto::{CreateAppointmentReq, UpdateAppointmentStatusReq};
|
||
use erp_health::dto::doctor_dto::CreateDoctorReq;
|
||
use erp_health::dto::patient_dto::CreatePatientReq;
|
||
use erp_health::service::{appointment_service, doctor_service, patient_service};
|
||
use erp_health::state::HealthState;
|
||
|
||
use super::test_db::TestDb;
|
||
|
||
/// 构建测试用 HealthState
|
||
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
|
||
HealthState {
|
||
db: db.clone(),
|
||
event_bus: EventBus::new(100),
|
||
crypto: PiiCrypto::dev_default(),
|
||
jwt_secret: "test-jwt-secret".to_string(),
|
||
}
|
||
}
|
||
|
||
/// 创建患者并返回其 ID
|
||
async fn seed_patient(state: &HealthState, tenant_id: uuid::Uuid, name: &str) -> uuid::Uuid {
|
||
let req = CreatePatientReq {
|
||
name: name.to_string(),
|
||
gender: Some("male".to_string()),
|
||
birth_date: None,
|
||
blood_type: None,
|
||
id_number: None,
|
||
allergy_history: None,
|
||
medical_history_summary: None,
|
||
emergency_contact_name: None,
|
||
emergency_contact_phone: None,
|
||
source: None,
|
||
notes: None,
|
||
};
|
||
let patient = patient_service::create_patient(state, tenant_id, None, req)
|
||
.await
|
||
.expect("创建患者应成功");
|
||
patient.id
|
||
}
|
||
|
||
/// 创建医护档案并返回其 ID
|
||
async fn seed_doctor(state: &HealthState, tenant_id: uuid::Uuid, name: &str) -> uuid::Uuid {
|
||
let req = CreateDoctorReq {
|
||
user_id: None,
|
||
name: name.to_string(),
|
||
department: Some("内科".to_string()),
|
||
title: Some("主治医师".to_string()),
|
||
specialty: Some("心血管内科".to_string()),
|
||
license_number: None,
|
||
bio: None,
|
||
};
|
||
let doctor = doctor_service::create_doctor(state, tenant_id, None, req)
|
||
.await
|
||
.expect("创建医护档案应成功");
|
||
doctor.id
|
||
}
|
||
|
||
/// 创建排班并返回其 ID
|
||
async fn seed_schedule(
|
||
state: &HealthState,
|
||
tenant_id: uuid::Uuid,
|
||
doctor_id: uuid::Uuid,
|
||
date: chrono::NaiveDate,
|
||
) -> uuid::Uuid {
|
||
let req = erp_health::dto::appointment_dto::CreateScheduleReq {
|
||
doctor_id,
|
||
schedule_date: date,
|
||
period_type: Some("am".to_string()),
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
|
||
max_appointments: 10,
|
||
};
|
||
let schedule = appointment_service::create_schedule(state, tenant_id, None, req)
|
||
.await
|
||
.expect("创建排班应成功");
|
||
schedule.id
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 1: 创建预约
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_create_appointment() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_id = uuid::Uuid::new_v4();
|
||
let operator_id = uuid::Uuid::new_v4();
|
||
|
||
// 前置:患者 + 医护 + 排班
|
||
let patient_id = seed_patient(&state, tenant_id, "预约测试患者").await;
|
||
let doctor_id = seed_doctor(&state, tenant_id, "预约测试医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
|
||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||
|
||
let req = CreateAppointmentReq {
|
||
patient_id,
|
||
doctor_id: Some(doctor_id),
|
||
appointment_type: Some("outpatient".to_string()),
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: Some("首次就诊".to_string()),
|
||
};
|
||
|
||
let appointment =
|
||
appointment_service::create_appointment(&state, tenant_id, Some(operator_id), req)
|
||
.await
|
||
.expect("创建预约应成功");
|
||
|
||
assert_eq!(appointment.patient_id, patient_id);
|
||
assert_eq!(appointment.doctor_id, Some(doctor_id));
|
||
assert_eq!(appointment.appointment_type, "outpatient");
|
||
assert_eq!(appointment.status, "pending");
|
||
assert_eq!(appointment.version, 1);
|
||
assert_eq!(appointment.notes, Some("首次就诊".to_string()));
|
||
|
||
// 通过 get_appointment 验证存储正确
|
||
let found = appointment_service::get_appointment(&state, tenant_id, appointment.id)
|
||
.await
|
||
.expect("查询预约应成功");
|
||
assert_eq!(found.id, appointment.id);
|
||
assert_eq!(found.status, "pending");
|
||
assert_eq!(found.version, 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 2: 列表查询 — 创建 2 条预约后验证分页计数
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_list_appointments() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_id = uuid::Uuid::new_v4();
|
||
|
||
let patient_id = seed_patient(&state, tenant_id, "列表测试患者").await;
|
||
let doctor_id = seed_doctor(&state, tenant_id, "列表测试医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
|
||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||
|
||
// 创建 2 条预约(同一个排班时段,CAS 按排班 start_time 匹配)
|
||
for _i in 0..2 {
|
||
let req = CreateAppointmentReq {
|
||
patient_id,
|
||
doctor_id: Some(doctor_id),
|
||
appointment_type: None,
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: None,
|
||
};
|
||
appointment_service::create_appointment(&state, tenant_id, None, req)
|
||
.await
|
||
.expect("创建预约应成功");
|
||
}
|
||
|
||
let result =
|
||
appointment_service::list_appointments(&state, tenant_id, 1, 10, None, None, None, None)
|
||
.await
|
||
.expect("列表查询应成功");
|
||
|
||
assert_eq!(result.total, 2, "应有 2 条预约记录");
|
||
assert_eq!(result.data.len(), 2, "当前页应返回 2 条");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 3: 租户隔离 — 租户 A 的预约对租户 B 不可见
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_appointment_tenant_isolation() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_a = uuid::Uuid::new_v4();
|
||
let tenant_b = uuid::Uuid::new_v4();
|
||
|
||
// 租户 A 创建完整链路:患者 + 医护 + 排班 + 预约
|
||
let patient_a = seed_patient(&state, tenant_a, "租户A患者").await;
|
||
let doctor_a = seed_doctor(&state, tenant_a, "租户A医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
|
||
seed_schedule(&state, tenant_a, doctor_a, date).await;
|
||
|
||
let req = CreateAppointmentReq {
|
||
patient_id: patient_a,
|
||
doctor_id: Some(doctor_a),
|
||
appointment_type: None,
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: None,
|
||
};
|
||
let appointment_a = appointment_service::create_appointment(&state, tenant_a, None, req)
|
||
.await
|
||
.expect("租户 A 创建预约应成功");
|
||
|
||
// 租户 B 列表查询应看不到租户 A 的预约
|
||
let result_b =
|
||
appointment_service::list_appointments(&state, tenant_b, 1, 10, None, None, None, None)
|
||
.await
|
||
.expect("租户 B 列表查询应成功");
|
||
assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的预约");
|
||
assert!(result_b.data.is_empty());
|
||
|
||
// 租户 B 通过 ID 查询租户 A 的预约应返回错误
|
||
let lookup_result =
|
||
appointment_service::get_appointment(&state, tenant_b, appointment_a.id).await;
|
||
assert!(lookup_result.is_err(), "跨租户查询预约应返回错误");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 4: 预约状态流转 — pending → confirmed → completed
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_appointment_status_flow() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_id = uuid::Uuid::new_v4();
|
||
let operator_id = uuid::Uuid::new_v4();
|
||
|
||
let patient_id = seed_patient(&state, tenant_id, "状态患者").await;
|
||
let doctor_id = seed_doctor(&state, tenant_id, "状态医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap();
|
||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||
|
||
let appt = appointment_service::create_appointment(
|
||
&state,
|
||
tenant_id,
|
||
Some(operator_id),
|
||
CreateAppointmentReq {
|
||
patient_id,
|
||
doctor_id: Some(doctor_id),
|
||
appointment_type: None,
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: None,
|
||
},
|
||
)
|
||
.await
|
||
.expect("创建预约应成功");
|
||
assert_eq!(appt.status, "pending");
|
||
|
||
// pending → confirmed
|
||
let confirmed = appointment_service::update_appointment_status(
|
||
&state,
|
||
tenant_id,
|
||
appt.id,
|
||
Some(operator_id),
|
||
UpdateAppointmentStatusReq {
|
||
status: "confirmed".to_string(),
|
||
cancel_reason: None,
|
||
},
|
||
appt.version,
|
||
)
|
||
.await
|
||
.expect("确认应成功");
|
||
assert_eq!(confirmed.status, "confirmed");
|
||
|
||
// confirmed → completed
|
||
let completed = appointment_service::update_appointment_status(
|
||
&state,
|
||
tenant_id,
|
||
appt.id,
|
||
Some(operator_id),
|
||
UpdateAppointmentStatusReq {
|
||
status: "completed".to_string(),
|
||
cancel_reason: None,
|
||
},
|
||
confirmed.version,
|
||
)
|
||
.await
|
||
.expect("完成应成功");
|
||
assert_eq!(completed.status, "completed");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 5: 预约取消 — pending → cancelled
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_appointment_cancel() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_id = uuid::Uuid::new_v4();
|
||
|
||
let patient_id = seed_patient(&state, tenant_id, "取消患者").await;
|
||
let doctor_id = seed_doctor(&state, tenant_id, "取消医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 16).unwrap();
|
||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||
|
||
let appt = appointment_service::create_appointment(
|
||
&state,
|
||
tenant_id,
|
||
None,
|
||
CreateAppointmentReq {
|
||
patient_id,
|
||
doctor_id: Some(doctor_id),
|
||
appointment_type: None,
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: None,
|
||
},
|
||
)
|
||
.await
|
||
.expect("创建应成功");
|
||
|
||
let cancelled = appointment_service::update_appointment_status(
|
||
&state,
|
||
tenant_id,
|
||
appt.id,
|
||
None,
|
||
UpdateAppointmentStatusReq {
|
||
status: "cancelled".to_string(),
|
||
cancel_reason: Some("患者临时有事".to_string()),
|
||
},
|
||
appt.version,
|
||
)
|
||
.await
|
||
.expect("取消应成功");
|
||
assert_eq!(cancelled.status, "cancelled");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 测试 6: 预约乐观锁 — 旧版本更新拒绝
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_appointment_version_conflict() {
|
||
let test_db = TestDb::new().await;
|
||
let state = make_state(test_db.db());
|
||
let tenant_id = uuid::Uuid::new_v4();
|
||
let operator_id = uuid::Uuid::new_v4();
|
||
|
||
let patient_id = seed_patient(&state, tenant_id, "乐观锁患者").await;
|
||
let doctor_id = seed_doctor(&state, tenant_id, "乐观锁医生").await;
|
||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 17).unwrap();
|
||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||
|
||
let appt = appointment_service::create_appointment(
|
||
&state,
|
||
tenant_id,
|
||
Some(operator_id),
|
||
CreateAppointmentReq {
|
||
patient_id,
|
||
doctor_id: Some(doctor_id),
|
||
appointment_type: None,
|
||
appointment_date: date,
|
||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||
notes: None,
|
||
},
|
||
)
|
||
.await
|
||
.expect("创建应成功");
|
||
|
||
// 正确版本确认
|
||
let _confirmed = appointment_service::update_appointment_status(
|
||
&state,
|
||
tenant_id,
|
||
appt.id,
|
||
Some(operator_id),
|
||
UpdateAppointmentStatusReq {
|
||
status: "confirmed".to_string(),
|
||
cancel_reason: None,
|
||
},
|
||
appt.version,
|
||
)
|
||
.await
|
||
.expect("确认应成功");
|
||
|
||
// 用旧版本再更新应失败
|
||
let result = appointment_service::update_appointment_status(
|
||
&state,
|
||
tenant_id,
|
||
appt.id,
|
||
Some(operator_id),
|
||
UpdateAppointmentStatusReq {
|
||
status: "cancelled".to_string(),
|
||
cancel_reason: None,
|
||
},
|
||
appt.version, // 旧版本
|
||
)
|
||
.await;
|
||
assert!(result.is_err(), "旧版本更新应失败");
|
||
}
|