use axum::{Router, body::Body, routing::get}; use common_telemetry::{ error::AppError, response::AppResponse, axum_middleware::trace_http_request, telemetry::{self, TelemetryConfig}, }; use http::{Request, StatusCode}; use serde_json::Value; use std::{fs, time::Duration}; use tower::ServiceExt; // for oneshot // --- 模拟业务 Handler --- // 模拟场景1:Access Token 过期 async fn handler_access_expired() -> Result { // 模拟业务逻辑判断... Err(AppError::AccessTokenExpired) } // 模拟场景2:Refresh Token 过期 async fn handler_refresh_expired() -> Result { Err(AppError::RefreshTokenExpired) } // 模拟场景3:正常成功 async fn handler_success() -> Result { // 这里打印一条日志,测试 Tracing 是否工作 tracing::info!(user_id = 10086, "User accessed success handler"); Ok("Success Data".to_string()) } async fn handler_success_wrapped() -> Result, AppError> { Ok(AppResponse::ok("Success Data".to_string())) } async fn handler_bad_request() -> Result { Err(AppError::BadRequest("bad params".into())) } #[cfg(feature = "with-validator")] async fn handler_validation_error() -> Result { Err(AppError::ValidationError("field required".into())) } #[tokio::test] async fn test_full_flow_error_and_logging() { println!(">>> [Step 0] Test Initializing..."); // 1. 准备环境:创建一个临时目录存放日志,防止污染项目 let temp_dir = tempfile::tempdir().unwrap(); let log_dir_path = temp_dir.path().to_str().unwrap().to_string(); let log_filename = "test_app.log"; // 2. 初始化 Tracing (模拟生产环境配置) let config = TelemetryConfig { service_name: "test-service".into(), log_level: "info".into(), log_to_file: true, // 开启文件写入 log_dir: Some(log_dir_path.clone()), log_file: Some(log_filename.to_string()), }; // !!! 必须持有 guard,否则日志不会写入 let _guard = telemetry::init(config); // 给一点时间让 tracing 系统初始化 tokio::time::sleep(Duration::from_millis(50)).await; // 3. 构建 Axum Router let app = Router::new() .route("/access-expired", get(handler_access_expired)) .route("/refresh-expired", get(handler_refresh_expired)) .route("/success", get(handler_success)) .route("/success-wrapped", get(handler_success_wrapped)) .route("/bad-request", get(handler_bad_request)); #[cfg(feature = "with-validator")] let app = app.route("/validation-error", get(handler_validation_error)); println!(">>> [Step 1] Running Case A: Access Token Expired..."); // --- 测试用例 A: Access Token 过期 --- let response = app .clone() .oneshot( Request::builder() .uri("/access-expired") .body(Body::empty()) .unwrap(), ) .await .unwrap(); // 验证 HTTP 状态码 (401) assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // 验证 JSON Body let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: Value = serde_json::from_slice(&body_bytes).unwrap(); // 核心验证:code 必须是 20001 (BizCode::AccessTokenExpired) // 前端根据这个 code 进行静默刷新 assert_eq!(body["code"], 20001); // --- 测试用例 B: Refresh Token 过期 --- println!(">>> [Step 2] Running Case B: Refresh Token Expired..."); let response = app .clone() .oneshot( Request::builder() .uri("/refresh-expired") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: Value = serde_json::from_slice(&body_bytes).unwrap(); // 核心验证:code 必须是 20002 (BizCode::RefreshTokenExpired) // 前端根据这个 code 强制登出 assert_eq!(body["code"], 20002); // --- 测试用例 C: 验证日志文件是否生成 --- println!(">>> [Step 3] Running Case C: Log File Verification..."); // 先请求一次成功接口,触发 tracing::info! let _ = app .clone() .oneshot( Request::builder() .uri("/success") .body(Body::empty()) .unwrap(), ) .await .unwrap(); // --- 测试用例 D: 统一成功响应格式 --- println!(">>> [Step 4] Running Case D: Success Response Wrapper..."); let response = app .clone() .oneshot( Request::builder() .uri("/success-wrapped") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body["code"], 0); assert_eq!(body["message"], "Success"); assert_eq!(body["data"], "Success Data"); // --- 测试用例 E: 错误响应 details 字段 --- println!(">>> [Step 5] Running Case E: Error Response Details..."); let response = app .clone() .oneshot( Request::builder() .uri("/bad-request") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body["code"], 30000); assert_eq!(body["details"], "bad params"); #[cfg(feature = "with-validator")] { let response = app .clone() .oneshot( Request::builder() .uri("/validation-error") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body["code"], 30001); assert_eq!(body["details"], "field required"); } // 稍微等待异步日志写入磁盘 println!(" Waiting for logs to flush..."); tokio::time::sleep(Duration::from_millis(200)).await; // 验证文件 let mut found_file = false; let paths = fs::read_dir(temp_dir.path()).unwrap(); for path in paths { let entry = path.unwrap(); let file_name = entry.file_name().into_string().unwrap(); println!(" Found file in temp dir: {}", file_name); // tracing-appender 滚动日志通常格式为: filename.YYYY-MM-DD if file_name.starts_with(log_filename) { found_file = true; let content = fs::read_to_string(entry.path()).unwrap(); println!(" Log Content Preview: {}", content.trim()); assert!( content.contains("User accessed success handler"), "日志内容丢失!" ); assert!(content.contains("\"user_id\":10086"), "结构化字段丢失!"); } } assert!(found_file, "未在临时目录找到日志文件!"); println!(" [Success] Case C Passed (Log file verified)"); // temp_dir 会在作用域结束时自动删除清理 } #[tokio::test] async fn error_log_includes_http_context_span() { use std::sync::{Arc, Mutex}; use tracing_subscriber::fmt::MakeWriter; struct BufferWriter(Arc>>); impl<'a> MakeWriter<'a> for BufferWriter { type Writer = BufferGuard; fn make_writer(&'a self) -> Self::Writer { BufferGuard(self.0.clone()) } } struct BufferGuard(Arc>>); impl std::io::Write for BufferGuard { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().unwrap().extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } async fn handler() -> Result { Err(AppError::MissingAuthHeader) } let buf = Arc::new(Mutex::new(Vec::::new())); let subscriber = tracing_subscriber::fmt() .with_writer(BufferWriter(buf.clone())) .with_ansi(false) .json() .with_current_span(true) .with_span_list(true) .finish(); let _guard = tracing::subscriber::set_default(subscriber); let app = Router::new() .route("/needs-auth", get(handler)) .layer(axum::middleware::from_fn(trace_http_request)); let resp = app .oneshot( Request::builder() .uri("/needs-auth") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); assert!(logs.contains("\"message\":\"Request failed\"")); assert!(logs.contains("/needs-auth")); assert!(logs.contains("http.method") || logs.contains("GET")); }