diff --git a/crates/erp-diary/src/service/mood_stats_service.rs b/crates/erp-diary/src/service/mood_stats_service.rs index 20f7491..68627d7 100644 --- a/crates/erp-diary/src/service/mood_stats_service.rs +++ b/crates/erp-diary/src/service/mood_stats_service.rs @@ -53,7 +53,7 @@ impl MoodStatsService { // SQL GROUP BY — 一次查询获取所有心情计数(替代全量加载) let sql = r#" SELECT mood, COUNT(*) AS count - FROM journal_entry + FROM journal_entries WHERE tenant_id = $1 AND author_id = $2 AND date >= $3 @@ -124,7 +124,7 @@ impl MoodStatsService { let sql = r#" SELECT DISTINCT date - FROM journal_entry + FROM journal_entries WHERE tenant_id = $1 AND author_id = $2 AND date >= $3 diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 7b2589e..0e7ce35 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -48,6 +48,7 @@ erp-auth = { workspace = true } erp-plugin = { workspace = true } erp-workflow = { workspace = true } erp-core = { workspace = true } +erp-diary = { workspace = true } async-trait.workspace = true futures.workspace = true sha2.workspace = true diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index 3afe0ad..af71d23 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -6,3 +6,6 @@ mod plugin_tests; mod test_db; #[path = "integration/workflow_tests.rs"] mod workflow_tests; +#[cfg(feature = "diary")] +#[path = "integration/diary_tests.rs"] +mod diary_tests; diff --git a/crates/erp-server/tests/integration/diary_tests.rs b/crates/erp-server/tests/integration/diary_tests.rs new file mode 100644 index 0000000..736ecbc --- /dev/null +++ b/crates/erp-server/tests/integration/diary_tests.rs @@ -0,0 +1,491 @@ +// 暖记模块集成测试 — 直接调用 Service 层,使用 TestDb 独立数据库 +// +// 测试覆盖: +// 1. 日记 CRUD 全流程(创建/查询/更新/软删除) +// 2. 班级码生成与成员管理 +// 3. 同步服务批量操作 + 版本冲突检测 +// 4. 心情统计聚合 +// 5. 家长绑定两步验证流程 + +use chrono::Utc; +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; +use erp_diary::dto::{ + ClassMemberResp, CreateJournalReq, Mood, SyncChange, UpdateJournalReq, Weather, +}; +use erp_diary::service::{ + achievement_service::AchievementService, class_service::ClassService, + journal_service::JournalService, mood_stats_service::MoodStatsService, + parent_service::ParentService, sync_service::SyncService, +}; +use erp_diary::service::mood_stats_service::StatsPeriod; + +use super::test_db::TestDb; + +/// 构造 DiaryState 所需的共享组件(不含 db) +struct TestContext { + event_bus: EventBus, + _crypto: PiiCrypto, +} + +impl TestContext { + fn new() -> Self { + Self { + event_bus: EventBus::new(100), + _crypto: PiiCrypto::dev_default(), + } + } +} + +// ============================================================ +// 日记 CRUD +// ============================================================ + +#[tokio::test] +async fn test_journal_crud_full_lifecycle() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let author_id = uuid::Uuid::new_v4(); + + // 1. 创建 + let created = JournalService::create( + tenant_id, + author_id, + &CreateJournalReq { + title: "今天很开心".to_string(), + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(), + mood: Mood::Happy, + weather: Weather::Sunny, + tags: vec!["心情".to_string()], + is_private: false, + class_id: None, + assigned_topic_id: None, + }, + db, + &ctx.event_bus, + ) + .await + .expect("创建日记失败"); + + assert_eq!(created.title, "今天很开心"); + assert_eq!(created.version, 1); + assert_eq!(created.author_id, author_id); + assert!(created.assigned_topic_id.is_none()); + + // 2. 按 ID 查询 + let found = JournalService::get_by_id(tenant_id, created.id, db) + .await + .expect("查询日记失败"); + assert_eq!(found.title, "今天很开心"); + assert!(matches!(found.mood, Mood::Happy)); + + // 3. 更新 + let updated = JournalService::update( + tenant_id, + author_id, + created.id, + &UpdateJournalReq { + title: Some("今天非常开心".to_string()), + mood: Some(Mood::Calm), + weather: None, + tags: None, + is_private: None, + shared_to_class: None, + version: 1, + }, + db, + &ctx.event_bus, + ) + .await + .expect("更新日记失败"); + + assert_eq!(updated.title, "今天非常开心"); + assert_eq!(updated.version, 2); + assert!(matches!(updated.mood, Mood::Calm)); + + // 4. 列表查询 + let (items, total) = JournalService::list( + tenant_id, + Some(author_id), + None, + None, + None, + None, + 1, + 10, + db, + ) + .await + .expect("日记列表查询失败"); + assert_eq!(total, 1); + assert_eq!(items[0].title, "今天非常开心"); + + // 5. 软删除 + JournalService::delete(tenant_id, author_id, created.id, 2, db, &ctx.event_bus) + .await + .expect("删除日记失败"); + + // 6. 删除后查询应失败 + let result = JournalService::get_by_id(tenant_id, created.id, db).await; + assert!(result.is_err(), "已删除日记应无法查询"); +} + +#[tokio::test] +async fn test_journal_version_conflict_on_update() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let author_id = uuid::Uuid::new_v4(); + + let created = JournalService::create( + tenant_id, + author_id, + &CreateJournalReq { + title: "版本冲突测试".to_string(), + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(), + mood: Mood::Happy, + weather: Weather::Sunny, + tags: vec![], + is_private: true, + class_id: None, + assigned_topic_id: None, + }, + db, + &ctx.event_bus, + ) + .await + .unwrap(); + + // 用错误版本号更新 → 应失败 + let result = JournalService::update( + tenant_id, + author_id, + created.id, + &UpdateJournalReq { + title: Some("冲突".to_string()), + mood: None, + weather: None, + tags: None, + is_private: None, + shared_to_class: None, + version: 999, // 错误版本 + }, + db, + &ctx.event_bus, + ) + .await; + + assert!(result.is_err(), "版本冲突应导致更新失败"); +} + +#[tokio::test] +async fn test_journal_tenant_isolation() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + let author_a = uuid::Uuid::new_v4(); + + // 租户 A 创建日记 + let _journal_a = JournalService::create( + tenant_a, + author_a, + &CreateJournalReq { + title: "租户A的日记".to_string(), + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(), + mood: Mood::Happy, + weather: Weather::Sunny, + tags: vec![], + is_private: true, + class_id: None, + assigned_topic_id: None, + }, + db, + &ctx.event_bus, + ) + .await + .unwrap(); + + // 租户 B 查询不应看到租户 A 的日记 + let (_, total_b) = JournalService::list(tenant_b, None, None, None, None, None, 1, 10, db) + .await + .unwrap(); + assert_eq!(total_b, 0, "租户隔离:B 不应看到 A 的数据"); +} + +// ============================================================ +// 班级管理 +// ============================================================ + +#[tokio::test] +async fn test_class_create_and_join() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let teacher_id = uuid::Uuid::new_v4(); + let student_id = uuid::Uuid::new_v4(); + + // 1. 老师创建班级 + let class_resp = ClassService::create_class( + tenant_id, + teacher_id, + "三年二班".to_string(), + Some("测试小学".to_string()), + db, + &ctx.event_bus, + ) + .await + .expect("创建班级失败"); + + assert_eq!(class_resp.name, "三年二班"); + assert_eq!(class_resp.member_count, 1); // 老师自动加入 + assert_eq!(class_resp.class_code.len(), 6); + assert!(class_resp.is_active); + + // 2. 学生通过班级码加入 + let joined = ClassService::join_class( + tenant_id, + student_id, + class_resp.class_code.clone(), + Some("小明".to_string()), + db, + None, // 无 Redis + &ctx.event_bus, + ) + .await + .expect("加入班级失败"); + + assert_eq!(joined.member_count, 2); + + // 3. 查询班级成员 + let members = ClassService::list_members(tenant_id, class_resp.id, db) + .await + .expect("查询成员失败"); + assert_eq!(members.len(), 2); + + let roles: Vec<&str> = members.iter().map(|m: &ClassMemberResp| m.role.as_str()).collect(); + assert!(roles.contains(&"teacher")); + assert!(roles.contains(&"student")); + + // 4. 查询我的班级 + let my_classes = ClassService::my_classes(tenant_id, student_id, db) + .await + .expect("查询我的班级失败"); + assert_eq!(my_classes.len(), 1); + + // 5. 重置班级码 + let reset_resp = ClassService::reset_class_code(tenant_id, teacher_id, class_resp.id, db) + .await + .expect("重置班级码失败"); + assert_ne!(reset_resp.new_class_code, class_resp.class_code); + assert_eq!(reset_resp.class_id, class_resp.id); +} + +// ============================================================ +// 同步服务 — 批量操作 + 版本冲突检测 +// ============================================================ + +#[tokio::test] +async fn test_sync_batch_create_and_fetch() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + // 批量创建 3 篇日记 + let changes = vec![ + SyncChange::CreateJournal { + data: serde_json::json!({ + "title": "日记1", + "date": "2026-06-01", + "mood": "happy", + "weather": "sunny", + "tags": [], + "is_private": true, + }), + }, + SyncChange::CreateJournal { + data: serde_json::json!({ + "title": "日记2", + "date": "2026-06-02", + "mood": "calm", + "weather": "cloudy", + "tags": ["test"], + "is_private": false, + }), + }, + SyncChange::CreateJournal { + data: serde_json::json!({ + "title": "日记3", + "date": "2026-06-03", + "mood": "thinking", + "weather": "rainy", + "tags": [], + "is_private": true, + }), + }, + ]; + + let resp = SyncService::sync(tenant_id, user_id, None, changes, db) + .await + .expect("同步失败"); + + assert!(resp.conflicts.is_empty(), "新创建不应有冲突"); + assert!(resp.server_changes.len() >= 3, "应返回至少 3 条服务端变更"); + assert!(resp.sync_time <= Utc::now()); +} + +#[tokio::test] +async fn test_sync_version_conflict_detection() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + // 先创建一篇日记 + let created = JournalService::create( + tenant_id, + user_id, + &CreateJournalReq { + title: "冲突测试".to_string(), + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(), + mood: Mood::Happy, + weather: Weather::Sunny, + tags: vec![], + is_private: true, + class_id: None, + assigned_topic_id: None, + }, + db, + &ctx.event_bus, + ) + .await + .unwrap(); + + // 用错误版本号同步更新 → 应产生冲突 + let changes = vec![SyncChange::UpdateJournal { + id: created.id, + version: 999, // 错误版本 + data: serde_json::json!({"title": "冲突更新"}), + }]; + + let resp = SyncService::sync(tenant_id, user_id, None, changes, db) + .await + .expect("同步调用失败"); + + assert_eq!(resp.conflicts.len(), 1, "版本不匹配应产生冲突"); + assert_eq!(resp.conflicts[0].journal_id, created.id); + assert_eq!(resp.conflicts[0].local_version, 999); + assert_eq!(resp.conflicts[0].server_version, 1); +} + +// ============================================================ +// 心情统计 +// ============================================================ + +#[tokio::test] +async fn test_mood_stats_aggregation() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let author_id = uuid::Uuid::new_v4(); + + // 创建几篇不同心情的日记 + let moods = vec![ + (Mood::Happy, "2026-06-01"), + (Mood::Happy, "2026-06-02"), + (Mood::Calm, "2026-06-03"), + ]; + + for (mood, date_str) in moods { + JournalService::create( + tenant_id, + author_id, + &CreateJournalReq { + title: format!("{}日记", date_str), + date: date_str.parse().unwrap(), + mood, + weather: Weather::Sunny, + tags: vec![], + is_private: true, + class_id: None, + assigned_topic_id: None, + }, + db, + &ctx.event_bus, + ) + .await + .unwrap(); + } + + // 查询统计 + let stats = MoodStatsService::get_mood_stats(tenant_id, author_id, StatsPeriod::Week, db) + .await + .expect("心情统计查询失败"); + + // 验证返回了数据 + assert!( + serde_json::to_string(&stats) + .unwrap() + .contains("mood_counts"), + "统计响应应包含 mood_counts" + ); +} + +// ============================================================ +// 家长绑定两步验证 +// ============================================================ + +#[tokio::test] +async fn test_parent_binding_two_step_verification() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let ctx = TestContext::new(); + let tenant_id = uuid::Uuid::new_v4(); + let parent_id = uuid::Uuid::new_v4(); + + // 注意:此测试验证绑定流程逻辑 + // bind_child 会检查孩子是否为 student 角色 + // 由于测试环境中可能没有 auth 模块的用户记录 + // 此测试仅验证 service 层的错误处理 + let result = ParentService::bind_child( + tenant_id, + parent_id, + uuid::Uuid::new_v4(), // 不存在的孩子 ID + db, + &ctx.event_bus, + ) + .await; + + // 预期失败:孩子用户不存在或不是学生角色 + assert!( + result.is_err(), + "绑定不存在的孩子应失败" + ); +} + +// ============================================================ +// 成就查询 +// ============================================================ + +#[tokio::test] +async fn test_achievement_list() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + // 查询成就列表(无种子数据时应返回空列表或预设列表) + let result = AchievementService::list_achievements(tenant_id, user_id, db).await; + // 成就查询不应失败,即使无数据 + assert!(result.is_ok(), "成就列表查询不应失败"); +}