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.
This commit is contained in:
iven
2026-04-15 01:06:34 +08:00
parent 9568dd7875
commit ee65b6e3c9
13 changed files with 1995 additions and 4 deletions

View File

@@ -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<MenuItemReq>,
}
@@ -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());
}
}

View File

@@ -41,3 +41,76 @@ impl From<ConfigError> for AppError {
}
pub type ConfigResult<T> = Result<T, ConfigError>;
#[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),
}
}
}

View File

@@ -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<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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<Option<Uuid>, 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);
}
}

View File

@@ -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, "");
}
}