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:
@@ -209,3 +209,183 @@ pub struct UpdatePositionReq {
|
|||||||
pub sort_order: Option<i32>,
|
pub sort_order: Option<i32>,
|
||||||
pub version: i32,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,79 @@ impl From<AppError> for AuthError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type AuthResult<T> = Result<T, AuthError>;
|
pub type AuthResult<T> = Result<T, AuthError>;
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ impl OrgService {
|
|||||||
///
|
///
|
||||||
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
||||||
/// includes its children grouped by parent_id.
|
/// includes its children grouped by parent_id.
|
||||||
fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
||||||
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
children_map.entry(item.parent_id).or_default().push(item);
|
children_map.entry(item.parent_id).or_default().push(item);
|
||||||
@@ -329,3 +329,121 @@ fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.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<Uuid>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ impl UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
|
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
|
||||||
fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
pub(crate) fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
||||||
UserResp {
|
UserResp {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
username: m.username.clone(),
|
username: m.username.clone(),
|
||||||
@@ -406,3 +406,82 @@ fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
|||||||
version: m.version,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pub struct UpdateMenuReq {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
pub struct BatchSaveMenusReq {
|
pub struct BatchSaveMenusReq {
|
||||||
#[validate(length(min = 1, message = "菜单列表不能为空"))]
|
#[validate(length(min = 1, message = "菜单列表不能为空"), nested)]
|
||||||
pub menus: Vec<MenuItemReq>,
|
pub menus: Vec<MenuItemReq>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,3 +239,431 @@ pub struct LanguageResp {
|
|||||||
pub struct UpdateLanguageReq {
|
pub struct UpdateLanguageReq {
|
||||||
pub is_active: bool,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,3 +41,76 @@ impl From<ConfigError> for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type ConfigResult<T> = Result<T, ConfigError>;
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -387,3 +387,163 @@ impl MenuService {
|
|||||||
.collect()
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::Json;
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// 统一错误响应格式
|
/// 统一错误响应格式
|
||||||
@@ -95,3 +95,91 @@ pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
|
|||||||
Err(AppError::VersionMismatch)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PaginatedResponse<T> {
|
pub struct PaginatedResponse<T> {
|
||||||
|
|||||||
@@ -176,3 +176,319 @@ pub struct UpdateSubscriptionReq {
|
|||||||
pub dnd_end: Option<String>,
|
pub dnd_end: Option<String>,
|
||||||
pub version: i32,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,3 +43,78 @@ impl From<sea_orm::TransactionError<MessageError>> for MessageError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type MessageResult<T> = Result<T, MessageError>;
|
pub type MessageResult<T> = Result<T, MessageError>;
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
"版本冲突: 数据已被其他操作修改,请刷新后重试"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "你好 赵六");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user