use invoance::models::IngestEventParams;
use invoance::{ClientConfig, InvoanceClient};
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client(base_url: &str) -> InvoanceClient {
InvoanceClient::from_config(ClientConfig {
api_key: Some("inv_test_key".into()),
base_url: Some(base_url.into()),
..Default::default()
})
.unwrap()
}
#[tokio::test]
async fn events_ingest_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/events"))
.and(header("authorization", "Bearer inv_test_key"))
.and(header("idempotency-key", "idem-123"))
.and(header("content-type", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"event_id": "evt_abc",
"ingested_at": "2026-07-06T00:00:00Z"
})))
.mount(&server)
.await;
let c = client(&server.uri());
let resp = c
.events()
.ingest(IngestEventParams {
event_type: "user.login".into(),
payload: json!({ "user_id": "u_1" }).as_object().unwrap().clone(),
idempotency_key: Some("idem-123".into()),
..Default::default()
})
.await
.expect("ingest should succeed");
assert_eq!(resp.event_id, "evt_abc");
assert_eq!(resp.ingested_at, "2026-07-06T00:00:00Z");
}
#[tokio::test]
async fn events_list_sends_query_params_and_skips_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/events"))
.and(query_param("limit", "5"))
.and(query_param("event_type", "user.login"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"events": [],
"page": 1,
"limit": 5,
"total": 0,
"has_more": false
})))
.mount(&server)
.await;
let c = client(&server.uri());
let listed = c
.events()
.list(invoance::models::ListEventsParams {
limit: Some(5),
event_type: Some("user.login".into()),
..Default::default()
})
.await
.expect("list should succeed");
assert_eq!(listed.limit, 5);
assert_eq!(listed.total, 0);
}
#[tokio::test]
async fn error_status_maps_to_authentication() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/events"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "invalid_api_key",
"message": "API key is invalid"
})))
.mount(&server)
.await;
let c = client(&server.uri());
let err = c
.events()
.list(Default::default())
.await
.expect_err("should error");
assert!(err.is_authentication());
assert_eq!(err.status_code(), Some(401));
assert_eq!(err.error_code(), Some("invalid_api_key"));
assert_eq!(err.to_string(), "API key is invalid");
}
#[tokio::test]
async fn quota_error_parses_retry_after() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/events"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "42")
.set_body_json(json!({ "error": "rate_limited" })),
)
.mount(&server)
.await;
let c = client(&server.uri());
let err = c
.events()
.ingest(IngestEventParams {
event_type: "x".into(),
payload: serde_json::Map::new(),
..Default::default()
})
.await
.expect_err("should error");
assert!(err.is_quota_exceeded());
assert_eq!(err.retry_after_seconds(), Some(42.0));
assert!(err.to_string().contains("rate limited, retry after 42s"));
}
#[tokio::test]
async fn server_error_without_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/events/evt_1"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let c = client(&server.uri());
let err = c.events().get("evt_1").await.expect_err("should error");
assert!(err.is_server());
assert_eq!(err.status_code(), Some(503));
assert!(err.to_string().contains("(no response body)"));
}
fn me_body() -> serde_json::Value {
json!({
"valid": true,
"organization": {
"id": "org_1",
"name": "Acme",
"issuer_name": "Acme Corp",
"primary_domain": "acme.example",
"domain_verified": true,
"plan_tier": "growth"
},
"tenant": { "id": "ten_1", "name": "Acme" },
"api_key": {
"id": "key_1",
"name": "ci key",
"key_prefix": "inv_test",
"key_last4": "_key",
"scopes": ["audit:read", "audit:write"],
"created_at": "2026-07-01T00:00:00Z",
"last_used_at": null
},
"limits": { "rate_limit_per_sec": 50 }
})
}
#[tokio::test]
async fn validate_reports_ok() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.and(header("authorization", "Bearer inv_test_key"))
.respond_with(ResponseTemplate::new(200).set_body_json(me_body()))
.mount(&server)
.await;
let c = client(&server.uri());
let res = c.validate().await;
assert!(res.valid);
assert_eq!(res.reason, None);
}
#[tokio::test]
async fn validate_succeeds_for_scope_limited_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(me_body()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/events"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({ "error": "missing_scope" })))
.mount(&server)
.await;
let c = client(&server.uri());
let res = c.validate().await;
assert!(res.valid);
assert_eq!(res.reason, None);
}
#[tokio::test]
async fn validate_invalid_key_reports_invalid() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "invalid_api_key",
"message": "API key is invalid"
})))
.mount(&server)
.await;
let c = client(&server.uri());
let res = c.validate().await;
assert!(!res.valid);
assert_eq!(
res.reason.as_deref(),
Some("Authentication failed — check INVOANCE_API_KEY")
);
}
#[tokio::test]
async fn validate_forbidden_still_valid() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({ "error": "forbidden" })))
.mount(&server)
.await;
let c = client(&server.uri());
let res = c.validate().await;
assert!(res.valid);
assert_eq!(
res.reason.as_deref(),
Some("API key authenticated but the request was blocked (IP access rules)")
);
}
#[tokio::test]
async fn me_returns_raw_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.and(header("authorization", "Bearer inv_test_key"))
.respond_with(ResponseTemplate::new(200).set_body_json(me_body()))
.mount(&server)
.await;
let c = client(&server.uri());
let me = c.me().await.expect("me should succeed");
assert_eq!(me["valid"], json!(true));
assert_eq!(me["organization"]["id"], json!("org_1"));
assert_eq!(me["tenant"]["id"], json!("ten_1"));
assert_eq!(me["api_key"]["key_prefix"], json!("inv_test"));
assert_eq!(
me["api_key"]["scopes"],
json!(["audit:read", "audit:write"])
);
assert_eq!(me["limits"]["rate_limit_per_sec"], json!(50));
}
#[tokio::test]
async fn me_invalid_key_is_authentication_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "invalid_api_key",
"message": "API key is invalid"
})))
.mount(&server)
.await;
let c = client(&server.uri());
let err = c.me().await.expect_err("should error");
assert!(err.is_authentication());
assert_eq!(err.error_code(), Some("invalid_api_key"));
}
#[tokio::test]
async fn client_side_validation_rejects_bad_hash() {
let c = client("http://127.0.0.1:1");
let err = c
.documents()
.verify(
"evt_1",
invoance::models::VerifyDocumentParams {
document_hash: "not-hex".into(),
},
)
.await
.expect_err("should reject");
assert!(err.is_validation());
}