// 暖记模块集成测试 — 直接调用 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(), "成就列表查询不应失败"); }