use jevapi::{Answer, Client, ClientBuilder, Error, EvaluationRequest, Question, RetryPolicy};
use serde_json::{Value, json};
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use wiremock::{
Mock, MockServer, Request, ResponseTemplate,
matchers::{body_json, header, method, path},
};
fn request() -> EvaluationRequest {
EvaluationRequest::new("Service is down").question("urgent", Question::noul("Urgent?"))
}
fn response() -> Value {
json!({"model": "jev-test", "answers": {"urgent": {"type": "noul", "noul": 0.95}}, "usage": {"input_tokens": 20, "output_tokens": 5}})
}
fn builder(server: &MockServer) -> ClientBuilder {
Client::builder()
.api_key("test-key")
.base_url(format!("{}/v1", server.uri()))
.http_client(
reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.build()
.unwrap(),
)
}
fn fast_retry(max_retries: u32) -> RetryPolicy {
RetryPolicy {
max_retries,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(25),
}
}
#[tokio::test]
async fn posts_documented_path_headers_body_and_decodes_response() {
let server = MockServer::start().await;
Mock::given(method("POST")).and(path("/proxy/v1/systemone"))
.and(header("authorization", "Bearer test-key"))
.and(header("content-type", "application/json"))
.and(header("accept", "application/json"))
.and(header("user-agent", concat!("jevapi/", env!("CARGO_PKG_VERSION"))))
.and(body_json(json!({"state": "Service is down", "model": "jev-latest", "questions": {"urgent": {"type": "noul", "instructions": "Urgent?"}}})))
.respond_with(ResponseTemplate::new(200).set_body_json(response())).expect(2).mount(&server).await;
for suffix in ["/proxy/v1", "/proxy/v1/"] {
let client = builder(&server)
.base_url(format!("{}{suffix}", server.uri()))
.build()
.unwrap();
let result = client.evaluate(&request()).await.unwrap();
assert_eq!(result.model, "jev-test");
assert_eq!(result.answers["urgent"], Answer::Noul { noul: 0.95 });
assert_eq!(result.usage.input_tokens, 20);
}
}
#[tokio::test]
async fn invalid_deserialized_request_never_reaches_server() {
let server = MockServer::start().await;
let invalid: EvaluationRequest = serde_json::from_value(json!({
"state": [], "model": "jev-latest", "questions": {"rating": {"type": "score", "instructions": "Rate", "criteria": ["One"]}}
})).unwrap();
let error = builder(&server)
.build()
.unwrap()
.evaluate(&invalid)
.await
.unwrap_err();
assert!(matches!(error, Error::Validation(_)));
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn retries_429_and_529_then_succeeds() {
for status in [429, 529] {
let server = MockServer::start().await;
let count = Arc::new(AtomicUsize::new(0));
let seen = count.clone();
Mock::given(method("POST"))
.respond_with(move |_: &Request| {
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
ResponseTemplate::new(status).set_body_string("busy")
} else {
ResponseTemplate::new(200).set_body_json(response())
}
})
.expect(2)
.mount(&server)
.await;
builder(&server)
.retry_policy(fast_retry(1))
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap();
assert_eq!(count.load(Ordering::SeqCst), 2);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[0].body, requests[1].body);
}
}
#[tokio::test]
async fn retry_budget_and_exponential_delays_are_enforced() {
let server = MockServer::start().await;
let times = Arc::new(Mutex::new(Vec::new()));
let seen = times.clone();
Mock::given(method("POST"))
.respond_with(move |_: &Request| {
seen.lock().unwrap().push(tokio::time::Instant::now());
ResponseTemplate::new(529).set_body_string("overloaded")
})
.expect(5)
.mount(&server)
.await;
let error = builder(&server)
.retry_policy(fast_retry(4))
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err();
assert!(
matches!(error, Error::Api(ref error) if error.status.as_u16() == 529 && error.body == "overloaded")
);
let times = times.lock().unwrap();
for (pair, minimum_micros) in times.windows(2).zip([5_000, 10_000, 12_500, 12_500]) {
assert!(pair[1].duration_since(pair[0]) >= Duration::from_micros(minimum_micros));
}
}
#[tokio::test]
async fn nonretryable_statuses_preserve_json_or_plain_text_errors() {
for status in [401, 422, 500] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(status)
.insert_header("x-request-id", "request-123")
.set_body_json(json!({"detail": [{"field": "state", "message": "invalid"}]})),
)
.expect(1)
.mount(&server)
.await;
let Error::Api(error) = builder(&server)
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err()
else {
panic!("expected API error")
};
assert_eq!(error.status.as_u16(), status);
assert_eq!(error.headers["x-request-id"], "request-123");
assert_eq!(error.json().unwrap()["detail"][0]["field"], "state");
assert!(!error.is_retryable());
assert!(!error.to_string().contains("detail"));
}
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(502).set_body_string("<html>upstream unavailable</html>"),
)
.expect(1)
.mount(&server)
.await;
let Error::Api(error) = builder(&server)
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err()
else {
panic!("expected API error")
};
assert_eq!(error.body, "<html>upstream unavailable</html>");
assert!(error.json().is_err());
}
#[tokio::test]
async fn retry_policy_can_be_disabled() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429))
.expect(1)
.mount(&server)
.await;
let error = builder(&server)
.retry_policy(RetryPolicy::none())
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err();
assert!(matches!(error, Error::Api(_)));
}
#[tokio::test]
async fn malformed_success_is_a_decode_error_without_retry() {
for body in [
"not json",
"{}",
"{\"model\":\"test\",\"answers\":{},\"usage\":null}",
] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.expect(1)
.mount(&server)
.await;
let error = builder(&server)
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err();
assert!(matches!(error, Error::Decode(_)));
}
}
#[tokio::test]
async fn overall_deadline_bounds_slow_responses_and_retry_after() {
for hint in [None, Some("1"), Some("18446744073709551615")] {
let server = MockServer::start().await;
let template = match hint {
Some(hint) => ResponseTemplate::new(429).insert_header("retry-after", hint),
None => ResponseTemplate::new(200)
.set_body_json(response())
.set_delay(Duration::from_secs(1)),
};
Mock::given(method("POST"))
.respond_with(template)
.expect(1)
.mount(&server)
.await;
let error = builder(&server)
.timeout(Duration::from_millis(100))
.retry_policy(fast_retry(3))
.build()
.unwrap()
.evaluate(&request())
.await
.unwrap_err();
assert!(matches!(error, Error::Timeout));
}
}
#[tokio::test]
async fn cloned_clients_and_futures_support_concurrent_spawned_tasks() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Client>();
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(response()))
.expect(2)
.mount(&server)
.await;
let first = builder(&server).build().unwrap();
let second = first.clone();
let a = tokio::spawn(async move { first.evaluate(&request()).await });
let b = tokio::spawn(async move { second.evaluate(&request()).await });
a.await.unwrap().unwrap();
b.await.unwrap().unwrap();
}
#[tokio::test]
async fn default_client_does_not_follow_redirects() {
let server = MockServer::start().await;
Mock::given(path("/v1/systemone"))
.respond_with(
ResponseTemplate::new(307)
.insert_header("location", format!("{}/redirected", server.uri())),
)
.expect(1)
.mount(&server)
.await;
Mock::given(path("/redirected"))
.respond_with(ResponseTemplate::new(200).set_body_json(response()))
.expect(0)
.mount(&server)
.await;
let client = Client::builder()
.api_key("test-key")
.base_url(format!("{}/v1", server.uri()))
.build()
.unwrap();
let error = client.evaluate(&request()).await.unwrap_err();
assert!(matches!(error, Error::Api(ref error) if error.status.as_u16() == 307));
}
#[test]
fn invalid_configuration_is_rejected_and_secrets_are_redacted() {
for key in ["", " ", "secret\r\ninjected"] {
let error = Client::new(key).unwrap_err();
assert!(matches!(error, Error::Configuration(_)));
assert!(!format!("{error:?}").contains("secret"));
}
for url in [
"relative/path",
"ftp://example.com",
"https://user:secret@example.com/v1",
"https://example.com/v1?key=secret",
"https://example.com/v1#fragment",
] {
assert!(
Client::builder()
.api_key("test-key")
.base_url(url)
.build()
.is_err()
);
}
assert!(
Client::builder()
.api_key("key")
.timeout(Duration::ZERO)
.build()
.is_err()
);
assert!(
Client::builder()
.api_key("key")
.timeout(Duration::MAX)
.build()
.is_err()
);
assert!(
Client::builder()
.api_key("key")
.retry_policy(RetryPolicy {
initial_delay: Duration::ZERO,
..RetryPolicy::default()
})
.build()
.is_err()
);
assert!(
Client::builder()
.api_key("key")
.retry_policy(RetryPolicy {
initial_delay: Duration::from_secs(9),
max_delay: Duration::from_secs(8),
..RetryPolicy::default()
})
.build()
.is_err()
);
let builder = Client::builder().api_key("never-print-this-secret");
assert!(!format!("{builder:?}").contains("never-print-this-secret"));
assert!(!format!("{:?}", builder.build().unwrap()).contains("never-print-this-secret"));
}