From ee65b6e3c9333a05d6293a840b47d31ed59dd8c8 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 15 Apr 2026 01:06:34 +0800 Subject: [PATCH] test: add 149 unit tests across core, auth, config, message crates Test coverage increased from ~34 to 183 tests (zero failures): - erp-core (21): version check, pagination, API response, error mapping - erp-auth (39): org tree building, DTO validation, error conversion, password hashing, user model mapping - erp-config (57): DTO validation, numbering reset logic, menu tree building, error conversion. Fixed BatchSaveMenusReq nested validation - erp-message (50): DTO validation, template rendering, query defaults, error conversion - erp-workflow (16): unchanged (parser + expression tests) All tests are pure unit tests requiring no database. --- crates/erp-auth/src/dto.rs | 180 ++++++++ crates/erp-auth/src/error.rs | 76 ++++ crates/erp-auth/src/service/org_service.rs | 120 ++++- crates/erp-auth/src/service/user_service.rs | 81 +++- crates/erp-config/src/dto.rs | 430 +++++++++++++++++- crates/erp-config/src/error.rs | 73 +++ crates/erp-config/src/service/menu_service.rs | 160 +++++++ .../src/service/numbering_service.rs | 233 ++++++++++ crates/erp-core/src/error.rs | 90 +++- crates/erp-core/src/types.rs | 87 ++++ crates/erp-message/src/dto.rs | 316 +++++++++++++ crates/erp-message/src/error.rs | 75 +++ .../src/service/template_service.rs | 78 ++++ 13 files changed, 1995 insertions(+), 4 deletions(-) diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index ff4d7cf..fd4e105 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -209,3 +209,183 @@ pub struct UpdatePositionReq { pub sort_order: Option, pub version: i32, } + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + #[test] + fn login_req_valid() { + let req = LoginReq { + username: "admin".to_string(), + password: "password123".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn login_req_empty_username_fails() { + let req = LoginReq { + username: "".to_string(), + password: "password123".to_string(), + }; + let result = req.validate(); + assert!(result.is_err()); + } + + #[test] + fn login_req_empty_password_fails() { + let req = LoginReq { + username: "admin".to_string(), + password: "".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_valid() { + let req = CreateUserReq { + username: "alice".to_string(), + password: "secret123".to_string(), + email: Some("alice@example.com".to_string()), + phone: None, + display_name: Some("Alice".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_user_req_short_password_fails() { + let req = CreateUserReq { + username: "bob".to_string(), + password: "12345".to_string(), // min 6 + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_empty_username_fails() { + let req = CreateUserReq { + username: "".to_string(), + password: "secret123".to_string(), + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_invalid_email_fails() { + let req = CreateUserReq { + username: "charlie".to_string(), + password: "secret123".to_string(), + email: Some("not-an-email".to_string()), + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_long_username_fails() { + let req = CreateUserReq { + username: "a".repeat(51), // max 50 + password: "secret123".to_string(), + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_role_req_valid() { + let req = CreateRoleReq { + name: "管理员".to_string(), + code: "admin".to_string(), + description: Some("系统管理员".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_role_req_empty_name_fails() { + let req = CreateRoleReq { + name: "".to_string(), + code: "admin".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_role_req_empty_code_fails() { + let req = CreateRoleReq { + name: "管理员".to_string(), + code: "".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_org_req_valid() { + let req = CreateOrganizationReq { + name: "总部".to_string(), + code: Some("HQ".to_string()), + parent_id: None, + sort_order: Some(0), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_org_req_empty_name_fails() { + let req = CreateOrganizationReq { + name: "".to_string(), + code: None, + parent_id: None, + sort_order: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dept_req_valid() { + let req = CreateDepartmentReq { + name: "技术部".to_string(), + code: Some("TECH".to_string()), + parent_id: None, + manager_id: None, + sort_order: Some(1), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_position_req_valid() { + let req = CreatePositionReq { + name: "高级工程师".to_string(), + code: Some("SENIOR".to_string()), + level: Some(3), + sort_order: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_position_req_empty_name_fails() { + let req = CreatePositionReq { + name: "".to_string(), + code: None, + level: None, + sort_order: None, + }; + assert!(req.validate().is_err()); + } +} diff --git a/crates/erp-auth/src/error.rs b/crates/erp-auth/src/error.rs index f79e215..9dc1b2f 100644 --- a/crates/erp-auth/src/error.rs +++ b/crates/erp-auth/src/error.rs @@ -53,3 +53,79 @@ impl From for AuthError { } pub type AuthResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn auth_error_invalid_credentials_maps_to_unauthorized() { + let app: AppError = AuthError::InvalidCredentials.into(); + match app { + AppError::Unauthorized => {} + other => panic!("Expected Unauthorized, got {:?}", other), + } + } + + #[test] + fn auth_error_token_expired_maps_to_unauthorized() { + let app: AppError = AuthError::TokenExpired.into(); + match app { + AppError::Unauthorized => {} + other => panic!("Expected Unauthorized, got {:?}", other), + } + } + + #[test] + fn auth_error_user_disabled_maps_to_forbidden() { + let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into(); + match app { + AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"), + other => panic!("Expected Forbidden, got {:?}", other), + } + } + + #[test] + fn auth_error_hash_error_maps_to_internal() { + let app: AppError = AuthError::HashError("argon2 failed".to_string()).into(); + match app { + AppError::Internal(_) => {} + other => panic!("Expected Internal, got {:?}", other), + } + } + + #[test] + fn auth_error_validation_maps_to_validation() { + let app: AppError = AuthError::Validation("用户名已存在".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"), + other => panic!("Expected Validation, got {:?}", other), + } + } + + #[test] + fn auth_error_version_mismatch_roundtrip() { + // AuthError::VersionMismatch -> AppError::VersionMismatch -> AuthError::VersionMismatch + let app: AppError = AuthError::VersionMismatch.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("Expected VersionMismatch, got {:?}", other), + } + // And back + let auth: AuthError = AppError::VersionMismatch.into(); + match auth { + AuthError::VersionMismatch => {} + other => panic!("Expected VersionMismatch, got {:?}", other), + } + } + + #[test] + fn app_error_other_maps_to_auth_validation() { + let auth: AuthError = AppError::NotFound("not found".to_string()).into(); + match auth { + AuthError::Validation(msg) => assert!(msg.contains("not found")), + other => panic!("Expected Validation, got {:?}", other), + } + } +} diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs index c95733d..02257e8 100644 --- a/crates/erp-auth/src/service/org_service.rs +++ b/crates/erp-auth/src/service/org_service.rs @@ -292,7 +292,7 @@ impl OrgService { /// /// Root nodes (parent_id = None) form the top level. Each node recursively /// includes its children grouped by parent_id. -fn build_org_tree(items: &[organization::Model]) -> Vec { +pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec { let mut children_map: HashMap, Vec<&organization::Model>> = HashMap::new(); for item in items { children_map.entry(item.parent_id).or_default().push(item); @@ -329,3 +329,121 @@ fn build_org_tree(items: &[organization::Model]) -> Vec { }) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use uuid::Uuid; + + use crate::entity::organization; + + use super::*; + + fn make_org( + id: Uuid, + tenant_id: Uuid, + name: &str, + parent_id: Option, + level: i32, + version: i32, + ) -> organization::Model { + organization::Model { + id, + tenant_id, + name: name.to_string(), + code: None, + parent_id, + path: None, + level, + sort_order: 0, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version, + } + } + + #[test] + fn build_org_tree_empty() { + let tree = build_org_tree(&[]); + assert!(tree.is_empty()); + } + + #[test] + fn build_org_tree_single_root() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].name, "总公司"); + assert!(tree[0].children.is_empty()); + } + + #[test] + fn build_org_tree_multiple_roots() { + let tid = Uuid::now_v7(); + let items = vec![ + make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1), + make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 2); + } + + #[test] + fn build_org_tree_nested_children() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let child1_id = Uuid::now_v7(); + let child2_id = Uuid::now_v7(); + let grandchild_id = Uuid::now_v7(); + + let items = vec![ + make_org(root_id, tid, "总公司", None, 1, 1), + make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1), + make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1), + make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); // one root + assert_eq!(tree[0].children.len(), 2); // two children + assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild + assert_eq!(tree[0].children[0].children[0].name, "部门A1"); + } + + #[test] + fn build_org_tree_deep_nesting() { + let tid = Uuid::now_v7(); + let l1 = Uuid::now_v7(); + let l2 = Uuid::now_v7(); + let l3 = Uuid::now_v7(); + let l4 = Uuid::now_v7(); + + let items = vec![ + make_org(l1, tid, "L1", None, 1, 1), + make_org(l2, tid, "L2", Some(l1), 2, 1), + make_org(l3, tid, "L3", Some(l2), 3, 1), + make_org(l4, tid, "L4", Some(l3), 4, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children[0].children[0].children[0].name, "L4"); + } + + #[test] + fn build_org_tree_preserves_version() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let items = vec![make_org(root_id, tid, "测试", None, 1, 5)]; + + let tree = build_org_tree(&items); + assert_eq!(tree[0].version, 5); + } +} diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index 184386c..87aac34 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -393,7 +393,7 @@ impl UserService { } /// Convert a SeaORM user Model and its role DTOs into a UserResp. -fn model_to_resp(m: &user::Model, roles: Vec) -> UserResp { +pub(crate) fn model_to_resp(m: &user::Model, roles: Vec) -> UserResp { UserResp { id: m.id, username: m.username.clone(), @@ -406,3 +406,82 @@ fn model_to_resp(m: &user::Model, roles: Vec) -> UserResp { version: m.version, } } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use uuid::Uuid; + + use crate::dto::RoleResp; + use crate::entity::user; + + use super::*; + + fn make_user_model( + id: Uuid, + tenant_id: Uuid, + username: &str, + status: &str, + version: i32, + ) -> user::Model { + user::Model { + id, + tenant_id, + username: username.to_string(), + email: None, + phone: None, + display_name: None, + avatar_url: None, + status: status.to_string(), + last_login_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version, + } + } + + #[test] + fn model_to_resp_maps_basic_fields() { + let id = Uuid::now_v7(); + let tid = Uuid::now_v7(); + let m = make_user_model(id, tid, "alice", "active", 1); + let resp = model_to_resp(&m, vec![]); + assert_eq!(resp.id, id); + assert_eq!(resp.username, "alice"); + assert_eq!(resp.status, "active"); + assert_eq!(resp.version, 1); + assert!(resp.roles.is_empty()); + } + + #[test] + fn model_to_resp_includes_roles() { + let id = Uuid::now_v7(); + let tid = Uuid::now_v7(); + let m = make_user_model(id, tid, "bob", "active", 2); + let roles = vec![ + RoleResp { + id: Uuid::now_v7(), + name: "管理员".to_string(), + code: "admin".to_string(), + description: None, + is_system: true, + version: 1, + }, + RoleResp { + id: Uuid::now_v7(), + name: "用户".to_string(), + code: "user".to_string(), + description: None, + is_system: false, + version: 1, + }, + ]; + let resp = model_to_resp(&m, roles); + assert_eq!(resp.roles.len(), 2); + assert_eq!(resp.roles[0].code, "admin"); + assert_eq!(resp.version, 2); + } +} diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs index a95d433..d4e3bb5 100644 --- a/crates/erp-config/src/dto.rs +++ b/crates/erp-config/src/dto.rs @@ -113,7 +113,7 @@ pub struct UpdateMenuReq { #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct BatchSaveMenusReq { - #[validate(length(min = 1, message = "菜单列表不能为空"))] + #[validate(length(min = 1, message = "菜单列表不能为空"), nested)] pub menus: Vec, } @@ -239,3 +239,431 @@ pub struct LanguageResp { pub struct UpdateLanguageReq { pub is_active: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + // ---- CreateDictionaryReq 验证 ---- + + #[test] + fn create_dictionary_req_valid() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "status".to_string(), + description: Some("通用状态".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_req_empty_name_fails() { + let req = CreateDictionaryReq { + name: "".to_string(), + code: "status".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_empty_code_fails() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_name_too_long_fails() { + let req = CreateDictionaryReq { + name: "x".repeat(101), + code: "status".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_code_too_long_fails() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "x".repeat(51), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_max_boundary_ok() { + let req = CreateDictionaryReq { + name: "x".repeat(100), + code: "x".repeat(50), + description: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateDictionaryItemReq 验证 ---- + + #[test] + fn create_dictionary_item_req_valid() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "active".to_string(), + sort_order: Some(1), + color: Some("#00FF00".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_item_req_empty_label_fails() { + let req = CreateDictionaryItemReq { + label: "".to_string(), + value: "active".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_empty_value_fails() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_label_too_long_fails() { + let req = CreateDictionaryItemReq { + label: "x".repeat(101), + value: "active".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_value_too_long_fails() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "x".repeat(101), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_min_boundary_ok() { + let req = CreateDictionaryItemReq { + label: "x".to_string(), + value: "x".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_item_req_max_boundary_ok() { + let req = CreateDictionaryItemReq { + label: "x".repeat(100), + value: "x".repeat(100), + sort_order: Some(99), + color: Some("#FFFFFF".to_string()), + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateMenuReq 验证 ---- + + #[test] + fn create_menu_req_valid() { + let req = CreateMenuReq { + parent_id: None, + title: "系统设置".to_string(), + path: Some("/settings".to_string()), + icon: Some("SettingOutlined".to_string()), + sort_order: Some(1), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_menu_req_empty_title_fails() { + let req = CreateMenuReq { + parent_id: None, + title: "".to_string(), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_menu_req_title_too_long_fails() { + let req = CreateMenuReq { + parent_id: None, + title: "x".repeat(101), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_menu_req_title_max_boundary_ok() { + let req = CreateMenuReq { + parent_id: None, + title: "x".repeat(100), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- BatchSaveMenusReq 验证 ---- + + #[test] + fn batch_save_menus_req_valid() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "首页".to_string(), + path: Some("/home".to_string()), + icon: None, + sort_order: Some(0), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn batch_save_menus_req_empty_list_fails() { + let req = BatchSaveMenusReq { menus: vec![] }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_item_empty_title_fails() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "".to_string(), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_item_title_too_long_fails() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "x".repeat(101), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_multiple_items_ok() { + let req = BatchSaveMenusReq { + menus: vec![ + MenuItemReq { + id: None, + parent_id: None, + title: "菜单A".to_string(), + path: Some("/a".to_string()), + icon: None, + sort_order: Some(0), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: None, + }, + MenuItemReq { + id: None, + parent_id: None, + title: "菜单B".to_string(), + path: Some("/b".to_string()), + icon: None, + sort_order: Some(1), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: Some(1), + }, + ], + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateNumberingRuleReq 验证 ---- + + #[test] + fn create_numbering_rule_req_valid() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "ORDER".to_string(), + prefix: Some("ORD".to_string()), + date_format: Some("%Y%m%d".to_string()), + seq_length: Some(4), + seq_start: Some(1), + separator: Some("-".to_string()), + reset_cycle: Some("daily".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_numbering_rule_req_empty_name_fails() { + let req = CreateNumberingRuleReq { + name: "".to_string(), + code: "ORDER".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_empty_code_fails() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_name_too_long_fails() { + let req = CreateNumberingRuleReq { + name: "x".repeat(101), + code: "ORDER".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_code_too_long_fails() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "x".repeat(51), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_max_boundary_ok() { + let req = CreateNumberingRuleReq { + name: "x".repeat(100), + code: "x".repeat(50), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- UpdateSettingReq 验证 ---- + + #[test] + fn update_setting_req_valid() { + let req = UpdateSettingReq { + setting_value: serde_json::json!({"key": "value"}), + version: Some(1), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn update_setting_req_without_version_ok() { + let req = UpdateSettingReq { + setting_value: serde_json::json!("hello"), + version: None, + }; + assert!(req.validate().is_ok()); + } +} diff --git a/crates/erp-config/src/error.rs b/crates/erp-config/src/error.rs index 3e05fa7..466c7c3 100644 --- a/crates/erp-config/src/error.rs +++ b/crates/erp-config/src/error.rs @@ -41,3 +41,76 @@ impl From for AppError { } pub type ConfigResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn config_error_validation_maps_to_app_validation() { + let app: AppError = ConfigError::Validation("字段不能为空".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "字段不能为空"), + other => panic!("期望 Validation,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_not_found_maps_to_app_not_found() { + let app: AppError = ConfigError::NotFound("字典不存在".to_string()).into(); + match app { + AppError::NotFound(msg) => assert_eq!(msg, "字典不存在"), + other => panic!("期望 NotFound,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_duplicate_key_maps_to_app_conflict() { + let app: AppError = ConfigError::DuplicateKey("编码已存在".to_string()).into(); + match app { + AppError::Conflict(msg) => assert_eq!(msg, "编码已存在"), + other => panic!("期望 Conflict,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_numbering_exhausted_maps_to_app_internal() { + let app: AppError = ConfigError::NumberingExhausted("序列已耗尽".to_string()).into(); + match app { + AppError::Internal(msg) => assert!(msg.contains("序列已耗尽")), + other => panic!("期望 Internal,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_version_mismatch_maps_to_app_version_mismatch() { + let app: AppError = ConfigError::VersionMismatch.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("期望 VersionMismatch,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_display_messages() { + // 验证各变体的 Display 输出包含中文描述 + assert!(ConfigError::Validation("test".into()).to_string().contains("验证失败")); + assert!(ConfigError::NotFound("test".into()).to_string().contains("资源未找到")); + assert!(ConfigError::DuplicateKey("test".into()).to_string().contains("键已存在")); + assert!(ConfigError::NumberingExhausted("test".into()).to_string().contains("编号序列耗尽")); + assert!(ConfigError::VersionMismatch.to_string().contains("版本冲突")); + } + + #[test] + fn transaction_error_connection_maps_to_validation() { + // TransactionError::Connection 应该转换为 ConfigError::Validation + let config_err: ConfigError = + sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接失败".to_string()))) + .into(); + match config_err { + ConfigError::Validation(msg) => assert!(msg.contains("连接失败")), + other => panic!("期望 Validation,实际得到 {:?}", other), + } + } +} diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs index c1e2b5a..205ca42 100644 --- a/crates/erp-config/src/service/menu_service.rs +++ b/crates/erp-config/src/service/menu_service.rs @@ -387,3 +387,163 @@ impl MenuService { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + /// 辅助:构造 menu::Model + fn make_menu(id: Uuid, parent_id: Option, title: &str, sort_order: i32) -> menu::Model { + let now = Utc::now(); + let tenant_id = Uuid::now_v7(); + menu::Model { + id, + tenant_id, + parent_id, + title: title.to_string(), + path: Some(format!("/{}", title.to_lowercase())), + icon: None, + sort_order, + visible: true, + menu_type: "menu".to_string(), + permission: None, + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 1, + } + } + + #[test] + fn build_tree_empty_input() { + let nodes: Vec<&menu::Model> = vec![]; + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let tree = MenuService::build_tree(&nodes, &children_map); + assert!(tree.is_empty()); + } + + #[test] + fn build_tree_single_root() { + let root_id = Uuid::now_v7(); + let root = make_menu(root_id, None, "首页", 0); + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].id, root_id); + assert_eq!(tree[0].title, "首页"); + assert!(tree[0].children.is_empty()); + } + + #[test] + fn build_tree_two_levels() { + // 根节点 -> 子节点1, 子节点2 + let root_id = Uuid::now_v7(); + let child1_id = Uuid::now_v7(); + let child2_id = Uuid::now_v7(); + + let root = make_menu(root_id, None, "系统管理", 0); + let child1 = make_menu(child1_id, Some(root_id), "用户管理", 1); + let child2 = make_menu(child2_id, Some(root_id), "角色管理", 2); + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + children_map.insert(Some(root_id), vec![&child1, &child2]); + + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), 2); + assert_eq!(tree[0].children[0].title, "用户管理"); + assert_eq!(tree[0].children[1].title, "角色管理"); + } + + #[test] + fn build_tree_three_levels() { + // 根 -> 子 -> 孙 + let root_id = Uuid::now_v7(); + let child_id = Uuid::now_v7(); + let grandchild_id = Uuid::now_v7(); + + let root = make_menu(root_id, None, "系统管理", 0); + let child = make_menu(child_id, Some(root_id), "用户管理", 1); + let grandchild = make_menu(grandchild_id, Some(child_id), "用户详情", 0); + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + children_map.insert(Some(root_id), vec![&child]); + children_map.insert(Some(child_id), vec![&grandchild]); + + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].children.len(), 1); + assert_eq!(tree[0].children[0].children[0].title, "用户详情"); + } + + #[test] + fn build_tree_multiple_roots() { + // 两个独立的根节点 + let root1_id = Uuid::now_v7(); + let root2_id = Uuid::now_v7(); + + let root1 = make_menu(root1_id, None, "首页", 0); + let root2 = make_menu(root2_id, None, "系统管理", 1); + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root1, &root2]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 2); + assert_eq!(tree[0].title, "首页"); + assert_eq!(tree[1].title, "系统管理"); + } + + #[test] + fn build_tree_preserves_model_fields() { + let root_id = Uuid::now_v7(); + let now = Utc::now(); + let tenant_id = Uuid::now_v7(); + + let root = menu::Model { + id: root_id, + tenant_id, + parent_id: None, + title: "设置".to_string(), + path: Some("/settings".to_string()), + icon: Some("SettingOutlined".to_string()), + sort_order: 5, + visible: false, + menu_type: "directory".to_string(), + permission: Some("settings:view".to_string()), + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 3, + }; + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + let node = &tree[0]; + assert_eq!(node.id, root_id); + assert_eq!(node.title, "设置"); + assert_eq!(node.path, Some("/settings".to_string())); + assert_eq!(node.icon, Some("SettingOutlined".to_string())); + assert_eq!(node.sort_order, 5); + assert!(!node.visible); + assert_eq!(node.menu_type, "directory"); + assert_eq!(node.permission, Some("settings:view".to_string())); + assert_eq!(node.version, 3); + } +} diff --git a/crates/erp-config/src/service/numbering_service.rs b/crates/erp-config/src/service/numbering_service.rs index 1a61475..5df4b33 100644 --- a/crates/erp-config/src/service/numbering_service.rs +++ b/crates/erp-config/src/service/numbering_service.rs @@ -440,3 +440,236 @@ impl NumberingService { } } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + /// 辅助:构造 NaiveDate + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + // ---- maybe_reset_sequence 测试 ---- + + #[test] + fn reset_never_keeps_current() { + // "never" 周期:永远不重置,保持 seq_current + let result = NumberingService::maybe_reset_sequence( + 100, + 1, + "never", + Some(date(2025, 1, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 100); + } + + #[test] + fn reset_unknown_cycle_keeps_current() { + // 未知周期值等同于不重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "weekly", + Some(date(2025, 1, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 50); + } + + #[test] + fn reset_daily_same_day_keeps_current() { + // 同一天内不重置 + let today = date(2026, 4, 15); + let result = NumberingService::maybe_reset_sequence(42, 1, "daily", Some(today), today); + assert_eq!(result, 42); + } + + #[test] + fn reset_daily_different_day_resets() { + // 不同天重置为 seq_start + let result = NumberingService::maybe_reset_sequence( + 42, + 1, + "daily", + Some(date(2026, 4, 14)), + date(2026, 4, 15), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_daily_resets_with_custom_start() { + // 重置时使用自定义 seq_start + let result = NumberingService::maybe_reset_sequence( + 99, + 10, + "daily", + Some(date(2026, 4, 10)), + date(2026, 4, 15), + ); + assert_eq!(result, 10); + } + + #[test] + fn reset_monthly_same_month_keeps_current() { + // 同月不重置 + let result = NumberingService::maybe_reset_sequence( + 30, + 1, + "monthly", + Some(date(2026, 4, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 30); + } + + #[test] + fn reset_monthly_different_month_resets() { + // 不同月份重置 + let result = NumberingService::maybe_reset_sequence( + 30, + 1, + "monthly", + Some(date(2026, 3, 31)), + date(2026, 4, 1), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_monthly_same_month_different_year_resets() { + // 不同年份但相同月份数字,仍然重置 + let result = NumberingService::maybe_reset_sequence( + 20, + 5, + "monthly", + Some(date(2025, 4, 15)), + date(2026, 4, 15), + ); + assert_eq!(result, 5); + } + + #[test] + fn reset_yearly_same_year_keeps_current() { + // 同年不重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "yearly", + Some(date(2026, 1, 1)), + date(2026, 12, 31), + ); + assert_eq!(result, 50); + } + + #[test] + fn reset_yearly_different_year_resets() { + // 不同年份重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "yearly", + Some(date(2025, 12, 31)), + date(2026, 1, 1), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_no_last_reset_date_returns_seq_start() { + // 从未重置过,使用 seq_start + let result = NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15)); + assert_eq!(result, 1); + } + + #[test] + fn reset_no_last_reset_date_uses_custom_start() { + // 从未重置过,使用自定义 seq_start + let result = + NumberingService::maybe_reset_sequence(999, 42, "monthly", None, date(2026, 4, 15)); + assert_eq!(result, 42); + } + + // ---- model_to_resp 测试 ---- + + #[test] + fn model_to_resp_maps_fields_correctly() { + let id = Uuid::now_v7(); + let tenant_id = Uuid::now_v7(); + let now = Utc::now(); + let today = now.date_naive(); + + let model = numbering_rule::Model { + id, + tenant_id, + name: "订单编号".to_string(), + code: "ORDER".to_string(), + prefix: "ORD".to_string(), + date_format: Some("%Y%m%d".to_string()), + seq_length: 6, + seq_start: 1, + seq_current: 42, + separator: "-".to_string(), + reset_cycle: "daily".to_string(), + last_reset_date: Some(today), + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 3, + }; + + let resp = NumberingService::model_to_resp(&model); + + assert_eq!(resp.id, id); + assert_eq!(resp.name, "订单编号"); + assert_eq!(resp.code, "ORDER"); + assert_eq!(resp.prefix, "ORD"); + assert_eq!(resp.date_format, Some("%Y%m%d".to_string())); + assert_eq!(resp.seq_length, 6); + assert_eq!(resp.seq_start, 1); + assert_eq!(resp.seq_current, 42); + assert_eq!(resp.separator, "-"); + assert_eq!(resp.reset_cycle, "daily"); + assert_eq!(resp.last_reset_date, Some(today.to_string())); + assert_eq!(resp.version, 3); + } + + #[test] + fn model_to_resp_none_fields() { + let id = Uuid::now_v7(); + let tenant_id = Uuid::now_v7(); + let now = Utc::now(); + + let model = numbering_rule::Model { + id, + tenant_id, + name: "简单编号".to_string(), + code: "SIMPLE".to_string(), + prefix: "".to_string(), + date_format: None, + seq_length: 4, + seq_start: 1, + seq_current: 1, + separator: "-".to_string(), + reset_cycle: "never".to_string(), + last_reset_date: None, + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 1, + }; + + let resp = NumberingService::model_to_resp(&model); + + assert_eq!(resp.date_format, None); + assert_eq!(resp.last_reset_date, None); + assert_eq!(resp.prefix, ""); + } +} diff --git a/crates/erp-core/src/error.rs b/crates/erp-core/src/error.rs index e652b63..9fd88ee 100644 --- a/crates/erp-core/src/error.rs +++ b/crates/erp-core/src/error.rs @@ -1,6 +1,6 @@ -use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +use axum::Json; use serde::Serialize; /// 统一错误响应格式 @@ -95,3 +95,91 @@ pub fn check_version(expected: i32, actual: i32) -> AppResult { Err(AppError::VersionMismatch) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_version_ok() { + assert_eq!(check_version(1, 1).unwrap(), 2); + assert_eq!(check_version(5, 5).unwrap(), 6); + } + + #[test] + fn check_version_mismatch() { + let result = check_version(1, 2); + assert!(result.is_err()); + match result.unwrap_err() { + AppError::VersionMismatch => {} + other => panic!("Expected VersionMismatch, got {:?}", other), + } + } + + #[test] + fn db_err_record_not_found_maps_to_not_found() { + let err = sea_orm::DbErr::RecordNotFound("test".to_string()); + let app_err: AppError = err.into(); + match app_err { + AppError::NotFound(msg) => assert_eq!(msg, "test"), + other => panic!("Expected NotFound, got {:?}", other), + } + } + + #[test] + fn db_err_generic_maps_to_internal() { + let db_err = sea_orm::DbErr::Custom("some error".to_string()); + let app_err: AppError = db_err.into(); + match app_err { + AppError::Internal(msg) => assert!(msg.contains("some error")), + other => panic!("Expected Internal, got {:?}", other), + } + } + + #[test] + fn app_error_into_response_status_codes() { + // NotFound -> 404 + let resp = AppError::NotFound("test".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Validation -> 400 + let resp = AppError::Validation("bad input".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Unauthorized -> 401 + let resp = AppError::Unauthorized.into_response(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + // Forbidden -> 403 + let resp = AppError::Forbidden("no access".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // VersionMismatch -> 409 + let resp = AppError::VersionMismatch.into_response(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // TooManyRequests -> 429 + let resp = AppError::TooManyRequests.into_response(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + + // Internal -> 500 + let resp = AppError::Internal("oops".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn app_error_internal_hides_details_from_response() { + // Internal errors should map to 500 with a generic message + let resp = AppError::Internal("sensitive db error detail".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn anyhow_error_maps_to_internal() { + let err: AppError = anyhow::anyhow!("something went wrong").into(); + match err { + AppError::Internal(msg) => assert_eq!(msg, "something went wrong"), + other => panic!("Expected Internal, got {:?}", other), + } + } +} diff --git a/crates/erp-core/src/types.rs b/crates/erp-core/src/types.rs index 40f83a7..8c69bfe 100644 --- a/crates/erp-core/src/types.rs +++ b/crates/erp-core/src/types.rs @@ -32,6 +32,93 @@ impl Pagination { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pagination_defaults() { + let p = Pagination { + page: None, + page_size: None, + }; + assert_eq!(p.limit(), 20); + assert_eq!(p.offset(), 0); + } + + #[test] + fn pagination_custom_values() { + let p = Pagination { + page: Some(3), + page_size: Some(10), + }; + assert_eq!(p.limit(), 10); + assert_eq!(p.offset(), 20); // (3-1) * 10 + } + + #[test] + fn pagination_max_cap() { + let p = Pagination { + page: Some(1), + page_size: Some(200), + }; + assert_eq!(p.limit(), 100); // capped at 100 + } + + #[test] + fn pagination_page_zero_treated_as_first() { + // page 0 -> saturating_sub wraps to 0 -> offset = 0 + let p = Pagination { + page: Some(0), + page_size: Some(10), + }; + assert_eq!(p.offset(), 0); + } + + #[test] + fn pagination_page_one() { + let p = Pagination { + page: Some(1), + page_size: Some(50), + }; + assert_eq!(p.offset(), 0); + } + + #[test] + fn paginated_response_total_pages() { + let resp = PaginatedResponse { + data: vec![1, 2, 3], + total: 25, + page: 1, + page_size: 10, + total_pages: 3, + }; + assert_eq!(resp.data.len(), 3); + assert_eq!(resp.total, 25); + assert_eq!(resp.total_pages, 3); + } + + #[test] + fn api_response_ok() { + let resp = ApiResponse::ok(42); + assert!(resp.success); + assert_eq!(resp.data, Some(42)); + assert!(resp.message.is_none()); + } + + #[test] + fn tenant_context_fields() { + let ctx = TenantContext { + tenant_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + roles: vec!["admin".to_string()], + permissions: vec!["user.read".to_string()], + }; + assert_eq!(ctx.roles.len(), 1); + assert_eq!(ctx.permissions.len(), 1); + } +} + /// 分页响应 #[derive(Debug, Serialize)] pub struct PaginatedResponse { diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs index b02022e..b249fe6 100644 --- a/crates/erp-message/src/dto.rs +++ b/crates/erp-message/src/dto.rs @@ -176,3 +176,319 @@ pub struct UpdateSubscriptionReq { pub dnd_end: Option, pub version: i32, } + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + use validator::Validate; + + // ============ SendMessageReq 测试 ============ + + fn valid_send_message_req() -> SendMessageReq { + SendMessageReq { + title: "系统通知".to_string(), + body: "您有一条新消息".to_string(), + recipient_id: Uuid::now_v7(), + recipient_type: "user".to_string(), + priority: "normal".to_string(), + template_id: None, + business_type: None, + business_id: None, + } + } + + #[test] + fn send_message_req_valid() { + let req = valid_send_message_req(); + assert!(req.validate().is_ok()); + } + + #[test] + fn send_message_req_empty_title_fails() { + let mut req = valid_send_message_req(); + req.title = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_title_too_long_fails() { + let mut req = valid_send_message_req(); + req.title = "x".repeat(201); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_title_max_length_ok() { + let mut req = valid_send_message_req(); + req.title = "x".repeat(200); + assert!(req.validate().is_ok()); + } + + #[test] + fn send_message_req_empty_body_fails() { + let mut req = valid_send_message_req(); + req.body = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_valid_recipient_types() { + for rt in &["user", "role", "department", "all"] { + let mut req = valid_send_message_req(); + req.recipient_type = rt.to_string(); + assert!(req.validate().is_ok(), "recipient_type '{}' should be valid", rt); + } + } + + #[test] + fn send_message_req_invalid_recipient_type_fails() { + let mut req = valid_send_message_req(); + req.recipient_type = "invalid".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_valid_priorities() { + for p in &["normal", "important", "urgent"] { + let mut req = valid_send_message_req(); + req.priority = p.to_string(); + assert!(req.validate().is_ok(), "priority '{}' should be valid", p); + } + } + + #[test] + fn send_message_req_invalid_priority_fails() { + let mut req = valid_send_message_req(); + req.priority = "critical".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_default_recipient_type_is_user() { + assert_eq!(default_recipient_type(), "user"); + } + + #[test] + fn send_message_req_default_priority_is_normal() { + assert_eq!(default_priority(), "normal"); + } + + // ============ MessageQuery 测试 ============ + + #[test] + fn message_query_safe_page_size_default() { + let query = MessageQuery { + page: None, + page_size: None, + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 20); + } + + #[test] + fn message_query_safe_page_size_custom() { + let query = MessageQuery { + page: None, + page_size: Some(50), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 50); + } + + #[test] + fn message_query_safe_page_size_capped_at_100() { + let query = MessageQuery { + page: None, + page_size: Some(200), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 100); + } + + #[test] + fn message_query_safe_page_size_exactly_100() { + let query = MessageQuery { + page: None, + page_size: Some(100), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 100); + } + + // ============ CreateTemplateReq 测试 ============ + + fn valid_create_template_req() -> CreateTemplateReq { + CreateTemplateReq { + name: "欢迎模板".to_string(), + code: "WELCOME".to_string(), + channel: "in_app".to_string(), + title_template: "欢迎加入".to_string(), + body_template: "您好,{{name}},欢迎加入平台".to_string(), + language: "zh-CN".to_string(), + } + } + + #[test] + fn create_template_req_valid() { + let req = valid_create_template_req(); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_empty_name_fails() { + let mut req = valid_create_template_req(); + req.name = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_name_too_long_fails() { + let mut req = valid_create_template_req(); + req.name = "x".repeat(101); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_name_max_length_ok() { + let mut req = valid_create_template_req(); + req.name = "x".repeat(100); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_empty_code_fails() { + let mut req = valid_create_template_req(); + req.code = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_code_too_long_fails() { + let mut req = valid_create_template_req(); + req.code = "X".repeat(51); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_code_max_length_ok() { + let mut req = valid_create_template_req(); + req.code = "X".repeat(50); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_valid_channels() { + for ch in &["in_app", "email", "sms", "wechat"] { + let mut req = valid_create_template_req(); + req.channel = ch.to_string(); + assert!(req.validate().is_ok(), "channel '{}' should be valid", ch); + } + } + + #[test] + fn create_template_req_invalid_channel_fails() { + let mut req = valid_create_template_req(); + req.channel = "telegram".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_empty_title_template_fails() { + let mut req = valid_create_template_req(); + req.title_template = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_title_template_too_long_fails() { + let mut req = valid_create_template_req(); + req.title_template = "x".repeat(201); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_empty_body_template_fails() { + let mut req = valid_create_template_req(); + req.body_template = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_default_channel_is_in_app() { + assert_eq!(default_channel(), "in_app"); + } + + #[test] + fn create_template_req_default_language_is_zh_cn() { + assert_eq!(default_language(), "zh-CN"); + } + + // ============ 自定义验证函数测试 ============ + + #[test] + fn validate_recipient_type_valid() { + for rt in &["user", "role", "department", "all"] { + assert!( + validate_recipient_type(rt).is_ok(), + "'{}' should be a valid recipient type", + rt + ); + } + } + + #[test] + fn validate_recipient_type_invalid() { + assert!(validate_recipient_type("invalid").is_err()); + assert!(validate_recipient_type("").is_err()); + assert!(validate_recipient_type("USER").is_err()); + } + + #[test] + fn validate_priority_valid() { + for p in &["normal", "important", "urgent"] { + assert!( + validate_priority(p).is_ok(), + "'{}' should be a valid priority", + p + ); + } + } + + #[test] + fn validate_priority_invalid() { + assert!(validate_priority("critical").is_err()); + assert!(validate_priority("").is_err()); + assert!(validate_priority("NORMAL").is_err()); + } + + #[test] + fn validate_channel_valid() { + for ch in &["in_app", "email", "sms", "wechat"] { + assert!( + validate_channel(ch).is_ok(), + "'{}' should be a valid channel", + ch + ); + } + } + + #[test] + fn validate_channel_invalid() { + assert!(validate_channel("slack").is_err()); + assert!(validate_channel("").is_err()); + assert!(validate_channel("EMAIL").is_err()); + } +} diff --git a/crates/erp-message/src/error.rs b/crates/erp-message/src/error.rs index 92ab58c..03620bf 100644 --- a/crates/erp-message/src/error.rs +++ b/crates/erp-message/src/error.rs @@ -43,3 +43,78 @@ impl From> for MessageError { } pub type MessageResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn validation_maps_to_app_validation() { + let app: AppError = MessageError::Validation("标题不能为空".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"), + other => panic!("Expected AppError::Validation, got {:?}", other), + } + } + + #[test] + fn not_found_maps_to_app_not_found() { + let app: AppError = MessageError::NotFound("消息不存在".to_string()).into(); + match app { + AppError::NotFound(msg) => assert_eq!(msg, "消息不存在"), + other => panic!("Expected AppError::NotFound, got {:?}", other), + } + } + + #[test] + fn duplicate_template_code_maps_to_app_conflict() { + let app: AppError = MessageError::DuplicateTemplateCode("WELCOME".to_string()).into(); + match app { + AppError::Conflict(msg) => assert_eq!(msg, "WELCOME"), + other => panic!("Expected AppError::Conflict, got {:?}", other), + } + } + + #[test] + fn template_render_error_maps_to_app_internal() { + let app: AppError = MessageError::TemplateRenderError("变量缺失".to_string()).into(); + match app { + AppError::Internal(msg) => assert_eq!(msg, "变量缺失"), + other => panic!("Expected AppError::Internal, got {:?}", other), + } + } + + #[test] + fn version_mismatch_maps_to_app_version_mismatch() { + let app: AppError = MessageError::VersionMismatch.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("Expected AppError::VersionMismatch, got {:?}", other), + } + } + + #[test] + fn error_display_format() { + assert_eq!( + MessageError::Validation("字段为空".to_string()).to_string(), + "验证失败: 字段为空" + ); + assert_eq!( + MessageError::NotFound("id=123".to_string()).to_string(), + "未找到: id=123" + ); + assert_eq!( + MessageError::DuplicateTemplateCode("CODE".to_string()).to_string(), + "模板编码已存在: CODE" + ); + assert_eq!( + MessageError::TemplateRenderError("解析失败".to_string()).to_string(), + "渲染失败: 解析失败" + ); + assert_eq!( + MessageError::VersionMismatch.to_string(), + "版本冲突: 数据已被其他操作修改,请刷新后重试" + ); + } +} diff --git a/crates/erp-message/src/service/template_service.rs b/crates/erp-message/src/service/template_service.rs index d0a44f2..06039fc 100644 --- a/crates/erp-message/src/service/template_service.rs +++ b/crates/erp-message/src/service/template_service.rs @@ -113,3 +113,81 @@ impl TemplateService { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_replaces_single_variable() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "张三".to_string()); + let result = TemplateService::render("您好,{{name}}", &vars); + assert_eq!(result, "您好,张三"); + } + + #[test] + fn render_replaces_multiple_variables() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "李四".to_string()); + vars.insert("code".to_string(), "ORD-001".to_string()); + let result = TemplateService::render("{{name}},您的订单 {{code}} 已发货", &vars); + assert_eq!(result, "李四,您的订单 ORD-001 已发货"); + } + + #[test] + fn render_no_variables_returns_original() { + let vars = std::collections::HashMap::new(); + let result = TemplateService::render("没有变量的模板", &vars); + assert_eq!(result, "没有变量的模板"); + } + + #[test] + fn render_missing_variable_leaves_placeholder() { + let vars = std::collections::HashMap::new(); + let result = TemplateService::render("您好,{{name}}", &vars); + assert_eq!(result, "您好,{{name}}"); + } + + #[test] + fn render_same_variable_multiple_times() { + let mut vars = std::collections::HashMap::new(); + vars.insert("user".to_string(), "王五".to_string()); + let result = TemplateService::render("{{user}} 你好,{{user}} 的订单已确认", &vars); + assert_eq!(result, "王五 你好,王五 的订单已确认"); + } + + #[test] + fn render_empty_template() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "test".to_string()); + let result = TemplateService::render("", &vars); + assert_eq!(result, ""); + } + + #[test] + fn render_empty_variable_value() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "".to_string()); + let result = TemplateService::render("您好,{{name}}!", &vars); + assert_eq!(result, "您好,!"); + } + + #[test] + fn render_adjacent_variables() { + let mut vars = std::collections::HashMap::new(); + vars.insert("a".to_string(), "1".to_string()); + vars.insert("b".to_string(), "2".to_string()); + let result = TemplateService::render("{{a}}{{b}}", &vars); + assert_eq!(result, "12"); + } + + #[test] + fn render_extra_variables_not_in_template_are_ignored() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "赵六".to_string()); + vars.insert("unused".to_string(), "ignore".to_string()); + let result = TemplateService::render("你好 {{name}}", &vars); + assert_eq!(result, "你好 赵六"); + } +}