diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index d26c85b..173360f 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -28,3 +28,5 @@ mod health_follow_up_tests; mod health_consultation_tests; #[path = "integration/health_data_tests.rs"] mod health_data_tests; +#[path = "integration/health_article_tests.rs"] +mod health_article_tests; diff --git a/crates/erp-server/tests/integration/health_article_tests.rs b/crates/erp-server/tests/integration/health_article_tests.rs new file mode 100644 index 0000000..f372bd3 --- /dev/null +++ b/crates/erp-server/tests/integration/health_article_tests.rs @@ -0,0 +1,438 @@ +//! erp-health 内容管理(文章 + 分类 + 标签)集成测试 +//! +//! 验证文章 CRUD + 状态流、分类 CRUD、标签 CRUD、租户隔离、乐观锁。 + +use erp_health::dto::article_dto::*; +use erp_health::service::{article_service, article_category_service, article_tag_service}; + +use super::test_fixture::TestApp; + +// --------------------------------------------------------------------------- +// 辅助函数 +// --------------------------------------------------------------------------- + +fn default_create_article_req() -> CreateArticleReq { + CreateArticleReq { + title: "测试文章".to_string(), + summary: Some("摘要".to_string()), + content: Some("正文内容".to_string()), + cover_image: None, + category: None, + author: Some("张医生".to_string()), + published_at: None, + slug: None, + content_type: None, + category_id: None, + tag_ids: vec![], + } +} + +async fn seed_article(app: &TestApp) -> ArticleResp { + article_service::create_article( + app.health_state(), app.tenant_id(), Some(app.operator_id()), + default_create_article_req(), + ) + .await + .expect("创建文章应成功") +} + +async fn seed_category(app: &TestApp, name: &str) -> CategoryResp { + article_category_service::create_category( + app.health_state(), app.tenant_id(), Some(app.operator_id()), + CreateCategoryReq { + name: name.to_string(), + slug: None, + parent_id: None, + description: None, + sort_order: None, + }, + ) + .await + .expect("创建分类应成功") +} + +async fn seed_tag(app: &TestApp, name: &str) -> TagResp { + article_tag_service::create_tag( + app.health_state(), app.tenant_id(), Some(app.operator_id()), + CreateTagReq { name: name.to_string() }, + ) + .await + .expect("创建标签应成功") +} + +// --------------------------------------------------------------------------- +// 测试 1: 创建文章 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_create() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + assert_eq!(article.title, "测试文章"); + assert_eq!(article.status, "draft"); + assert_eq!(article.view_count, 0); + assert_eq!(article.version, 1); + assert_eq!(article.content_type, "rich_text"); +} + +// --------------------------------------------------------------------------- +// 测试 2: 文章状态流 draft → pending_review → published → draft +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_status_flow() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + assert_eq!(article.status, "draft"); + + // draft → pending_review + let submitted = article_service::submit_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), article.version, + ) + .await + .expect("提交审核应成功"); + assert_eq!(submitted.status, "pending_review"); + + // pending_review → published + let published = article_service::approve_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + ReviewArticleReq { note: Some("通过".to_string()), version: Some(submitted.version) }, + submitted.version, + ) + .await + .expect("审核通过应成功"); + assert_eq!(published.status, "published"); + + // published → draft(取消发布) + let unpublished = article_service::unpublish_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), published.version, + ) + .await + .expect("取消发布应成功"); + assert_eq!(unpublished.status, "draft"); +} + +// --------------------------------------------------------------------------- +// 测试 3: 文章拒绝与重新提交 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_reject_and_resubmit() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + let submitted = article_service::submit_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), article.version, + ) + .await + .unwrap(); + + // pending_review → rejected + let rejected = article_service::reject_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + ReviewArticleReq { note: Some("内容需修改".to_string()), version: Some(submitted.version) }, + submitted.version, + ) + .await + .expect("拒绝应成功"); + assert_eq!(rejected.status, "rejected"); + + // rejected → pending_review + let resubmitted = article_service::submit_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), rejected.version, + ) + .await + .expect("重新提交应成功"); + assert_eq!(resubmitted.status, "pending_review"); +} + +// --------------------------------------------------------------------------- +// 测试 4: 文章更新 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_update() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + let updated = article_service::update_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + UpdateArticleReq { + title: Some("更新标题".to_string()), + summary: None, + content: None, + cover_image: None, + category: None, + author: None, + published_at: None, + slug: None, + content_type: None, + category_id: None, + tag_ids: None, + sort_order: None, + version: article.version, + }, + ) + .await + .expect("更新应成功"); + + assert_eq!(updated.title, "更新标题"); + assert_eq!(updated.version, 2); +} + +// --------------------------------------------------------------------------- +// 测试 5: 文章列表 + 状态过滤 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_list_filter() { + let app = TestApp::new().await; + + let a1 = seed_article(&app).await; + let _a2 = seed_article(&app).await; + + // 提交 a1 到 pending_review + article_service::submit_article( + app.health_state(), app.tenant_id(), a1.id, + Some(app.operator_id()), a1.version, + ) + .await + .unwrap(); + + // 按状态过滤 + let pending = article_service::list_articles( + app.health_state(), app.tenant_id(), 1, 20, + None, Some("pending_review".to_string()), None, None, None, + ) + .await + .unwrap(); + assert_eq!(pending.total, 1); + + let drafts = article_service::list_articles( + app.health_state(), app.tenant_id(), 1, 20, + None, Some("draft".to_string()), None, None, None, + ) + .await + .unwrap(); + assert_eq!(drafts.total, 1); +} + +// --------------------------------------------------------------------------- +// 测试 6: 文章软删除 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_soft_delete() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + article_service::delete_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), article.version, + ) + .await + .expect("删除应成功"); + + let result = article_service::get_article( + app.health_state(), app.tenant_id(), article.id, + ) + .await; + assert!(result.is_err(), "软删除后查询应失败"); +} + +// --------------------------------------------------------------------------- +// 测试 7: 文章租户隔离 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_tenant_isolation() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + let other_tenant = uuid::Uuid::new_v4(); + let result = article_service::get_article( + app.health_state(), other_tenant, article.id, + ) + .await; + assert!(result.is_err(), "不同租户不应看到此文章"); +} + +// --------------------------------------------------------------------------- +// 测试 8: 分类 CRUD + 租户隔离 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_category_crud_and_isolation() { + let app = TestApp::new().await; + + // 创建 + let cat = seed_category(&app, "肾病科普").await; + assert_eq!(cat.name, "肾病科普"); + assert_eq!(cat.version, 1); + + // 列表 + let list = article_category_service::list_categories( + app.health_state(), app.tenant_id(), + ) + .await + .unwrap(); + assert_eq!(list.len(), 1); + + // 更新 + let updated = article_category_service::update_category( + app.health_state(), app.tenant_id(), cat.id, + Some(app.operator_id()), + UpdateCategoryReq { + name: Some("透析护理".to_string()), + slug: None, + parent_id: None, + description: None, + sort_order: None, + version: cat.version, + }, + ) + .await + .expect("更新分类应成功"); + assert_eq!(updated.name, "透析护理"); + + // 删除 + article_category_service::delete_category( + app.health_state(), app.tenant_id(), cat.id, + Some(app.operator_id()), updated.version, + ) + .await + .expect("删除分类应成功"); + + let list_after = article_category_service::list_categories( + app.health_state(), app.tenant_id(), + ) + .await + .unwrap(); + assert_eq!(list_after.len(), 0, "删除后列表应为空"); + + // 租户隔离 + let cat2 = seed_category(&app, "隔离分类").await; + let other_tenant = uuid::Uuid::new_v4(); + let other_list = article_category_service::list_categories( + app.health_state(), other_tenant, + ) + .await + .unwrap(); + assert_eq!(other_list.len(), 0, "不同租户不应看到分类"); + // 防止 unused warning + let _ = cat2; +} + +// --------------------------------------------------------------------------- +// 测试 9: 标签 CRUD + 文章关联 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_tag_crud_and_article_association() { + let app = TestApp::new().await; + + // 创建标签 + let tag1 = seed_tag(&app, "高血压").await; + let tag2 = seed_tag(&app, "糖尿病").await; + assert_eq!(tag1.name, "高血压"); + + // 创建文章并关联标签 + let article = article_service::create_article( + app.health_state(), app.tenant_id(), Some(app.operator_id()), + CreateArticleReq { + title: "带标签的文章".to_string(), + tag_ids: vec![tag1.id, tag2.id], + ..default_create_article_req() + }, + ) + .await + .expect("创建带标签文章应成功"); + assert_eq!(article.tags.len(), 2); + + // 更新标签(替换为只有 tag1) + let updated = article_service::update_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + UpdateArticleReq { + tag_ids: Some(vec![tag1.id]), + version: article.version, + title: None, summary: None, content: None, cover_image: None, + category: None, author: None, published_at: None, slug: None, + content_type: None, category_id: None, sort_order: None, + }, + ) + .await + .expect("更新标签应成功"); + assert_eq!(updated.tags.len(), 1); + + // 标签列表 + let tags = article_tag_service::list_tags( + app.health_state(), app.tenant_id(), + ) + .await + .unwrap(); + assert_eq!(tags.len(), 2); + + // 更新标签名称 + let renamed = article_tag_service::update_tag( + app.health_state(), app.tenant_id(), tag1.id, + Some(app.operator_id()), + UpdateTagReq { name: "血压高".to_string(), version: tag1.version }, + ) + .await + .expect("更新标签应成功"); + assert_eq!(renamed.name, "血压高"); + + // 删除标签 + article_tag_service::delete_tag( + app.health_state(), app.tenant_id(), tag2.id, + Some(app.operator_id()), tag2.version, + ) + .await + .expect("删除标签应成功"); + + let tags_after = article_tag_service::list_tags( + app.health_state(), app.tenant_id(), + ) + .await + .unwrap(); + assert_eq!(tags_after.len(), 1); +} + +// --------------------------------------------------------------------------- +// 测试 10: 乐观锁冲突 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_article_version_conflict() { + let app = TestApp::new().await; + let article = seed_article(&app).await; + + // 先更新一次,version 变为 2 + article_service::update_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + UpdateArticleReq { + title: Some("第一次更新".to_string()), + version: article.version, + summary: None, content: None, cover_image: None, category: None, + author: None, published_at: None, slug: None, content_type: None, + category_id: None, tag_ids: None, sort_order: None, + }, + ) + .await + .unwrap(); + + // 用旧 version 再次更新应失败 + let result = article_service::update_article( + app.health_state(), app.tenant_id(), article.id, + Some(app.operator_id()), + UpdateArticleReq { + title: Some("冲突更新".to_string()), + version: article.version, // 旧版本号 + summary: None, content: None, cover_image: None, category: None, + author: None, published_at: None, slug: None, content_type: None, + category_id: None, tag_ids: None, sort_order: None, + }, + ) + .await; + assert!(result.is_err(), "乐观锁冲突应返回错误"); +}