test(health): 文章/分类/标签集成测试 — 10 个用例全通过
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

覆盖文章 CRUD、状态流(draft→pending_review→published→draft)、
拒绝与重提交、列表过滤、软删除、租户隔离、分类 CRUD+隔离、
标签 CRUD+文章关联、乐观锁冲突。
This commit is contained in:
iven
2026-04-27 23:04:41 +08:00
parent f58f1f73c5
commit 2d5b6d4c50
2 changed files with 440 additions and 0 deletions

View File

@@ -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;

View File

@@ -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(), "乐观锁冲突应返回错误");
}