#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use quiver_proto::v1::{self, quiver_client::QuiverClient};
use quiver_server::{Config, serve};
use tokio::net::TcpListener;
async fn wait_ready(http: &reqwest::Client, base: &str) {
for _ in 0..200 {
if let Ok(resp) = http.get(format!("{base}/healthz")).send().await
&& resp.status().is_success()
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("server did not become ready");
}
fn assert_client_error(label: &str, result: reqwest::Result<reqwest::Response>) {
let resp =
result.unwrap_or_else(|e| panic!("{label}: no HTTP response (handler panicked?): {e}"));
let status = resp.status();
assert!(
status.is_client_error(),
"{label}: expected a 4xx, got {status}"
);
}
#[tokio::test]
async fn bad_input_is_rejected_cleanly_never_500() {
let tmp = tempfile::tempdir().unwrap();
let key = "test-api-key";
let rest_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let grpc_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let rest_addr = rest_listener.local_addr().unwrap();
let grpc_addr = grpc_listener.local_addr().unwrap();
let config = Config {
data_dir: tmp.path().to_path_buf(),
rest_addr,
grpc_addr,
api_keys: vec![key.into()],
encryption_key: Some(
"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".to_owned(),
),
..Default::default()
};
let server = tokio::spawn(async move {
let _ = serve(config, rest_listener, grpc_listener).await;
});
let http = reqwest::Client::new();
let base = format!("http://{rest_addr}");
wait_ready(&http, &base).await;
let resp = http
.post(format!("{base}/v1/collections"))
.bearer_auth(key)
.json(&serde_json::json!({"name": "items", "dim": 4, "metric": "l2"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
assert_client_error(
"upsert to unknown collection",
http.post(format!("{base}/v1/collections/ghost/points"))
.bearer_auth(key)
.json(&serde_json::json!({"points": [
{"id": "x", "vector": [0.0, 0.0, 0.0, 0.0], "payload": {}}
]}))
.send()
.await,
);
assert_client_error(
"query unknown collection",
http.post(format!("{base}/v1/collections/ghost/query"))
.bearer_auth(key)
.json(&serde_json::json!({"vector": [0.0, 0.0, 0.0, 0.0], "k": 1}))
.send()
.await,
);
assert_client_error(
"get from unknown collection",
http.get(format!("{base}/v1/collections/ghost/points/x"))
.bearer_auth(key)
.send()
.await,
);
assert_client_error(
"get unknown collection",
http.get(format!("{base}/v1/collections/ghost"))
.bearer_auth(key)
.send()
.await,
);
assert_client_error(
"upsert wrong-dim vector",
http.post(format!("{base}/v1/collections/items/points"))
.bearer_auth(key)
.json(&serde_json::json!({"points": [
{"id": "x", "vector": [0.0, 0.0, 0.0], "payload": {}}
]}))
.send()
.await,
);
assert_client_error(
"query wrong-dim vector",
http.post(format!("{base}/v1/collections/items/query"))
.bearer_auth(key)
.json(&serde_json::json!({"vector": [0.0, 0.0, 0.0], "k": 1}))
.send()
.await,
);
assert_client_error(
"k over max_k",
http.post(format!("{base}/v1/collections/items/query"))
.bearer_auth(key)
.json(&serde_json::json!({"vector": [0.0, 0.0, 0.0, 0.0], "k": 100_000}))
.send()
.await,
);
assert_client_error(
"ef_search over max_ef_search",
http.post(format!("{base}/v1/collections/items/query"))
.bearer_auth(key)
.json(&serde_json::json!({
"vector": [0.0, 0.0, 0.0, 0.0], "k": 1, "ef_search": 1_000_000
}))
.send()
.await,
);
let huge = "x".repeat(70_000);
assert_client_error(
"payload over max_payload_bytes",
http.post(format!("{base}/v1/collections/items/points"))
.bearer_auth(key)
.json(&serde_json::json!({"points": [
{"id": "x", "vector": [0.0, 0.0, 0.0, 0.0], "payload": {"blob": huge}}
]}))
.send()
.await,
);
assert_client_error(
"unknown metric on create",
http.post(format!("{base}/v1/collections"))
.bearer_auth(key)
.json(&serde_json::json!({"name": "bad_metric", "dim": 4, "metric": "banana"}))
.send()
.await,
);
assert_client_error(
"missing required dim on create",
http.post(format!("{base}/v1/collections"))
.bearer_auth(key)
.json(&serde_json::json!({"name": "no_dim", "metric": "l2"}))
.send()
.await,
);
assert_client_error(
"non-JSON-object body on create",
http.post(format!("{base}/v1/collections"))
.bearer_auth(key)
.header("content-type", "application/json")
.body("not json at all")
.send()
.await,
);
let mut client = QuiverClient::connect(format!("http://{grpc_addr}"))
.await
.unwrap();
let mut request = tonic::Request::new(v1::SearchRequest {
collection: "items".to_owned(),
vector: vec![0.0, 0.0, 0.0], k: 1,
filter: Vec::new(),
ef_search: 64,
with_payload: false,
with_vector: false,
});
request
.metadata_mut()
.insert("authorization", format!("Bearer {key}").parse().unwrap());
let status = client.search(request).await.unwrap_err();
assert_eq!(
status.code(),
tonic::Code::InvalidArgument,
"grpc wrong-dim search must be InvalidArgument, got {:?}",
status.code()
);
let health = http.get(format!("{base}/healthz")).send().await.unwrap();
assert!(
health.status().is_success(),
"server alive after bad inputs"
);
server.abort();
}