invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! HTTP-level tests against a mock server: successful ingest, query params,
//! and error status mapping.

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));
    // No server message -> synthesized rate-limit message.
    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() {
    // /v1/me requires no scope: a key that can ONLY touch audit endpoints
    // (listing events would 403) must still validate cleanly.
    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() {
    // No server needed; validation happens before any request.
    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());
}