Files
hms/crates/erp-core/src/error.rs
iven ee65b6e3c9 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.
2026-04-15 01:06:34 +08:00

186 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
/// 统一错误响应格式
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
/// 平台级错误类型
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("资源未找到: {0}")]
NotFound(String),
#[error("验证失败: {0}")]
Validation(String),
#[error("未授权")]
Unauthorized,
#[error("禁止访问: {0}")]
Forbidden(String),
#[error("冲突: {0}")]
Conflict(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
#[error("请求过于频繁,请稍后重试")]
TooManyRequests,
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string()),
};
let body = ErrorResponse {
error: status.canonical_reason().unwrap_or("Error").to_string(),
message,
details: None,
};
(status, Json(body)).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<sea_orm::DbErr> for AppError {
fn from(err: sea_orm::DbErr) -> Self {
match err {
sea_orm::DbErr::RecordNotFound(msg) => AppError::NotFound(msg),
sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(e))
if e.to_string().contains("duplicate key") =>
{
AppError::Conflict("记录已存在".to_string())
}
_ => AppError::Internal(err.to_string()),
}
}
}
pub type AppResult<T> = Result<T, AppError>;
/// 检查乐观锁版本是否匹配。
///
/// 返回下一个版本号actual + 1或 VersionMismatch 错误。
pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
if expected == actual {
Ok(actual + 1)
} else {
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),
}
}
}