use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::Router; use dotenv::dotenv; use htycommons::common::current_local_datetime; use htycommons::jwt::jwt_encode_token; use htycommons::redis_util::{get_token_expiration_days, save_token_with_exp_days}; use htycommons::web::HtyToken; use http_body_util::BodyExt; use serde_json::{json, Value}; use tower::util::ServiceExt; fn setup() -> Router { dotenv().ok(); std::env::set_var("POOL_SIZE", "1"); let db_url = std::env::var("KC_DB_URL") .unwrap_or_else(|_| "postgres://htykc:htykc@localhost:5432/htykc_test".to_string()); htykc::clazz_router(&db_url) } fn build_test_token(with_org_context: bool) -> String { std::env::set_var("JWT_KEY", "htykc-e2e-test-jwt-key"); let token = HtyToken { token_id: "test-token-id".to_string(), hty_id: Some("teacher-1".to_string()), app_id: Some("app-1".to_string()), ts: current_local_datetime(), roles: None, tags: None, current_org_id: if with_org_context { Some("org-1".to_string()) } else { None }, current_org_role_keys: Some(vec!["TEACHER".to_string()]), }; jwt_encode_token(token).expect("encode test token") } fn prepare_verified_token(with_org_context: bool) -> Option { let token = build_test_token(with_org_context); let decoded = htycommons::jwt::jwt_decode_token(&token.clone()).ok()?; let expiration_days = get_token_expiration_days().ok()?; if save_token_with_exp_days(&decoded, expiration_days).is_err() { return None; } Some(token) } async fn get_with_headers(router: &Router, uri: &str, headers: Vec<(&str, &str)>) -> (StatusCode, Value) { let mut request = Request::builder().method("GET").uri(uri); for (key, value) in headers { request = request.header(key, value); } let response = router .clone() .oneshot(request.body(Body::empty()).expect("build request")) .await .expect("send request"); let status = response.status(); let body_bytes = response .into_body() .collect() .await .expect("collect body") .to_bytes(); let json_body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); (status, json_body) } async fn post_with_headers( router: &Router, uri: &str, body: Value, headers: Vec<(&str, &str)>, ) -> (StatusCode, Value) { let mut request = Request::builder() .method("POST") .uri(uri) .header("Content-Type", "application/json"); for (key, value) in headers { request = request.header(key, value); } let response = router .clone() .oneshot( request .body(Body::from(body.to_string())) .expect("build request"), ) .await .expect("send request"); let status = response.status(); let body_bytes = response .into_body() .collect() .await .expect("collect body") .to_bytes(); let json_body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); (status, json_body) } #[tokio::test] async fn test_teacher_hour_stats_requires_current_org_id() { let router = match std::panic::catch_unwind(setup) { Ok(ok) => ok, Err(_) => { println!("DB unavailable in current test env, skip stats test"); return; } }; let token = match prepare_verified_token(false) { Some(ok) => ok, None => { println!("Redis unavailable in current test env, skip stats test"); return; } }; let (status, response) = get_with_headers( &router, "/api/v1/clazz/stats/my-hours?start_date=2026-01-01%2000:00:00&end_date=2026-12-31%2023:59:59", vec![ ("Authorization", token.as_str()), ("HtySudoerToken", token.as_str()), ("HtyHost", "root"), ], ) .await; assert_eq!(status, StatusCode::OK, "stats request should return business response envelope"); assert!( !response["r"].as_bool().unwrap_or(true), "stats should fail without current_org_id, body: {:?}", response ); } #[tokio::test] async fn test_create_clazz_leave_requires_current_org_id() { let router = match std::panic::catch_unwind(setup) { Ok(ok) => ok, Err(_) => { println!("DB unavailable in current test env, skip leave test"); return; } }; let token = match prepare_verified_token(false) { Some(ok) => ok, None => { println!("Redis unavailable in current test env, skip leave test"); return; } }; let (status, response) = post_with_headers( &router, "/api/v1/clazz/leave/create", json!({ "clazz_id": "clazz-1", "student_id": "student-1", "teacher_id": "teacher-1", "leave_type": "PERSONAL", "reason": "test leave request" }), vec![ ("Authorization", token.as_str()), ("HtySudoerToken", token.as_str()), ("HtyHost", "root"), ], ) .await; assert_eq!(status, StatusCode::OK, "leave request should return business response envelope"); assert!( !response["r"].as_bool().unwrap_or(true), "create leave should fail without current_org_id, body: {:?}", response ); } #[tokio::test] async fn test_teacher_hour_stats_with_current_org_id_should_not_fail_on_org_context_check() { let router = match std::panic::catch_unwind(setup) { Ok(ok) => ok, Err(_) => { println!("DB unavailable in current test env, skip stats positive test"); return; } }; let token = match prepare_verified_token(true) { Some(ok) => ok, None => { println!("Redis unavailable in current test env, skip stats positive test"); return; } }; let (status, response) = get_with_headers( &router, "/api/v1/clazz/stats/my-hours?start_date=2026-01-01%2000:00:00&end_date=2026-12-31%2023:59:59", vec![ ("Authorization", token.as_str()), ("HtySudoerToken", token.as_str()), ("HtyHost", "root"), ], ) .await; assert_eq!(status, StatusCode::OK, "stats request should return business response envelope"); let error_text = response .get("e") .and_then(|error_node| error_node.as_str()) .unwrap_or_default() .to_string(); assert!( !error_text.contains("current_org_id is required"), "stats with org context should not hit org-context-missing error, body: {:?}", response ); }