Files
huike-back/htykc/tests/e2e_clazz_tests.rs
T
weli c5134c9356 feat(org): enforce tenant isolation in ws/kc with leave and stats APIs
Add org_id schema migrations and service-level filtering for teacher-student and class workflows, then cover org-context behavior with focused unit/e2e tests for leave and hour statistics.

Made-with: Cursor
2026-04-27 20:12:02 +08:00

224 lines
6.8 KiB
Rust

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<String> {
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
);
}