use areev::{Areev, AreevError, HttpClient};
use serde_json::json;
use wiremock::matchers::{any, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client_with_retries(server: &MockServer, max_retries: usize) -> Areev {
Areev::with_max_retries(
Some("test-key"),
Some(&server.uri()),
Some("m"),
max_retries,
)
}
fn http_with_retries(server: &MockServer, max_retries: usize) -> HttpClient {
HttpClient::new(&server.uri(), "m", Some("test-key")).with_max_retries(max_retries)
}
#[test]
fn dispatch_401_is_authentication() {
let e = AreevError::from_response_status(401, None, "boom", None);
assert!(matches!(e, AreevError::Authentication { .. }));
}
#[test]
fn dispatch_403_no_code_is_authorization() {
let e = AreevError::from_response_status(403, None, "boom", None);
assert!(matches!(e, AreevError::Authorization { .. }));
}
#[test]
fn dispatch_403_hipaa_codes_is_hipaa_policy_violation() {
for code in ["HRN-E040", "HRN-E041", "HRN-E042"] {
let e = AreevError::from_response_status(403, Some(code), "phi blocked", None);
assert!(
matches!(&e, AreevError::HipaaPolicyViolation { code: c, .. } if c.starts_with("HRN-E04")),
"code {code} should dispatch to HipaaPolicyViolation, got {e:?}",
);
}
}
#[test]
fn dispatch_403_non_hipaa_code_is_authorization() {
let e = AreevError::from_response_status(403, Some("AUT-E003"), "no scope", None);
assert!(matches!(e, AreevError::Authorization { .. }));
}
#[test]
fn dispatch_404_is_not_found() {
let e = AreevError::from_response_status(404, None, "gone", None);
assert!(matches!(e, AreevError::NotFound { .. }));
}
#[test]
fn dispatch_409_is_conflict() {
let e = AreevError::from_response_status(409, None, "raced", None);
assert!(matches!(e, AreevError::Conflict { .. }));
}
#[test]
fn dispatch_429_is_rate_limit() {
let e = AreevError::from_response_status(429, None, "slow", None);
assert!(matches!(e, AreevError::RateLimit { .. }));
}
#[test]
fn dispatch_400_and_422_are_validation() {
assert!(matches!(
AreevError::from_response_status(400, Some("MEM-E101"), "bad", None),
AreevError::Validation { .. }
));
assert!(matches!(
AreevError::from_response_status(422, None, "bad", None),
AreevError::Validation { .. }
));
}
#[test]
fn dispatch_5xx_is_server() {
for status in [500u16, 503, 599] {
let e = AreevError::from_response_status(status, None, "boom", None);
assert!(matches!(e, AreevError::Server { .. }), "status {status}");
}
}
#[test]
fn dispatch_unknown_status_is_other() {
let e = AreevError::from_response_status(302, None, "redirected", None);
assert!(matches!(e, AreevError::Other { .. }));
}
#[test]
fn error_carries_metadata() {
let e = AreevError::from_response_status(
403,
Some("HRN-E041"),
"phi exposure blocked",
Some("req-abc-123".to_string()),
);
assert_eq!(e.http_status(), 403);
assert_eq!(e.code(), Some("HRN-E041"));
assert_eq!(e.request_id(), Some("req-abc-123"));
assert!(e.to_string().contains("HRN-E041"));
assert!(e.to_string().contains("req-abc-123"));
}
#[test]
fn dispatch_402_with_credit_code_is_insufficient_credits() {
let e = AreevError::from_response_status(402, Some("CRD-E003"), "no credits", None);
assert!(matches!(e, AreevError::InsufficientCredits { .. }));
assert_eq!(e.code(), Some("CRD-E003"));
}
#[test]
fn dispatch_bare_402_routes_to_insufficient_credits() {
let e = AreevError::from_response_status(402, None, "payment required", None);
assert!(matches!(e, AreevError::InsufficientCredits { .. }));
assert_eq!(e.http_status(), 402);
assert_eq!(e.code(), Some("CRD-E003"));
}
#[test]
fn error_exposes_structured_body() {
let e = AreevError::from_response_with_body(
429,
Some("RTL-E001"),
"slow down",
json!({ "retry_after_ms": 1500, "scope": "per-endpoint" }),
None,
);
assert!(matches!(e, AreevError::RateLimit { .. }));
assert_eq!(
e.body().get("retry_after_ms").and_then(|v| v.as_i64()),
Some(1500)
);
assert_eq!(
e.body().get("scope").and_then(|v| v.as_str()),
Some("per-endpoint")
);
}
#[test]
fn error_body_redacts_secret_keys() {
let e = AreevError::from_response_with_body(
400,
Some("MEM-E101"),
"bad request",
json!({
"field": "executor",
"access_token": "super-secret",
"nested": { "client_secret": "also-secret", "ok": 1 }
}),
None,
);
assert_eq!(
e.body().get("access_token").and_then(|v| v.as_str()),
Some("[redacted]")
);
assert_eq!(
e.body()
.get("nested")
.and_then(|n| n.get("client_secret"))
.and_then(|v| v.as_str()),
Some("[redacted]")
);
assert_eq!(
e.body().get("field").and_then(|v| v.as_str()),
Some("executor")
);
assert_eq!(
e.body()
.get("nested")
.and_then(|n| n.get("ok"))
.and_then(|v| v.as_i64()),
Some(1)
);
}
#[test]
fn synthetic_errors_have_null_body() {
let e = AreevError::from_response_status(404, None, "gone", None);
assert!(e.body().is_null());
}
#[tokio::test]
async fn retries_5xx_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(503)
.set_body_json(json!({"error": "transient", "code": "SYS-E500"})),
)
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
let resp = http
._get("/test", None)
.await
.expect("expected eventual success");
assert_eq!(resp.get("ok"), Some(&json!(true)));
}
#[tokio::test]
async fn no_retry_on_4xx_validation_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(json!({"error": "bad schema", "code": "MEM-E101"})),
)
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
let body = json!({});
let err = http
._post("/test", Some(&body))
.await
.expect_err("400 should not retry");
assert!(matches!(err, AreevError::Validation { .. }));
assert_eq!(err.code(), Some("MEM-E101"));
assert_eq!(err.http_status(), 400);
}
#[tokio::test]
async fn retry_on_429_rate_limit() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(429)
.set_body_json(json!({"error": "slow down", "code": "RAT-E001"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
let body = json!({});
let resp = http
._post("/test", Some(&body))
.await
.expect("429 should retry then succeed");
assert_eq!(resp.get("ok"), Some(&json!(true)));
}
#[tokio::test]
async fn no_retry_on_401_auth_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(json!({"error": "invalid token", "code": "AUT-E001"})),
)
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
let err = http._get("/test", None).await.expect_err("401 fails fast");
assert!(matches!(err, AreevError::Authentication { .. }));
}
#[tokio::test]
async fn dispatches_403_hipaa_via_http_layer() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/test"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
"error": "HIPAA-bound memory rejects PHI bind",
"code": "HRN-E041",
})))
.mount(&server)
.await;
let http = http_with_retries(&server, 0);
let body = json!({});
let err = http
._post("/test", Some(&body))
.await
.expect_err("should be HipaaPolicyViolation");
assert!(matches!(err, AreevError::HipaaPolicyViolation { .. }));
assert_eq!(err.code(), Some("HRN-E041"));
}
#[tokio::test]
async fn request_id_surfaced_from_response_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(json!({"error": "boom", "code": "SYS-E001"}))
.append_header("x-request-id", "req-12345"),
)
.mount(&server)
.await;
let http = http_with_retries(&server, 0);
let err = http
._get("/test", None)
.await
.expect_err("500 with 0 retries");
assert!(matches!(err, AreevError::Server { .. }));
assert_eq!(err.request_id(), Some("req-12345"));
}
#[tokio::test]
async fn retry_exhaustion_raises_last_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/test"))
.respond_with(
ResponseTemplate::new(503)
.set_body_json(json!({"error": "still broken", "code": "SYS-E500"})),
)
.expect(3) .mount(&server)
.await;
let http = http_with_retries(&server, 2);
let err = http
._get("/test", None)
.await
.expect_err("retries exhausted");
assert!(matches!(err, AreevError::Server { .. }));
assert_eq!(err.http_status(), 503);
}
#[tokio::test]
async fn resource_namespaces_are_accessible() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.expect(1)
.mount(&server)
.await;
let areev = client_with_retries(&server, 0);
let health = areev.system().health().await.expect("health ok");
assert_eq!(health.get("status").and_then(|v| v.as_str()), Some("ok"));
}
#[tokio::test]
async fn tools_unbind_validates_inputs() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let areev = client_with_retries(&server, 0);
let err = areev
.tools()
.unbind("any-slug", None, None)
.await
.expect_err("must require id or hash");
assert!(matches!(err, AreevError::Validation { .. }));
assert_eq!(err.code(), Some("SDK-E001"));
}
#[tokio::test]
async fn tools_unbind_resolves_tool_id_via_list() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/memories/m/harnesses/slug/tools"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"harness_slug": "slug",
"tools": [
{"tool_id": "foo", "tool_hash": "abc123"},
],
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/api/memories/m/harnesses/slug/tools/abc123"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let areev = client_with_retries(&server, 0);
areev
.tools()
.unbind("slug", Some("foo"), None)
.await
.expect("unbind by tool_id should resolve and DELETE");
}
#[tokio::test]
async fn tools_unbind_missing_tool_id_raises_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/memories/m/harnesses/slug/tools"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"harness_slug": "slug",
"tools": [],
})))
.mount(&server)
.await;
let areev = client_with_retries(&server, 0);
let err = areev
.tools()
.unbind("slug", Some("gone"), None)
.await
.expect_err("no such tool");
assert!(matches!(err, AreevError::NotFound { .. }));
assert_eq!(err.code(), Some("SDK-E011"));
}
#[tokio::test]
async fn tools_unbind_by_action_hash_skips_list() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/memories/m/harnesses/slug/tools/deadbeef"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/memories/m/harnesses/slug/tools"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let areev = client_with_retries(&server, 0);
areev
.tools()
.unbind("slug", None, Some("deadbeef"))
.await
.expect("DELETE by tool_hash");
}
#[tokio::test]
async fn b3_retried_post_stamps_idempotency_key() {
use wiremock::matchers::header_exists;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/echo"))
.and(header_exists("idempotency-key"))
.respond_with(ResponseTemplate::new(503).set_body_json(json!({
"error": "transient",
"code": "SYS-E500"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/echo"))
.and(header_exists("idempotency-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
http._post("/echo", Some(&json!({})))
.await
.expect("retried POST");
}
#[tokio::test]
async fn b2_post_no_retry_fires_exactly_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth-stub"))
.respond_with(ResponseTemplate::new(503).set_body_json(json!({
"error": "transient",
"code": "SYS-E500"
})))
.expect(1) .mount(&server)
.await;
let http = http_with_retries(&server, 3);
let err = http
._post_no_retry("/oauth-stub", Some(&json!({})))
.await
.expect_err("5xx must surface");
assert!(matches!(err, AreevError::Server { .. }));
}
#[tokio::test]
async fn h1_429_honours_retry_after_header() {
use std::time::Instant;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/limited"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "1")
.set_body_json(json!({"error": "slow down", "code": "RAT-E001"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/limited"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let http = http_with_retries(&server, 3);
let start = Instant::now();
http._get("/limited", None).await.expect("retried GET");
let elapsed = start.elapsed();
assert!(
elapsed >= std::time::Duration::from_millis(900),
"should honour Retry-After: 1 (waited {:?})",
elapsed
);
}
#[test]
fn b1_builder_applies_custom_timeout() {
use std::time::Duration;
let http = HttpClient::builder()
.base_url("https://example.invalid")
.memory_id("m")
.api_key("test-key")
.timeout(Duration::from_secs(45))
.connect_timeout(Duration::from_secs(5))
.max_retries(5)
.pool_max_idle_per_host(16)
.build()
.expect("builder accepts production config");
assert_eq!(http.base_url(), "https://example.invalid");
assert_eq!(http.memory_id(), "m");
}