#![cfg(feature = "http")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use camel_api::{CamelError, Value};
use camel_bundles::BootHandle;
use camel_core::CamelContext;
use camel_integration_test::adapters::DirectStimulus;
use camel_integration_test::env_layers::ambient_std;
use camel_integration_test::{
DocumentOutcome, EndpointRef, Expectation, HttpPartner, LayeredEnv, PartnerAdapter,
PartnerRouter, Provisioning, RouteSource, ScenarioAction, ScenarioDocument, ScenarioFailure,
ScenarioTarget, ScenarioVars, ScenarioVerdict, ScriptedResponse, ValidateExpectation,
boot_scenario, parse_scenario_document, run_scenario_document,
};
const PARTNER_ENDPOINT: &str = "http://127.0.0.1:0/orders";
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/outbound")
}
fn scripted_response(method: &str, body: &[u8]) -> ScriptedResponse {
ScriptedResponse {
method: Some(method.to_string()),
path: Some("/orders".to_string()),
status: 200,
headers: BTreeMap::new(),
body: body.to_vec(),
..Default::default()
}
}
fn layered_env(
doc: &ScenarioDocument,
harness_provisioned: BTreeMap<String, String>,
) -> LayeredEnv {
LayeredEnv::new(
doc.env.clone().unwrap_or_default(),
harness_provisioned,
doc.env_passthrough.clone().unwrap_or_default(),
ambient_std(),
)
}
struct FailingTeardown;
#[async_trait]
impl camel_api::lifecycle::Lifecycle for FailingTeardown {
fn name(&self) -> &str {
"test-failing-teardown"
}
async fn start(&mut self) -> Result<(), CamelError> {
Ok(())
}
async fn stop(&mut self) -> Result<(), CamelError> {
Err(CamelError::Config(
"test-only failing teardown dependency".to_string(),
))
}
}
struct BootedFixture {
doc: ScenarioDocument,
router: PartnerRouter,
ctx: Arc<tokio::sync::Mutex<CamelContext>>,
boot: BootHandle,
inbound_bound: Option<std::net::SocketAddr>,
}
async fn boot_with(
doc: ScenarioDocument,
partner: HttpPartner,
partner_key: String,
) -> BootedFixture {
let harness_provisioned = BTreeMap::from([(
"PARTNER".to_string(),
format!("http://{}", partner.bound_addr()),
)]);
let env = layered_env(&doc, harness_provisioned);
let run = boot_scenario(&doc, &fixture_root(), &env)
.await
.expect("the full boot must succeed");
let ctx = Arc::new(tokio::sync::Mutex::new(run.ctx));
let mut adapters: BTreeMap<String, Box<dyn PartnerAdapter>> = BTreeMap::new();
adapters.insert(
"direct:start".to_string(),
Box::new(DirectStimulus::new(Arc::clone(&ctx))),
);
adapters.insert(partner_key, Box::new(partner));
BootedFixture {
doc,
router: PartnerRouter::new(adapters),
ctx,
boot: run.boot,
inbound_bound: run.inbound_bound,
}
}
async fn boot_method_fixture(method: &str, expected_body: &str) -> BootedFixture {
let partner = HttpPartner::start(vec![scripted_response(method, expected_body.as_bytes())])
.await
.expect("partner must bind 127.0.0.1:0");
let bound_endpoint = format!("http://{}/orders", partner.bound_addr());
let doc = method_scenario_document(method, expected_body, &bound_endpoint);
boot_with(doc, partner, bound_endpoint).await
}
async fn boot_fixture() -> BootedFixture {
let doc = parse_scenario_document(&fixture_root().join("bridge.test.yaml"))
.expect("fixture document must parse");
let partner = HttpPartner::start(vec![scripted_response("POST", b"accepted")])
.await
.expect("partner must bind 127.0.0.1:0");
boot_with(doc, partner, PARTNER_ENDPOINT.to_string()).await
}
fn method_scenario_document(
method: &str,
expected_body: &str,
partner_endpoint: &str,
) -> ScenarioDocument {
let partner = EndpointRef {
endpoint: partner_endpoint.to_string(),
provisioning: Some(Provisioning::Harness),
bind_var: Some("PARTNER".to_string()),
};
let scenario = vec![
ScenarioAction::Send {
to: partner.clone(),
body: None,
headers: None,
method: method.to_string(),
expect_reply: None,
},
ScenarioAction::Receive {
from: partner.clone(),
deadline: Duration::from_secs(2),
extract: None,
},
ScenarioAction::Validate {
target: ScenarioTarget::LastReceived(partner),
expectation: ValidateExpectation::Message(Expectation::Equals(Value::String(
expected_body.to_string(),
))),
deadline: None,
elapsed_at_least: None,
},
];
ScenarioDocument {
source_path: fixture_root().join("bridge.test.yaml"),
route_source: RouteSource::RouteFiles(vec![PathBuf::from("routes/bridge.yaml")]),
scenario,
partners: None,
env: None,
env_passthrough: None,
profile: Some("default".to_string()),
send_deadline: None,
inbound: None,
logs: None,
}
}
#[tokio::test]
async fn outbound_bridge_validates_wire() {
let fixture = boot_fixture().await;
let mut vars = ScenarioVars::new();
let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
assert_eq!(
outcome.verdict,
Some(ScenarioVerdict::Pass),
"every action must pass: {outcome:?}"
);
let mut ctx = fixture.ctx.lock().await;
fixture
.boot
.shutdown(&mut ctx)
.await
.expect("clean shutdown must complete");
}
#[tokio::test]
async fn explicit_put_reaches_partner() {
let fixture = boot_method_fixture("PUT", "put-ok").await;
let mut vars = ScenarioVars::new();
let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
assert_eq!(
outcome.verdict,
Some(ScenarioVerdict::Pass),
"the PUT send must reach the partner and validate: {outcome:?}"
);
let mut ctx = fixture.ctx.lock().await;
fixture
.boot
.shutdown(&mut ctx)
.await
.expect("clean shutdown must complete");
}
#[tokio::test]
async fn bodyless_post_reaches_partner() {
let fixture = boot_method_fixture("POST", "post-ok").await;
let mut vars = ScenarioVars::new();
let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
assert_eq!(
outcome.verdict,
Some(ScenarioVerdict::Pass),
"the bodyless POST send must reach the partner and validate: {outcome:?}"
);
let mut ctx = fixture.ctx.lock().await;
fixture
.boot
.shutdown(&mut ctx)
.await
.expect("clean shutdown must complete");
}
#[tokio::test]
async fn outbound_bridge_header_corruption_fails() {
let fixture = boot_fixture().await;
let corrupted = ScenarioDocument {
source_path: fixture.doc.source_path.clone(),
route_source: fixture.doc.route_source,
scenario: fixture
.doc
.scenario
.iter()
.map(|action| {
if let ScenarioAction::Validate { target, .. } = action
&& matches!(target, ScenarioTarget::Variable(name) if name == "orderType")
{
ScenarioAction::Validate {
target: target.clone(),
expectation: ValidateExpectation::Message(Expectation::Equals(
Value::String("express".to_string()),
)),
deadline: None,
elapsed_at_least: None,
}
} else {
action.clone()
}
})
.collect(),
env: fixture.doc.env.clone(),
env_passthrough: fixture.doc.env_passthrough.clone(),
profile: fixture.doc.profile.clone(),
partners: None,
send_deadline: fixture.doc.send_deadline,
inbound: fixture.doc.inbound,
logs: fixture.doc.logs,
};
let mut vars = ScenarioVars::new();
let outcome = run_scenario_document(&corrupted, &fixture.router, &mut vars, None).await;
assert_eq!(outcome.verdict, None, "the corrupted header must fail");
let mismatch = outcome
.per_action
.last()
.and_then(|result| result.as_ref().err())
.expect("the failing action must carry a failure");
assert!(
matches!(mismatch, ScenarioFailure::ValidationMismatch { .. }),
"expected ValidationMismatch, got {mismatch:?}"
);
assert!(
mismatch.to_string().contains("orderType"),
"the mismatch must name the header's variable: {mismatch}"
);
let mut ctx = fixture.ctx.lock().await;
fixture
.boot
.shutdown(&mut ctx)
.await
.expect("shutdown after a verdict failure must still complete");
}
#[tokio::test]
async fn shutdown_failure_does_not_mask_verdict() {
let fixture = boot_fixture().await;
fixture.ctx.lock().await.add_lifecycle(FailingTeardown);
let mut vars = ScenarioVars::new();
let mut outcome: DocumentOutcome =
run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
outcome.inbound_bound = fixture.inbound_bound;
assert_eq!(outcome.verdict, Some(ScenarioVerdict::Pass));
let mut ctx = fixture.ctx.lock().await;
let shutdown = fixture.boot.shutdown(&mut ctx).await;
outcome.final_failure = shutdown.err().map(|e| ScenarioFailure::ShutdownFailure {
message: e.to_string(),
});
assert_eq!(
outcome.verdict,
Some(ScenarioVerdict::Pass),
"the shutdown failure must not mask the recorded verdict"
);
let final_failure = outcome
.final_failure
.as_ref()
.expect("the shutdown failure must be reported deterministically");
assert!(
matches!(final_failure, ScenarioFailure::ShutdownFailure { .. }),
"expected ShutdownFailure, got {final_failure:?}"
);
assert!(
final_failure
.to_string()
.contains("test-only failing teardown"),
"the failure must name the teardown dependency: {final_failure}"
);
}
#[tokio::test]
async fn outbound_receive_deadline_is_real() {
let mut fixture = boot_fixture().await;
fixture.doc.scenario.retain(|action| {
matches!(
action,
ScenarioAction::Receive { .. } | ScenarioAction::Validate { .. }
)
});
let mut vars = ScenarioVars::new();
let started = std::time::Instant::now();
let outcome = run_scenario_document(&fixture.doc, &fixture.router, &mut vars, None).await;
assert_eq!(outcome.verdict, None);
let failure = outcome
.per_action
.first()
.and_then(|result| result.as_ref().err())
.expect("the receive must fail");
assert!(
matches!(failure, ScenarioFailure::ReceiveTimeout { .. }),
"expected ReceiveTimeout, got {failure:?}"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"the 2s deadline must bound the wait, not hang"
);
let mut ctx = fixture.ctx.lock().await;
fixture
.boot
.shutdown(&mut ctx)
.await
.expect("shutdown must complete after a timeout");
}