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() {
use reasoninglayer::types::ingestion::IngestMarkdownRequest;
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!({
"success": true,
"session_id": "s1",
"stats": {},
"pending_review": []
})))
.expect(1)
.mount(&server)
.await;
let resp = client
.ingestion()
.ingest_markdown(
IngestMarkdownRequest {
content: "# hello".into(),
owner_id: "00000000-0000-0000-0000-000000000010".into(),
config: None,
},
None,
)
.await
.unwrap();
assert_eq!(resp.session_id.as_deref(), Some("s1"));
assert!(resp.success);
}
#[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();
}
#[tokio::test]
async fn ilp_learn_round_trips_integer_examples_and_u128_processing_time() {
use reasoninglayer::types::ilp::{
ArithmeticRecursionOp, LearnPatternRequest, LearnPatternResponse, TrainingExample,
};
let server = MockServer::start().await;
let client = client_at(&server.uri());
let expected_request = json!({
"name": "factorial",
"examples": [
{"input": 0, "output": 1},
{"input": 1, "output": 1},
{"input": 2, "output": 2},
]
});
let response_body = json!({
"success": true,
"pattern": {
"operation": "multiplication",
"input_feature": "n",
"output_feature": "result",
"base_case_inputs": [0, 1],
"confidence": 0.95,
"description": "factorial(n)"
},
"pattern_id": "pattern-1",
"examples_used": 3,
"processing_time_ms": 250
});
Mock::given(method("POST"))
.and(path("/api/v1/ilp/learn"))
.and(body_json(expected_request))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp: LearnPatternResponse = client
.ilp()
.learn(
LearnPatternRequest {
name: "factorial".into(),
examples: vec![
TrainingExample {
input: 0,
output: 1,
},
TrainingExample {
input: 1,
output: 1,
},
TrainingExample {
input: 2,
output: 2,
},
],
config: None,
},
None,
)
.await
.expect("learn succeeds");
assert!(resp.success);
assert_eq!(resp.processing_time_ms, 250_u128);
let pattern = resp.pattern.expect("pattern present");
assert_eq!(pattern.base_case_inputs, vec![0_i64, 1]);
assert!(matches!(
pattern.operation,
ArithmeticRecursionOp::Multiplication
));
}
#[tokio::test]
async fn cdl_forward_chain_uses_corrected_path() {
use reasoninglayer::types::cdl::{DifferentiableFcRequest, DifferentiableFcResponse};
let server = MockServer::start().await;
let client = client_at(&server.uri());
let response_body = json!({
"weighted_facts": [],
"iteration_metrics": [],
"symbolic_result": {
"total_facts": 5,
"derived_count": 2,
"iterations": 3,
"fixpoint_reached": true,
},
"elapsed_ms": 42
});
Mock::given(method("POST"))
.and(path("/api/v1/cdl/forward-chain/differentiable"))
.and(header("x-tenant-id", TENANT))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp: DifferentiableFcResponse = client
.cdl()
.forward_chain(
DifferentiableFcRequest {
max_iterations: Some(10),
..Default::default()
},
None,
)
.await
.expect("forward_chain hits the corrected backend path");
assert!(resp.symbolic_result.fixpoint_reached);
assert_eq!(resp.symbolic_result.iterations, 3);
assert_eq!(resp.elapsed_ms, 42);
}
#[tokio::test]
async fn control_create_module_round_trips_with_parents_and_success() {
use reasoninglayer::types::control::{CreateModuleRequest, CreateModuleResponse};
let server = MockServer::start().await;
let client = client_at(&server.uri());
let expected_request = json!({
"session_id": "sess-1",
"name": "math",
"parents": ["base"]
});
let response_body = json!({
"module_name": "math",
"success": true
});
Mock::given(method("POST"))
.and(path("/api/v1/modules"))
.and(header("x-tenant-id", TENANT))
.and(body_json(expected_request))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp: CreateModuleResponse = client
.control()
.create_module(
CreateModuleRequest {
session_id: "sess-1".into(),
name: "math".into(),
parents: vec!["base".into()],
},
None,
)
.await
.expect("create_module succeeds");
assert_eq!(resp.module_name, "math");
assert!(resp.success);
}
#[tokio::test]
async fn webhook_actions_invoke_round_trips_typed_request_and_response() {
use reasoninglayer::types::webhook_actions::{InvokeActionRequest, InvokeActionResponse};
use std::collections::BTreeMap;
let server = MockServer::start().await;
let client = client_at(&server.uri());
let mut inputs = BTreeMap::new();
inputs.insert("query".to_string(), json!("hello world"));
let expected_request = json!({
"action_name": "search",
"inputs": {"query": "hello world"},
"tenant_id": TENANT,
});
let response_body = json!({
"callback_url": "http://localhost/api/v1/invocations/inv-1/complete",
"demons_attached": 2,
"invocation_id": "inv-1",
"message": "queued",
"status": "pending",
"term_id": "term-1"
});
Mock::given(method("POST"))
.and(path("/api/v1/external-actions/search/invoke"))
.and(header("x-tenant-id", TENANT))
.and(body_json(expected_request))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp: InvokeActionResponse = client
.webhook_actions()
.invoke(
"search",
InvokeActionRequest {
action_name: "search".into(),
inputs,
tenant_id: TENANT.into(),
},
None,
)
.await
.expect("invoke succeeds");
assert_eq!(resp.invocation_id, "inv-1");
assert_eq!(resp.demons_attached, 2);
}
#[tokio::test]
async fn constraints_create_session_parses_iso_timestamp() {
use reasoninglayer::types::constraints::{
CreateConstraintSessionRequest, CreateConstraintSessionResponse,
};
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/constraint-sessions"))
.and(header("x-tenant-id", TENANT))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "11111111-2222-3333-4444-555555555555",
"created_at": "2026-04-27T12:00:00Z",
"status": "active"
})))
.expect(1)
.mount(&server)
.await;
let resp: CreateConstraintSessionResponse = client
.constraints()
.create_session(CreateConstraintSessionRequest::default(), None)
.await
.expect("create_session succeeds with ISO-8601 created_at");
assert_eq!(resp.session_id, "11111111-2222-3333-4444-555555555555");
assert_eq!(resp.created_at, "2026-04-27T12:00:00Z");
assert_eq!(resp.status, "active");
}
#[tokio::test]
async fn constraints_add_constraints_round_trips_integer_bindings() {
use reasoninglayer::types::constraints::{
AddConstraintsRequest, ArithmeticConstraintDto, ArithmeticExprDto, GeneralConstraintDto,
RelOpDto,
};
use std::collections::BTreeMap;
let server = MockServer::start().await;
let client = client_at(&server.uri());
let mut bindings = BTreeMap::new();
bindings.insert("x".to_string(), 5_i64);
let request_body = json!({
"constraints": [{
"type": "arithmetic",
"constraint_type": "relation",
"left": {"type": "variable", "name": "x"},
"op": "less_than",
"right": {"type": "constant", "value": 10}
}],
"bindings": {"x": 5}
});
let response_body = json!({
"success": true,
"added_count": 1,
"satisfied_count": 1,
"suspended_count": 0,
"current_bindings": {"x": 5},
"message": null
});
Mock::given(method("POST"))
.and(path("/api/v1/constraint-sessions/sess-1/constraints"))
.and(body_json(request_body))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp = client
.constraints()
.add_constraints(
"sess-1",
AddConstraintsRequest {
constraints: vec![GeneralConstraintDto::Arithmetic {
constraint: ArithmeticConstraintDto::Relation {
left: ArithmeticExprDto::Variable { name: "x".into() },
op: RelOpDto::LessThan,
right: ArithmeticExprDto::Constant { value: 10 },
},
}],
bindings: Some(bindings),
},
None,
)
.await
.expect("add_constraints succeeds with integer bindings");
assert!(resp.success);
assert_eq!(resp.added_count, 1);
assert_eq!(resp.current_bindings.get("x"), Some(&5_i64));
}
#[tokio::test]
async fn namespaces_add_import_export_send_typed_bodies_and_ignore_empty_response() {
use reasoninglayer::types::namespaces::{AddExportRequest, AddImportRequest};
let server = MockServer::start().await;
let client = client_at(&server.uri());
Mock::given(method("POST"))
.and(path("/api/v1/namespaces/ns-1/imports"))
.and(header("x-tenant-id", TENANT))
.and(body_json(json!({"source_id": "ns-2"})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/namespaces/ns-1/exports"))
.and(header("x-tenant-id", TENANT))
.and(body_json(json!({"symbol": "MyType"})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
client
.namespaces()
.add_import(
"ns-1",
AddImportRequest {
source_id: "ns-2".into(),
},
None,
)
.await
.expect("add_import succeeds with empty body");
client
.namespaces()
.add_export(
"ns-1",
AddExportRequest {
symbol: "MyType".into(),
},
None,
)
.await
.expect("add_export succeeds with empty body");
}
#[tokio::test]
async fn validate_did_round_trips_typed_request_and_response() {
use reasoninglayer::types::causal::{ValidateDidRequest, ValidateDidResponse};
let server = MockServer::start().await;
let client = client_at(&server.uri());
let expected_request = json!({
"treatment": "policy",
"outcome_pre": "income_2019",
"outcome_post": "income_2021",
"covariates": ["age", "region"]
});
let response_body = json!({
"is_valid": false,
"explanation": "policy parent of region",
"bad_controls": ["region"],
"framework": "Delta-SWIG (Knaus & Pfleiderer, 2026)"
});
Mock::given(method("POST"))
.and(path("/api/v1/causal/validate-did"))
.and(header("x-tenant-id", TENANT))
.and(body_json(expected_request))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
let resp: ValidateDidResponse = client
.causal()
.validate_did(
ValidateDidRequest {
treatment: "policy".into(),
outcome_pre: "income_2019".into(),
outcome_post: "income_2021".into(),
covariates: vec!["age".into(), "region".into()],
},
None,
)
.await
.expect("validate_did succeeds");
assert!(!resp.is_valid);
assert_eq!(resp.bad_controls, vec!["region"]);
assert_eq!(resp.framework, "Delta-SWIG (Knaus & Pfleiderer, 2026)");
}
#[tokio::test]
async fn lattice_dot_returns_plain_text_body() {
let server = MockServer::start().await;
let client = client_at(&server.uri());
let dot = "digraph { \"person\" -> \"employee\" }";
Mock::given(method("GET"))
.and(path("/api/v1/visualization/lattice/dot"))
.and(header("x-tenant-id", TENANT))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/plain; charset=utf-8")
.set_body_string(dot),
)
.expect(1)
.mount(&server)
.await;
let body = client
.visualization()
.lattice_dot(None)
.await
.expect("lattice_dot succeeds on text/plain response");
assert_eq!(body, dot);
}