diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index 9912402..bdecb30 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -20,3 +20,5 @@ mod health_points_tests; mod health_dialysis_tests; #[path = "integration/health_alert_tests.rs"] mod health_alert_tests; +#[path = "integration/health_device_reading_tests.rs"] +mod health_device_reading_tests; diff --git a/crates/erp-server/tests/integration/health_device_reading_tests.rs b/crates/erp-server/tests/integration/health_device_reading_tests.rs new file mode 100644 index 0000000..533fced --- /dev/null +++ b/crates/erp-server/tests/integration/health_device_reading_tests.rs @@ -0,0 +1,297 @@ +//! erp-health 设备读数集成测试 +//! +//! 验证批量摄入、设备绑定自动创建、hourly 聚合、查询过滤、参数校验、租户隔离。 + +use erp_health::service::device_reading_service::{ + BatchReadingRequest, ReadingInput, +}; +use erp_health::service::device_reading_service; +use chrono::Datelike; +use sea_orm::ConnectionTrait; + +use super::test_fixture::TestApp; + +/// 确保当前月份有 device_readings 分区(测试用的 measured_at 是过去时间) +async fn ensure_current_month_partition(app: &TestApp) { + let now = chrono::Utc::now(); + let year = now.format("%Y").to_string(); + let month = now.format("%m").to_string(); + let suffix = format!("{}_{}", year, month); + let start = format!("{}-{}-01", year, month); + // 计算下个月第一天 + let next_month = if now.month() == 12 { + format!("{}-01-01", year.parse::().unwrap() + 1) + } else { + format!("{}-{:02}-01", year, now.month() as u32 + 1) + }; + let sql = format!( + "CREATE TABLE IF NOT EXISTS device_readings_{suffix} PARTITION OF device_readings FOR VALUES FROM ('{start}') TO ('{next_month}');" + ); + app.db().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + sql, + )).await.expect("创建分区应成功"); +} + +/// 构建一条心率读数(measured_at 用几分钟前的时间) +fn heart_rate_reading(bpm: i64, minutes_ago: i64) -> ReadingInput { + let measured_at = (chrono::Utc::now() - chrono::Duration::minutes(minutes_ago)).to_rfc3339(); + ReadingInput { + device_type: "heart_rate".to_string(), + values: serde_json::json!({"heart_rate": bpm}), + measured_at, + } +} + +// --------------------------------------------------------------------------- +// 测试 1: 批量摄入 — 单条读数成功 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_batch_single() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + let patient_id = app.create_patient("读数患者").await; + + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-001".to_string(), + device_model: Some("Apple Watch".to_string()), + readings: vec![heart_rate_reading(75, 5)], + }, + ) + .await + .expect("摄入应成功"); + + assert_eq!(result.accepted, 1); + assert_eq!(result.duplicates, 0); + assert!(result.earliest.is_some()); + assert!(result.latest.is_some()); +} + +// --------------------------------------------------------------------------- +// 测试 2: 批量摄入 — 多条读数 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_batch_multiple() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + let patient_id = app.create_patient("批量患者").await; + + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-002".to_string(), + device_model: None, + readings: vec![ + heart_rate_reading(70, 30), + heart_rate_reading(72, 20), + heart_rate_reading(68, 10), + ], + }, + ) + .await + .expect("批量摄入应成功"); + + assert_eq!(result.accepted, 3); +} + +// --------------------------------------------------------------------------- +// 测试 3: 设备绑定自动创建 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_creates_device_binding() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + let patient_id = app.create_patient("绑定患者").await; + + device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "band-001".to_string(), + device_model: Some("Mi Band".to_string()), + readings: vec![heart_rate_reading(80, 5)], + }, + ) + .await + .unwrap(); + + // 再次使用同一设备,应更新 last_sync_at 而非重复创建 + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "band-001".to_string(), + device_model: Some("Mi Band".to_string()), + readings: vec![heart_rate_reading(82, 2)], + }, + ) + .await + .expect("重复绑定应成功"); + + assert_eq!(result.accepted, 1); +} + +// --------------------------------------------------------------------------- +// 测试 4: hourly 聚合生成 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_hourly_aggregation() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + let patient_id = app.create_patient("聚合患者").await; + + device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-003".to_string(), + device_model: None, + readings: vec![ + heart_rate_reading(60, 25), + heart_rate_reading(70, 20), + heart_rate_reading(80, 15), + ], + }, + ) + .await + .expect("摄入应成功"); + + // 查询 hourly 聚合 + let hourly = device_reading_service::query_hourly_readings( + app.health_state(), app.tenant_id(), patient_id, "heart_rate", 1, 1, 20, + ) + .await + .expect("查询 hourly 应成功"); + + assert!(hourly.total > 0, "应有聚合记录"); + let rec = &hourly.data[0]; + assert_eq!(rec.device_type, "heart_rate"); + assert!(rec.avg_val > 0.0); + assert!(rec.sample_count > 0); +} + +// --------------------------------------------------------------------------- +// 测试 5: 查询读数 — 按类型过滤 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_query_filter() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + let patient_id = app.create_patient("查询患者").await; + + device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-004".to_string(), + device_model: None, + readings: vec![ + heart_rate_reading(72, 5), + heart_rate_reading(74, 3), + ], + }, + ) + .await + .unwrap(); + + let readings = device_reading_service::query_device_readings( + app.health_state(), app.tenant_id(), patient_id, Some("heart_rate"), None, 1, 20, + ) + .await + .expect("查询应成功"); + + assert_eq!(readings.total, 2); + assert_eq!(readings.data[0].device_type, "heart_rate"); +} + +// --------------------------------------------------------------------------- +// 测试 6: 无效 device_type 返回错误 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_invalid_device_type() { + let app = TestApp::new().await; + let patient_id = app.create_patient("校验患者").await; + + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "bad-001".to_string(), + device_model: None, + readings: vec![ReadingInput { + device_type: "invalid_type".to_string(), + values: serde_json::json!(42), + measured_at: chrono::Utc::now().to_rfc3339(), + }], + }, + ) + .await; + + assert!(result.is_err(), "无效 device_type 应返回错误"); +} + +// --------------------------------------------------------------------------- +// 测试 7: 未来时间拒绝 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_future_time_rejected() { + let app = TestApp::new().await; + let patient_id = app.create_patient("未来患者").await; + + let future_time = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-005".to_string(), + device_model: None, + readings: vec![ReadingInput { + device_type: "heart_rate".to_string(), + values: serde_json::json!({"heart_rate": 80}), + measured_at: future_time, + }], + }, + ) + .await; + + assert!(result.is_err(), "未来时间应被拒绝"); +} + +// --------------------------------------------------------------------------- +// 测试 8: 无效患者返回错误 + 租户隔离 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_device_reading_invalid_patient_and_isolation() { + let app = TestApp::new().await; + ensure_current_month_partition(&app).await; + + // 无效患者 + let fake_patient = uuid::Uuid::new_v4(); + let result = device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), fake_patient, + BatchReadingRequest { + device_id: "watch-006".to_string(), + device_model: None, + readings: vec![heart_rate_reading(75, 5)], + }, + ) + .await; + assert!(result.is_err(), "无效患者应返回错误"); + + // 租户隔离:创建患者并摄入数据,用不同租户查询 + let patient_id = app.create_patient("隔离患者").await; + device_reading_service::batch_create_readings( + app.health_state(), app.tenant_id(), patient_id, + BatchReadingRequest { + device_id: "watch-007".to_string(), + device_model: None, + readings: vec![heart_rate_reading(75, 5)], + }, + ) + .await + .unwrap(); + + let other_tenant = uuid::Uuid::new_v4(); + let readings = device_reading_service::query_device_readings( + app.health_state(), other_tenant, patient_id, None, None, 1, 20, + ) + .await + .expect("查询应成功"); + assert_eq!(readings.total, 0, "不同租户不应看到读数"); +}