mod resilience;
use super::*;
use axum::extract::{Request, State};
use axum::response::{IntoResponse, Response};
use std::sync::{Arc, Mutex};
#[test]
fn client_constructs() {
let _client = IotdbClient::new("http://localhost:18080", "root", "root");
}
#[derive(Debug)]
struct CapturedRequest {
path: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl CapturedRequest {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
async fn spawn_mock_insert(
captured: Arc<Mutex<Vec<CapturedRequest>>>,
status: u16,
body: &'static str,
) -> String {
let config = Arc::new(MockConfig {
captured,
status,
body,
});
let app = axum::Router::new()
.route("/rest/v2/insertTablet", axum::routing::post(handle_insert))
.with_state(config);
async fn handle_insert(State(config): State<Arc<MockConfig>>, req: Request) -> Response {
let path = req.uri().path().to_string();
let (parts, req_body) = req.into_parts();
let headers = parts
.headers
.iter()
.map(|(k, v)| {
(
k.as_str().to_string(),
v.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let req_body = axum::body::to_bytes(req_body, usize::MAX)
.await
.unwrap_or_default();
config
.captured
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(CapturedRequest {
path,
headers,
body: req_body.to_vec(),
});
if config.body.is_empty() {
axum::Json(serde_json::json!({"code": 200})).into_response()
} else {
(
axum::http::StatusCode::from_u16(config.status).unwrap(),
axum::response::Response::new(axum::body::Body::from(config.body)),
)
.into_response()
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
struct MockConfig {
captured: Arc<Mutex<Vec<CapturedRequest>>>,
status: u16,
body: &'static str,
}
fn assert_field(
body: &serde_json::Value,
field: &str,
expected_type: &str,
expected_value: serde_json::Value,
) {
let measurements = body["measurements"].as_array().unwrap();
let idx = measurements
.iter()
.position(|m| m.as_str() == Some(field))
.unwrap_or_else(|| panic!("field {field} missing from {measurements:?}"));
assert_eq!(
body["data_types"][idx].as_str(),
Some(expected_type),
"type for {field}"
);
assert_eq!(body["values"][0][idx], expected_value, "value for {field}");
}
#[tokio::test]
async fn insert_tablet_sends_full_protocol_body() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(captured.clone(), 200, "").await;
let client = IotdbClient::new(base_url, "root", "root");
let point = DataPoint::new("cpu")
.with_field("usage", FieldValue::Float(0.85))
.with_field("count", FieldValue::Int(3))
.with_field("active", FieldValue::Bool(true))
.with_field("name", FieldValue::String("web".into()))
.with_timestamp(1_700_000_000_000);
client.write(&[point]).await.unwrap();
let reqs = captured.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].path, "/rest/v2/insertTablet");
assert_eq!(reqs[0].header("content-type"), Some("application/json"));
assert_eq!(reqs[0].header("authorization"), Some("Basic cm9vdDpyb290"));
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
assert_eq!(body["device"], "cpu");
assert_eq!(body["is_aligned"], false);
assert_eq!(
body["timestamps"],
serde_json::json!([1_700_000_000_000_i64])
);
assert_eq!(body["values"].as_array().unwrap().len(), 1);
assert_field(&body, "usage", "DOUBLE", serde_json::json!(0.85));
assert_field(&body, "count", "INT64", serde_json::json!(3));
assert_field(&body, "active", "BOOLEAN", serde_json::json!(true));
assert_field(&body, "name", "TEXT", serde_json::json!("web"));
}
#[tokio::test]
async fn insert_tablet_defaults_timestamp_to_zero() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(captured.clone(), 200, "").await;
let client = IotdbClient::new(base_url, "root", "root");
client
.write(&[DataPoint::new("mem").with_field("used", FieldValue::Int(7))])
.await
.unwrap();
let reqs = captured.lock().unwrap_or_else(|e| e.into_inner());
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
assert_eq!(body["timestamps"], serde_json::json!([0]));
}
#[tokio::test]
async fn insert_tablet_sends_one_request_per_point() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(captured.clone(), 200, "").await;
let client = IotdbClient::new(base_url, "root", "root");
let p1 = DataPoint::new("cpu").with_field("usage", FieldValue::Float(0.5));
let p2 = DataPoint::new("mem").with_field("used", FieldValue::Int(1));
client.write(&[p1, p2]).await.unwrap();
let reqs = captured.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(reqs.len(), 2, "每点独立一次 insertTablet 请求");
let devices: Vec<String> = reqs
.iter()
.map(|r| {
let body: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
body["device"].as_str().unwrap().to_string()
})
.collect();
assert!(devices.iter().any(|d| d == "cpu"));
assert!(devices.iter().any(|d| d == "mem"));
}
#[tokio::test]
async fn write_returns_err_on_http_error() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(captured.clone(), 500, "boom").await;
let client = IotdbClient::new(base_url, "root", "root");
let err = client
.write(&[DataPoint::new("cpu").with_field("x", FieldValue::Int(1))])
.await
.unwrap_err();
assert!(err.to_string().contains("boom"), "got: {err}");
}
#[tokio::test]
async fn write_returns_err_on_2xx_with_failure_code() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(
captured.clone(),
200,
r#"{"code":501,"message":"table not exists"}"#,
)
.await;
let client = IotdbClient::new(base_url, "root", "root");
let err = client
.write(&[DataPoint::new("cpu").with_field("x", FieldValue::Int(1))])
.await
.unwrap_err();
assert!(err.to_string().contains("table not exists"), "got: {err}");
}
#[tokio::test]
async fn write_converts_non_finite_float_to_zero() {
let captured = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_mock_insert(captured.clone(), 200, "").await;
let client = IotdbClient::new(base_url, "root", "root");
client
.write(&[DataPoint::new("cpu").with_field("x", FieldValue::Float(f64::NAN))])
.await
.unwrap();
let reqs = captured.lock().unwrap_or_else(|e| e.into_inner());
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
assert_field(&body, "x", "DOUBLE", serde_json::json!(0));
}
async fn spawn_mock_query(status: u16, body: &'static str) -> String {
let app = axum::Router::new().route(
"/rest/v2/query",
axum::routing::post(move || async move {
(
axum::http::StatusCode::from_u16(status).unwrap(),
axum::response::Response::new(axum::body::Body::from(body)),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test]
async fn query_parses_successful_json_response() {
let body = r#"{"code":200,"expression":[{"alias":"x"}],"timestamp":[],"values":[]}"#;
let base_url = spawn_mock_query(200, body).await;
let client = IotdbClient::new(base_url, "root", "root");
let v = client.query("select x from root.s").await.unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["expression"][0]["alias"], "x");
}
#[tokio::test]
async fn query_returns_err_on_http_error() {
let base_url = spawn_mock_query(500, "query failed").await;
let client = IotdbClient::new(base_url, "root", "root");
let err = client.query("select 1").await.unwrap_err();
assert!(err.to_string().contains("query failed"), "got: {err}");
}
#[tokio::test]
async fn query_non_json_body_returns_parse_error() {
let base_url = spawn_mock_query(200, "not json").await;
let client = IotdbClient::new(base_url, "root", "root");
let err = client.query("select 1").await.unwrap_err();
assert!(err.to_string().contains("iotdb parse"), "got: {err}");
}