use cloacina_client::{types, Client, ClientBuilder, ClientError};
const RANDOM_UUID: &str = "00000000-0000-4000-8000-000000000000";
fn live_client() -> Option<(Client, String)> {
let server = std::env::var("CLOACINA_SERVER_URL").ok()?;
let key = std::env::var("CLOACINA_API_KEY").ok()?;
let tenant = format!(
"sdk_rs_contract_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);
let client = ClientBuilder::new(server)
.api_key(key)
.tenant(&tenant)
.build()
.expect("client builds");
Some((client, tenant))
}
macro_rules! require_live {
() => {
match live_client() {
Some(v) => v,
None => {
eprintln!("skipping: CLOACINA_SERVER_URL / CLOACINA_API_KEY not set");
return;
}
}
};
}
#[tokio::test]
async fn full_rest_surface_contract() {
let (client, tenant) = require_live!();
let health = client.health().await.expect("health");
assert_eq!(health.get("status").and_then(|s| s.as_str()), Some("ok"));
let (ready_status, _) = client.ready().await.expect("ready");
assert!(ready_status == 200 || ready_status == 503);
client
.create_tenant(&types::CreateTenantRequest {
name: tenant.clone(),
description: Some("rust sdk contract".into()),
password: None,
})
.await
.expect("create_tenant");
let tenants = client.list_tenants().await.expect("list_tenants");
assert!(tenants.items.iter().any(|t| t.name == tenant));
let created = client
.create_key(&format!("rs-contract-{tenant}"), types::KeyRole::Read)
.await
.expect("create_key");
assert!(!created.key.is_empty(), "plaintext returned exactly once");
assert_eq!(created.permissions, "read");
let keys = client.list_keys().await.expect("list_keys");
assert!(keys.total > 0);
let mine = keys
.items
.iter()
.find(|k| k.id == created.id)
.expect("listed");
assert_eq!(mine.permissions, "read");
let revoked = client.revoke_key(&created.id).await.expect("revoke_key");
assert_eq!(revoked.status, "revoked");
let tenant_key = client
.create_tenant_key(
&format!("rs-contract-t-{tenant}"),
types::KeyRole::Write,
None,
)
.await
.expect("create_tenant_key");
assert_eq!(tenant_key.tenant_id.as_deref(), Some(tenant.as_str()));
client
.revoke_key(&tenant_key.id)
.await
.expect("revoke tenant key");
let ticket = client.create_ws_ticket().await.expect("ws ticket");
assert!(!ticket.ticket.is_empty());
assert!(ticket.expires_in_seconds > 0);
let upload_err = client
.upload_workflow(b"not a real package".to_vec(), None)
.await
.expect_err("garbage package must be rejected");
assert!(matches!(upload_err, ClientError::InvalidRequest(_)));
let workflows = client.list_workflows(None).await.expect("list_workflows");
assert_eq!(workflows.tenant_id, tenant);
assert_eq!(workflows.total, workflows.items.len());
let missing = client.get_workflow("does-not-exist", None).await;
assert!(matches!(missing, Err(ClientError::NotFound(_))));
let deleted = client
.delete_workflow("does-not-exist", "0.0.0", None)
.await
.expect("idempotent delete");
assert_eq!(deleted.status, "deleted");
let triggers = client
.list_triggers(Some(10), Some(0), None)
.await
.expect("list_triggers");
assert_eq!(triggers.tenant_id, tenant);
let bad_page = client.list_triggers(Some(100_000), None, None).await;
assert!(matches!(bad_page, Err(ClientError::InvalidRequest(_))));
let no_trigger = client.get_trigger("does-not-exist", None).await;
assert!(matches!(no_trigger, Err(ClientError::NotFound(_))));
let exec_err = client
.execute_workflow("does-not-exist", serde_json::json!({"k": "v"}))
.await
.expect_err("unknown workflow rejected");
assert!(matches!(exec_err, ClientError::InvalidRequest(_)));
let executions = client
.list_executions(
&types::ListExecutionsQuery {
status: Some("Completed".into()),
limit: Some(5),
..Default::default()
},
None,
)
.await
.expect("list_executions");
assert_eq!(executions.tenant_id, tenant);
let bad_id = client.get_execution("not-a-uuid", None).await;
assert!(matches!(bad_id, Err(ClientError::InvalidRequest(_))));
let missing_exec = client.get_execution(RANDOM_UUID, None).await;
assert!(matches!(missing_exec, Err(ClientError::NotFound(_))));
let events = client
.get_execution_events(RANDOM_UUID, None)
.await
.expect("events envelope");
assert_eq!(events.execution_id, RANDOM_UUID);
assert!(events.events.is_empty());
let accs = client.list_accumulators().await.expect("accumulators");
assert_eq!(accs.total, accs.items.len());
let graphs = client.list_graphs().await.expect("graphs");
assert_eq!(graphs.total, graphs.items.len());
let no_graph = client.get_graph("does-not-exist").await;
assert!(matches!(no_graph, Err(ClientError::NotFound(_))));
let removed = client.remove_tenant(&tenant).await.expect("remove_tenant");
assert_eq!(removed.status, "removed");
}
#[tokio::test]
async fn ws_subscription_lifecycle() {
use futures_util::StreamExt;
let (client, _tenant) = require_live!();
let options = cloacina_client::SubscribeOptions {
reconnect: false,
..Default::default()
};
let stream = client.subscribe_delivery(&format!("exec_events:{RANDOM_UUID}"), options);
let mut stream = std::pin::pin!(stream);
let outcome = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()).await;
match outcome {
Err(_elapsed) => {}
Ok(Some(Ok(_push))) => {}
Ok(Some(Err(e))) => panic!("WS lifecycle failed: {e}"),
Ok(None) => panic!("WS stream ended unexpectedly"),
}
}
#[tokio::test]
async fn overhead_under_5ms_vs_raw_reqwest() {
let (client, _tenant) = require_live!();
let server = std::env::var("CLOACINA_SERVER_URL").unwrap();
let raw = reqwest::Client::new();
let median = |mut samples: Vec<u128>| {
samples.sort_unstable();
samples[samples.len() / 2]
};
let _ = client.health().await.unwrap();
let _ = raw.get(format!("{server}/health")).send().await.unwrap();
let mut raw_samples = Vec::new();
for _ in 0..20 {
let t = std::time::Instant::now();
let r = raw.get(format!("{server}/health")).send().await.unwrap();
let _ = r.bytes().await.unwrap();
raw_samples.push(t.elapsed().as_micros());
}
let mut client_samples = Vec::new();
for _ in 0..20 {
let t = std::time::Instant::now();
let _ = client.health().await.unwrap();
client_samples.push(t.elapsed().as_micros());
}
let raw_median = median(raw_samples);
let client_median = median(client_samples);
let overhead_us = client_median.saturating_sub(raw_median);
eprintln!("median raw={raw_median}us client={client_median}us overhead={overhead_us}us");
assert!(
overhead_us < 5_000,
"client overhead {overhead_us}us exceeds 5ms (NFR-002)"
);
}