use std::time::Duration;
use reasoninglayer::types::homoiconic::FeatureInputValueDto;
use reasoninglayer::{
psi, var, AddFactRequest, AddRuleRequest, BackwardChainRequest, ClientConfig,
CreateSortRequest, CreateTermRequest, Error, FindBySortRequest, ReasoningLayerClient,
SDK_LANGUAGE, SDK_VERSION,
};
use serde_json::{json, Value as Json};
use wiremock::matchers::{body_json, header, header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TENANT: &str = "00000000-0000-0000-0000-000000000001";
fn client_at(server_url: &str) -> ReasoningLayerClient {
let config = ClientConfig::new(server_url, TENANT).with_max_retries(0);
ReasoningLayerClient::new(config).expect("client builds")
}
#[tokio::test]
async fn create_sort_sends_tenant_header_and_sdk_metadata() {
let server = MockServer::start().await;
let response_body = json!({
"sort": {
"id": "sort-1",
"name": "person",
"tenant_id": TENANT,
"parents": [],
"feature_declarations": [],
"bound_constraints": [],
"needs_review": false,
}
});
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.and(header("x-tenant-id", TENANT))
.and(header("x-sdk-version", SDK_VERSION))
.and(header("x-sdk-language", SDK_LANGUAGE))
.and(header("content-type", "application/json"))
.and(body_json(json!({"name": "person"})))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let client = client_at(&server.uri());
let sort = client
.sorts()
.create_sort(CreateSortRequest::with_name("person"), None)
.await
.expect("create_sort succeeds");
assert_eq!(sort.id, "sort-1");
assert_eq!(sort.name, "person");
}
#[tokio::test]
async fn bearer_token_adds_authorization_header() {
let server = MockServer::start().await;
let config = ClientConfig::new(server.uri(), TENANT)
.with_bearer_token("test-jwt")
.with_max_retries(0);
let client = ReasoningLayerClient::new(config).unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.and(header("authorization", "Bearer test-jwt"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sort": {
"id": "sort-2", "name": "x", "tenant_id": TENANT, "parents": [],
"feature_declarations": [], "bound_constraints": [], "needs_review": false
}
})))
.expect(1)
.mount(&server)
.await;
client
.sorts()
.create_sort(CreateSortRequest::with_name("x"), None)
.await
.unwrap();
}
#[tokio::test]
async fn user_id_override_per_call() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.and(header("x-user-id", "admin-user"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sort": {
"id": "s", "name": "y", "tenant_id": TENANT, "parents": [],
"feature_declarations": [], "bound_constraints": [], "needs_review": false
}
})))
.expect(1)
.mount(&server)
.await;
let opts = reasoninglayer::RequestOptions::new().with_user_id("admin-user");
client
.sorts()
.create_sort(CreateSortRequest::with_name("y"), Some(&opts))
.await
.unwrap();
}
#[tokio::test]
async fn rate_limit_retries_honor_retry_after() {
let server = MockServer::start().await;
let config = ClientConfig::new(server.uri(), TENANT).with_max_retries(2);
let client = ReasoningLayerClient::new(config).unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "0")
.set_body_json(json!({"error": "rate_limited", "message": "slow down"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sort": {
"id": "s", "name": "z", "tenant_id": TENANT, "parents": [],
"feature_declarations": [], "bound_constraints": [], "needs_review": false
}
})))
.expect(1)
.mount(&server)
.await;
let sort = client
.sorts()
.create_sort(CreateSortRequest::with_name("z"), None)
.await
.expect("retries through 429");
assert_eq!(sort.id, "s");
}
#[tokio::test]
async fn not_found_maps_to_api_error() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("GET"))
.and(path("/api/v1/sorts/missing"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"error": "not_found",
"message": "sort missing not found"
})))
.mount(&server)
.await;
let err = client
.sorts()
.get_sort("missing", None)
.await
.expect_err("expected not found");
let api = err.as_api_error().expect("api error");
assert_eq!(api.status.as_u16(), 404);
assert_eq!(api.error_code.as_deref(), Some("not_found"));
assert!(api.message.contains("missing"));
}
#[tokio::test]
async fn timeout_propagates_as_timeout_error() {
let server = MockServer::start().await;
let config = ClientConfig::new(server.uri(), TENANT)
.with_timeout(Duration::from_millis(50))
.with_max_retries(0);
let client = ReasoningLayerClient::new(config).unwrap();
Mock::given(method("GET"))
.and(path("/api/v1/sorts/slow"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(400))
.set_body_json(json!({
"sort": {
"id": "s", "name": "slow", "tenant_id": TENANT, "parents": [],
"feature_declarations": [], "bound_constraints": [], "needs_review": false
}
})),
)
.mount(&server)
.await;
let err = client
.sorts()
.get_sort("slow", None)
.await
.expect_err("should time out");
assert!(matches!(err, Error::Timeout { .. }), "got {err:?}");
}
#[tokio::test]
async fn term_create_sends_tagged_features_on_wire() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
let mut features = std::collections::BTreeMap::new();
features.insert("name".to_string(), reasoninglayer::Value::string("Alice"));
features.insert("age".to_string(), reasoninglayer::Value::integer(30));
Mock::given(method("POST"))
.and(path("/api/v1/terms"))
.and(body_json(json!({
"sort_id": "sort-x",
"owner_id": "owner-x",
"features": {
"name": {"type": "String", "value": "Alice"},
"age": {"type": "Integer", "value": 30}
}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"term": {
"id": "term-1",
"sort_id": "sort-x",
"tenant_id": TENANT,
"owner_id": "owner-x",
"features": {
"name": {"type": "String", "value": "Alice"},
"age": {"type": "Integer", "value": 30}
}
}
})))
.expect(1)
.mount(&server)
.await;
let resp = client
.terms()
.create_term(
CreateTermRequest {
sort_id: "sort-x".into(),
owner_id: "owner-x".into(),
features,
},
None,
)
.await
.unwrap();
assert_eq!(resp.term.id, "term-1");
}
#[tokio::test]
async fn add_rule_sends_untagged_inference_format() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
let body_matcher = json!({
"term": {
"sort_name": "well_paid",
"features": {"person": {"name": "?X"}}
},
"antecedents": [{
"sort_name": "employee",
"features": {"name": {"name": "?X"}}
}]
});
Mock::given(method("POST"))
.and(path("/api/v1/inference/rules"))
.and(body_json(body_matcher))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"term": {
"term_id": "rule-1",
"sort_id": "sort-rule",
"sort_name": "well_paid",
"features": {},
"display": "well_paid(?X)"
}
})))
.expect(1)
.mount(&server)
.await;
let request = AddRuleRequest {
term: psi("well_paid", [("person", var("?X"))]),
antecedents: vec![psi("employee", [("name", var("?X"))])],
certainty: None,
};
let _ = client.inference().add_rule(request, None).await.unwrap();
}
#[tokio::test]
async fn backward_chain_parses_solutions() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/inference/backward-chain"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"solutions": [{
"substitution": {"bindings": [{
"variable_term_id": "v1",
"variable_name": "?X",
"bound_to_term_id": "t1",
"bound_to_display": "alice"
}]},
"certainty": 1.0
}],
"query_time_ms": 7
})))
.expect(1)
.mount(&server)
.await;
let req = BackwardChainRequest {
goal: Some(psi("well_paid", [("person", var("?X"))])),
..Default::default()
};
let resp = client.inference().backward_chain(req, None).await.unwrap();
assert_eq!(resp.solutions.len(), 1);
assert_eq!(resp.query_time_ms, 7);
assert_eq!(
resp.solutions[0].substitution.bindings[0].bound_to_display,
"alice"
);
}
#[tokio::test]
async fn find_by_sort_unwraps_term_list() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/query/by-sort"))
.and(body_json(json!({"sort_name": "person"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"terms": [{
"id": "t1",
"sort_id": "s1",
"tenant_id": TENANT,
"owner_id": "o1",
"features": {"name": {"type": "String", "value": "Alice"}}
}],
"count": 1
})))
.expect(1)
.mount(&server)
.await;
let terms = client
.query()
.find_by_sort(
FindBySortRequest {
sort_name: Some("person".into()),
..Default::default()
},
None,
)
.await
.unwrap();
assert_eq!(terms.len(), 1);
assert_eq!(terms[0].id, "t1");
}
#[tokio::test]
async fn add_fact_with_features_list_round_trips() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/inference/facts"))
.and(body_json(json!({
"term": {
"sort_name": "inventory",
"features": {
"tags": ["a", "b"],
"count": 3,
}
}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"term": {
"term_id": "fact-1",
"sort_id": "s",
"sort_name": "inventory",
"features": {},
"display": "inventory"
}
})))
.expect(1)
.mount(&server)
.await;
let term = psi(
"inventory",
[
(
"tags".to_string(),
FeatureInputValueDto::List(vec![
FeatureInputValueDto::String("a".into()),
FeatureInputValueDto::String("b".into()),
]),
),
("count".to_string(), FeatureInputValueDto::Integer(3)),
],
);
let _: Json = serde_json::to_value(&term).unwrap();
client
.inference()
.add_fact(AddFactRequest { term }, None)
.await
.unwrap();
}
#[tokio::test]
async fn missing_tenant_returns_validation_error() {
let config = ClientConfig::new("http://localhost:1", "");
let err = ReasoningLayerClient::new(config).expect_err("empty tenant is invalid");
match err {
Error::Validation { field, .. } => assert_eq!(field.as_deref(), Some("tenant_id")),
other => panic!("expected validation error, got {other:?}"),
}
}
#[tokio::test]
async fn phase3_fuzzy_unify_typed() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/fuzzy/unify"))
.and(header("x-tenant-id", TENANT))
.and(body_json(json!({
"term1_id": "t1",
"term2_id": "t2",
"threshold": 0.8
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"degree": 0.95,
"confidence_percent": 95.0,
"term": {
"id": "t3",
"sort_id": "s1",
"tenant_id": TENANT,
"owner_id": "o1",
"features": {}
}
})))
.expect(1)
.mount(&server)
.await;
use reasoninglayer::types::fuzzy::FuzzyUnifyRequest;
let req = FuzzyUnifyRequest {
term1_id: "t1".into(),
term2_id: "t2".into(),
threshold: Some(0.8),
similarity_mode: None,
fail_on_unknown: None,
};
let resp = client.fuzzy().fuzzy_unify(req, None).await.unwrap();
assert_eq!(resp.degree, 0.95);
assert_eq!(resp.term.id, "t3");
}
#[tokio::test]
async fn phase2_health_hits_absolute_root_no_v1_prefix() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"build_info": { "version": "1.0.0" },
"components": [],
"status": "healthy"
})))
.expect(1)
.mount(&server)
.await;
let health = client.health().check(None).await.unwrap();
assert_eq!(health.status, "healthy");
assert_eq!(health.build_info.version, "1.0.0");
}
#[tokio::test]
async fn phase2_cognitive_agent_events_url_formatted_correctly() {
let config = ClientConfig::new("http://localhost:8083", TENANT);
let client = ReasoningLayerClient::new(config).unwrap();
let url = client.cognitive().agent_events_url("agent-123").unwrap();
assert!(url.starts_with("ws://localhost:8083/api/v1/cognitive/agents/ws"));
assert!(url.contains("agent_id=agent-123"));
assert!(url.contains(&format!("tenant_id={TENANT}")));
}
#[tokio::test]
async fn phase2_ingestion_delegates_to_correct_path() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/ingest/markdown"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"session_id": "s1"})))
.expect(1)
.mount(&server)
.await;
let body = json!({"markdown": "# hello"});
let resp = client
.ingestion()
.ingest_markdown(&body, None)
.await
.unwrap();
assert_eq!(resp["session_id"], "s1");
}
#[tokio::test]
async fn namespace_header_forwarded_when_configured() {
let server = MockServer::start().await;
let config = ClientConfig::new(server.uri(), TENANT).with_namespace_id("ns-1");
let client = ReasoningLayerClient::new(config).unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/sorts"))
.and(header_exists("x-namespace-id"))
.and(header("x-namespace-id", "ns-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sort": {
"id": "s", "name": "x", "tenant_id": TENANT, "parents": [],
"feature_declarations": [], "bound_constraints": [], "needs_review": false
}
})))
.expect(1)
.mount(&server)
.await;
client
.sorts()
.create_sort(CreateSortRequest::with_name("x"), None)
.await
.unwrap();
}