test(diary): 添加 9 个集成测试 + 修复 mood_stats 表名
集成测试 (TestDb + Service 层直接调用): - test_journal_crud_full_lifecycle: 创建/查询/更新/列表/软删除全流程 - test_journal_version_conflict_on_update: 乐观锁版本冲突检测 - test_journal_tenant_isolation: 多租户数据隔离验证 - test_class_create_and_join: 班级创建+学生加入+成员查询+班级码重置 - test_sync_batch_create_and_fetch: 批量创建 3 篇日记同步 - test_sync_version_conflict_detection: 同步版本冲突检测 - test_mood_stats_aggregation: 心情统计 GROUP BY 聚合 - test_parent_binding_two_step_verification: 家长绑定两步验证 - test_achievement_list: 成就查询 修复: - mood_stats_service: journal_entry → journal_entries 表名修正 测试: 518/518 全仓库通过 (含 9 新增集成测试)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
491
crates/erp-server/tests/integration/diary_tests.rs
Normal file
491
crates/erp-server/tests/integration/diary_tests.rs
Normal file
@@ -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(), "成就列表查询不应失败");
|
||||
}
|
||||
Reference in New Issue
Block a user