use super::*;
use std::time::Duration;
use proptest::prelude::*;
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::method;
use rsa::RsaPrivateKey;
use rsa::pkcs8::EncodePrivateKey;
use rsa::pkcs8::LineEnding;
use std::sync::OnceLock;
fn cached_test_private_key() -> &'static str {
static KEY: OnceLock<String> = OnceLock::new();
KEY.get_or_init(|| {
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成密钥失败");
private_key
.to_pkcs8_pem(LineEnding::LF)
.expect("编码 PKCS#8 PEM 失败")
.to_string()
})
}
fn test_config(server_url: &str) -> ClientConfig {
ClientConfig {
tiger_id: "test_tiger_id".to_string(),
private_key: cached_test_private_key().to_string(),
account: "DU123456".to_string(),
secret_key: None,
license: None,
language: crate::model::enums::Language::ZhCn,
timezone: None,
timeout: Duration::from_secs(5),
token: None,
token_refresh_duration: None,
token_check_interval: None,
token_writer: None,
token_loader: None,
server_url: server_url.to_string(),
quote_server_url: server_url.to_string(),
tiger_public_key: "".to_string(),
device_id: "".to_string(),
}
}
#[test]
fn test_user_agent() {
assert_eq!(HttpClient::user_agent(), format!("openapi-rust-sdk-{}", crate::VERSION));
}
#[test]
fn test_execute_rejects_empty_api_method() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = test_config("http://localhost:1234");
let client = HttpClient::new(config);
let result = client.execute("", r#"{"market":"US"}"#).await;
assert!(result.is_err());
match result.unwrap_err() {
TigerError::Config(msg) => assert!(msg.contains("api_method")),
other => panic!("应返回 Config 错误,实际: {:?}", other),
}
});
}
#[test]
fn test_execute_rejects_invalid_json() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = test_config("http://localhost:1234");
let client = HttpClient::new(config);
let result = client.execute("market_state", "not json").await;
assert!(result.is_err());
match result.unwrap_err() {
TigerError::Config(msg) => assert!(msg.contains("JSON")),
other => panic!("应返回 Config 错误,实际: {:?}", other),
}
});
}
#[tokio::test]
async fn test_execute_sends_request_and_returns_raw_response() {
let mock_server = MockServer::start().await;
let response_body = r#"{"code":0,"message":"success","data":{"market":"US"}}"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(response_body))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let result = client.execute("market_state", r#"{"market":"US"}"#).await;
assert!(result.is_ok());
let body = result.unwrap();
assert_eq!(body, response_body);
}
#[tokio::test]
async fn test_execute_request_parses_success_response() {
let mock_server = MockServer::start().await;
let response_body = r#"{"code":0,"message":"success","data":{"market":"US"},"timestamp":1234567890}"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(response_body))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let req = ApiRequest::new("market_state", r#"{"market":"US"}"#);
let result = client.execute_request(&req).await;
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.message, "success");
}
#[tokio::test]
async fn test_execute_request_returns_api_error() {
let mock_server = MockServer::start().await;
let response_body = r#"{"code":1010,"message":"参数错误","data":null,"timestamp":1234567890}"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(response_body))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let req = ApiRequest::new("market_state", r#"{"market":"US"}"#);
let result = client.execute_request(&req).await;
assert!(result.is_err());
match result.unwrap_err() {
TigerError::Api { code, message } => {
assert_eq!(code, 1010);
assert_eq!(message, "参数错误");
}
other => panic!("应返回 Api 错误,实际: {:?}", other),
}
}
fn valid_api_method() -> impl Strategy<Value = String> {
prop_oneof![
Just("market_state".to_string()),
Just("quote_real_time".to_string()),
Just("kline".to_string()),
Just("place_order".to_string()),
Just("get_position".to_string()),
Just("get_orders".to_string()),
]
}
fn valid_biz_content_json() -> impl Strategy<Value = String> {
prop_oneof![
Just(r#"{"market":"US"}"#.to_string()),
Just(r#"{"symbol":"AAPL"}"#.to_string()),
Just(r#"{"market":"HK","symbol":"00700"}"#.to_string()),
Just(r#"{}"#.to_string()),
Just(r#"{"limit":100}"#.to_string()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(30))]
#[test]
fn generic_execute_request_construction(
api_method in valid_api_method(),
biz_content in valid_biz_content_json(),
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mock_server = MockServer::start().await;
let response_body = r#"{"code":0,"message":"success","data":null}"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(response_body))
.expect(1)
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let result = client.execute(&api_method, &biz_content).await;
prop_assert!(result.is_ok(), "请求应成功");
let received = mock_server.received_requests().await.unwrap();
prop_assert_eq!(received.len(), 1, "应发送恰好一个请求");
let req_body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap();
prop_assert_eq!(req_body["method"].as_str().unwrap(), api_method.as_str(),
"method 字段应等于传入的 api_method");
prop_assert_eq!(req_body["biz_content"].as_str().unwrap(), biz_content.as_str(),
"biz_content 字段应等于传入的 request_json");
prop_assert_eq!(req_body["tiger_id"].as_str().unwrap(), "test_tiger_id",
"tiger_id 应正确");
prop_assert_eq!(req_body["charset"].as_str().unwrap(), "UTF-8",
"charset 应为 UTF-8");
prop_assert_eq!(req_body["sign_type"].as_str().unwrap(), "RSA",
"sign_type 应为 RSA");
prop_assert_eq!(req_body["version"].as_str().unwrap(), "2.0",
"version 应为 2.0");
prop_assert!(req_body["sign"].as_str().is_some(),
"应包含 sign 字段");
prop_assert!(req_body["timestamp"].as_str().is_some(),
"应包含 timestamp 字段");
Ok(())
})?;
}
}
fn valid_response_json() -> impl Strategy<Value = String> {
prop_oneof![
Just(r#"{"code":0,"message":"success","data":{"market":"US"}}"#.to_string()),
Just(r#"{"code":0,"message":"ok","data":[1,2,3]}"#.to_string()),
Just(r#"{"code":0,"message":"","data":null}"#.to_string()),
Just(r#"{"code":1010,"message":"error","data":null}"#.to_string()),
Just(r#"{"code":0,"message":"success","data":{"items":[{"id":1},{"id":2}]}}"#.to_string()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(30))]
#[test]
fn generic_execute_response_passthrough(
response_json in valid_response_json(),
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(&response_json))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let result = client.execute("market_state", r#"{"market":"US"}"#).await;
prop_assert!(result.is_ok(), "请求应成功");
let body = result.unwrap();
prop_assert_eq!(&body, &response_json,
"返回的响应应与服务器原始响应完全一致");
Ok(())
})?;
}
}
fn invalid_json_string() -> impl Strategy<Value = String> {
prop_oneof![
Just("not json".to_string()),
Just("{invalid}".to_string()),
Just("".to_string()),
Just("{key: value}".to_string()),
Just("[1,2,".to_string()),
Just(r#"{"unclosed": "string"#.to_string()),
Just("undefined".to_string()),
Just("NaN".to_string()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn generic_execute_rejects_invalid_json(
invalid_json in invalid_json_string(),
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = test_config("http://localhost:1234");
let client = HttpClient::new(config);
let result = client.execute("market_state", &invalid_json).await;
prop_assert!(result.is_err(), "无效 JSON 应返回错误");
match result.unwrap_err() {
TigerError::Config(msg) => {
prop_assert!(msg.contains("JSON"), "错误消息应提及 JSON: {}", msg);
}
other => {
prop_assert!(false, "应返回 Config 错误,实际: {:?}", other);
}
}
Ok(())
})?;
}
}
#[tokio::test]
async fn test_authorization_header_with_token() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":null}"#,
))
.mount(&mock_server)
.await;
let mut config = test_config(&mock_server.uri());
config.token = Some("my_test_token_value".to_string());
let client = HttpClient::new(config);
let result = client.execute("market_state", r#"{"market":"US"}"#).await;
assert!(result.is_ok());
let received = mock_server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let auth_header: wiremock::http::HeaderName = "Authorization".into();
let auth = received[0]
.headers
.get(&auth_header)
.expect("应包含 Authorization 头");
assert_eq!(auth.as_str(), "my_test_token_value");
}
#[tokio::test]
async fn test_no_authorization_header_without_token() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":null}"#,
))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let result = client.execute("market_state", r#"{"market":"US"}"#).await;
assert!(result.is_ok());
let received = mock_server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let auth_header: wiremock::http::HeaderName = "Authorization".into();
assert!(
received[0].headers.get(&auth_header).is_none(),
"未设置 Token 时不应携带 Authorization 头"
);
}
#[tokio::test]
async fn test_query_token_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"new_token_abc123"}}"#,
))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
let token = client.query_token().await.unwrap();
assert_eq!(token, "new_token_abc123");
}
#[tokio::test]
async fn test_query_token_api_error() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":40001,"message":"unauthorized","data":null}"#,
))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
assert!(client.query_token().await.is_err());
}
#[tokio::test]
async fn test_query_token_empty_token() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":""}}"#,
))
.mount(&mock_server)
.await;
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
assert!(client.query_token().await.is_err());
}
#[tokio::test]
async fn test_refresh_token_updates_config() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"refreshed_xyz"}}"#,
))
.mount(&mock_server)
.await;
let mut config = test_config(&mock_server.uri());
config.token = Some("old_token".to_string());
let client = HttpClient::new(config);
client.refresh_token(None).await.unwrap();
assert_eq!(client.config.read().unwrap().token, Some("refreshed_xyz".to_string()));
}
#[tokio::test]
async fn test_refresh_token_persists_to_file() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"persisted_token"}}"#,
))
.mount(&mock_server)
.await;
let tmpdir = std::env::temp_dir();
let token_file = tmpdir.join("rust_http_client_token_test.properties");
let tm = crate::config::token_manager::TokenManager::with_file_path(token_file.to_str().unwrap());
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
client.refresh_token(Some(&tm)).await.unwrap();
let content = std::fs::read_to_string(&token_file).unwrap();
assert_eq!(content, "token=persisted_token\n");
std::fs::remove_file(&token_file).ok();
}
#[tokio::test]
async fn test_refresh_token_triggers_writer() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"callback_token"}}"#,
))
.mount(&mock_server)
.await;
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let captured_clone = Arc::clone(&captured);
let tmpdir = std::env::temp_dir();
let token_file = tmpdir.join("rust_http_client_writer_test.properties");
let mut tm = crate::config::token_manager::TokenManager::with_file_path(token_file.to_str().unwrap());
tm.set_token_writer(move |t| {
*captured_clone.lock().unwrap() = t;
});
let config = test_config(&mock_server.uri());
let client = HttpClient::new(config);
client.refresh_token(Some(&tm)).await.unwrap();
assert_eq!(*captured.lock().unwrap(), "callback_token");
std::fs::remove_file(&token_file).ok();
}
fn make_expired_token(seconds_ago: i64) -> String {
use base64::Engine;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let gen_ts_ms = now_ms - seconds_ago * 1000;
let expire_ts_ms = gen_ts_ms + 3_600_000;
let payload = format!("{:013},{:013}some_extra_payload_data", gen_ts_ms, expire_ts_ms);
base64::engine::general_purpose::STANDARD.encode(payload.as_bytes())
}
#[tokio::test]
async fn test_new_http_client_auto_refresh_on_creation() {
use std::sync::{Arc, Mutex};
let call_count: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
let call_count_clone = Arc::clone(&call_count);
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"auto_created_token"}}"#,
))
.mount(&mock_server)
.await;
let mut config = test_config(&mock_server.uri());
config.token = Some(make_expired_token(100)); config.token_refresh_duration = Some(Duration::from_secs(30));
config.token_check_interval = Some(Duration::from_millis(100));
config.token_writer = Some(std::sync::Arc::new(move |_t: String| {
*call_count_clone.lock().unwrap() += 1;
}));
let client = HttpClient::new(config);
tokio::time::sleep(Duration::from_millis(400)).await;
client.close();
let token = client.config.read().unwrap().token.clone();
assert_eq!(token, Some("auto_created_token".to_string()));
}
#[tokio::test]
async fn test_new_http_client_no_auto_refresh_when_zero() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"should_not_appear"}}"#,
))
.mount(&mock_server)
.await;
let mut config = test_config(&mock_server.uri());
config.token = Some(make_expired_token(100));
let _client = HttpClient::new(config);
tokio::time::sleep(Duration::from_millis(200)).await;
let received = mock_server.received_requests().await.unwrap();
assert_eq!(received.len(), 0, "no refresh should have been triggered");
}
#[tokio::test]
async fn test_close_stops_background_task() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"code":0,"message":"success","data":{"token":"stopped_token"}}"#,
))
.mount(&mock_server)
.await;
let mut config = test_config(&mock_server.uri());
config.token = Some(make_expired_token(100));
config.token_refresh_duration = Some(Duration::from_secs(30));
config.token_check_interval = Some(Duration::from_millis(100));
let client = HttpClient::new(config);
tokio::time::sleep(Duration::from_millis(300)).await;
client.close();
let count_after_close = mock_server.received_requests().await.unwrap().len();
tokio::time::sleep(Duration::from_millis(300)).await;
let count_final = mock_server.received_requests().await.unwrap().len();
assert_eq!(count_after_close, count_final, "no more requests after close()");
}