use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use camel_api::{Body, Exchange, Message};
use camel_component_api::NoOpComponentContext;
use camel_component_direct::DirectComponent;
use camel_component_log::LogComponent;
use camel_component_mock::HeaderMatcher;
use camel_component_mock::MockComponent;
use camel_component_seda::SedaComponent;
use camel_component_timer::TimerComponent;
use camel_core::CamelContext;
use camel_core::cache::MemoryCacheRepository;
use camel_core::claim_check::MemoryClaimCheckRepository;
use camel_core::idempotent::MemoryIdempotentRepository;
use camel_core::intercept::InterceptRules;
use noyalib::compat::serde_yaml;
use tokio::sync::Mutex;
use tower::ServiceExt;
use super::beans::{collect_bean_calls, stub_from_decl};
use super::document::{
ExpectReply, ExpectSet, InputBody, RepositoriesDoc, TestDocError, TestDocument, TestInput,
};
pub(crate) const SETTLE_DEADLINE: Duration = Duration::from_secs(5);
const SAMPLE_INTERVAL: Duration = Duration::from_millis(50);
const DEFAULT_QUIET: Duration = Duration::from_millis(250);
const STARTUP_RETRY_SLEEP: Duration = Duration::from_millis(20);
const STARTUP_RETRY_DEADLINE: Duration = Duration::from_secs(1);
pub struct EndpointResult {
pub endpoint: String,
pub outcome: Result<(), String>,
}
pub struct TestDocResult {
pub endpoint_results: Vec<EndpointResult>,
pub doc_error: Option<String>,
}
async fn boot_context(
intercepts: Option<InterceptRules>,
beans: Option<Arc<std::sync::Mutex<camel_bean::BeanRegistry>>>,
repo_stubs: Option<&RepositoriesDoc>,
) -> Result<(CamelContext, MockComponent), String> {
let mut builder = CamelContext::builder();
if let Some(rules) = intercepts {
builder = builder.with_intercept_rules(rules);
}
if let Some(registry) = beans {
builder = builder.beans(registry);
}
let mut ctx = builder
.build()
.await
.map_err(|e| format!("failed to boot CamelContext: {e}"))?;
let mock = MockComponent::new();
ctx.register_component(mock.clone());
ctx.register_component(DirectComponent::new());
ctx.register_component(TimerComponent::new());
ctx.register_component(LogComponent::new());
ctx.register_component(SedaComponent::new());
if let Some(stubs) = repo_stubs {
if let Some(cache) = &stubs.cache {
for name in cache.keys() {
ctx.register_cache_repository(
name.clone(),
Arc::new(MemoryCacheRepository::new(name.clone(), 10_000)),
)
.expect("repository stub registration must succeed"); }
}
if let Some(idempotent) = &stubs.idempotent {
for name in idempotent.keys() {
ctx.register_idempotent_repository(
name.clone(),
Arc::new(MemoryIdempotentRepository::new(name.clone())),
)
.expect("repository stub registration must succeed"); }
}
if let Some(claim_check) = &stubs.claim_check {
for name in claim_check.keys() {
ctx.register_claim_check_repository(
name.clone(),
Arc::new(MemoryClaimCheckRepository::new(name.clone())),
)
.expect("repository stub registration must succeed"); }
}
}
Ok((ctx, mock))
}
pub(crate) fn find_camel_toml_root(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| dir.join("Camel.toml").exists())
.map(Path::to_path_buf)
}
async fn load_routes(
doc: &TestDocument,
doc_dir: &Path,
) -> Result<Vec<camel_core::RouteDefinition>, String> {
if let Some(files) = &doc.route_files_from_root {
let root = find_camel_toml_root(doc_dir).ok_or_else(|| {
TestDocError::NoProjectRoot {
doc_dir: doc_dir.display().to_string(),
}
.to_string()
})?;
let mut defs = Vec::new();
for path in files {
let full = root.join(path);
let loaded =
camel_dsl::load_from_file(&full).map_err(|e| format!("{}: {e}", full.display()))?;
defs.extend(loaded);
}
Ok(defs)
} else if let Some(files) = &doc.route_files {
let mut defs = Vec::new();
for path in files {
let full = doc_dir.join(path);
let loaded =
camel_dsl::load_from_file(&full).map_err(|e| format!("{}: {e}", full.display()))?;
defs.extend(loaded);
}
Ok(defs)
} else if let Some(value) = &doc.routes {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert("routes", value.clone());
let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
.map_err(|e| format!("failed to serialize inline routes: {e}"))?;
camel_dsl::parse_yaml(&text).map_err(|e| format!("inline routes: {e}"))
} else {
Err("document declares none of routeFiles, routeFilesFromRoot, or routes".to_string())
}
}
async fn deliver_input(
ctx: &Arc<Mutex<CamelContext>>,
input: &TestInput,
) -> Result<Exchange, String> {
let body = match &input.body {
Some(InputBody::Text(s)) => Body::Text(s.clone()),
Some(InputBody::Json(v)) => Body::Json(v.clone()),
None => Body::Empty,
};
let mut message = Message::new(body);
if let Some(headers) = &input.headers {
for (k, v) in headers {
message.set_header(k.clone(), v.clone());
}
}
let exchange = Exchange::new(message);
let deadline = tokio::time::Instant::now() + STARTUP_RETRY_DEADLINE;
loop {
let producer = {
let ctx = ctx.lock().await;
let producer_ctx = ctx.producer_context();
let registry = ctx.registry();
let component = registry
.get("direct")
.ok_or_else(|| "direct component not registered".to_string())?;
let endpoint = component
.create_endpoint(&input.to, &*ctx)
.map_err(|e| format!("failed to create endpoint {}: {e}", input.to))?;
endpoint
.create_producer(Arc::new(NoOpComponentContext), &producer_ctx)
.map_err(|e| format!("failed to create producer for {}: {e}", input.to))?
};
match producer.oneshot(exchange.clone()).await {
Ok(reply) => return Ok(reply),
Err(e) => {
let is_startup_race = matches!(e, camel_api::CamelError::EndpointCreationFailed(_))
|| e.to_string().contains("not registered");
if is_startup_race && tokio::time::Instant::now() < deadline {
tokio::time::sleep(STARTUP_RETRY_SLEEP).await;
continue;
}
return Err(format!("input to {} failed: {e}", input.to));
}
}
}
}
async fn sample_counts(mock: &MockComponent, names: &[String]) -> Vec<usize> {
let mut counts = Vec::with_capacity(names.len());
for name in names {
let count = match mock.get_endpoint(name) {
Some(inner) => inner.received_count().await,
None => 0,
};
counts.push(count);
}
counts
}
async fn settle(
mock: &MockComponent,
names: &[String],
quiet: Duration,
route_started_at: Instant,
) -> Result<(), String> {
let deadline = route_started_at + quiet + SETTLE_DEADLINE;
let mut last_change = Instant::now();
let mut last_counts = sample_counts(mock, names).await;
loop {
tokio::time::sleep(SAMPLE_INTERVAL).await;
let now = Instant::now();
if now >= deadline {
return Err(
"settle timeout: traffic did not quiesce within the 5s instability budget"
.to_string(),
);
}
let counts = sample_counts(mock, names).await;
if counts != last_counts {
last_counts = counts;
last_change = now;
continue;
}
if now.duration_since(last_change) >= quiet {
return Ok(());
}
}
}
fn set_expectations(inner: &camel_component_mock::MockEndpointInner, set: &ExpectSet) {
if let Some(n) = set.count {
inner.expect_count(n);
}
if let Some(m) = set.min_count {
inner.expect_minimum_count(m);
}
if let Some(bodies) = &set.bodies {
for matcher in bodies {
inner.expect_body_matcher(matcher.clone());
}
}
if let Some(headers) = &set.headers {
for (key, matcher) in headers {
inner.expect_header_matcher(key, matcher.clone());
}
}
}
async fn evaluate_endpoint(mock: &MockComponent, name: &str, set: &ExpectSet) -> EndpointResult {
match mock.get_endpoint(name) {
None => EndpointResult {
endpoint: name.to_string(),
outcome: Err(format!("endpoint '{name}' not created by any route")),
},
Some(inner) => {
set_expectations(&inner, set);
match inner.try_assert_satisfied().await {
Ok(()) => EndpointResult {
endpoint: name.to_string(),
outcome: Ok(()),
},
Err(e) => EndpointResult {
endpoint: name.to_string(),
outcome: Err(e.to_string()),
},
}
}
}
}
fn render_body(body: &Body) -> String {
match body {
Body::Text(s) => s.clone(),
Body::Json(v) => v.to_string(),
other => format!("{other:?}"),
}
}
fn evaluate_reply_expectation(
expect: &ExpectReply,
reply: &Exchange,
label: &str,
) -> EndpointResult {
let message = reply.output.as_ref().unwrap_or(&reply.input);
if let Some(matcher) = &expect.body
&& !matcher.matches(&message.body)
{
return EndpointResult {
endpoint: label.to_string(),
outcome: Err(format!(
"reply body mismatch: expected {matcher}, actual {}",
render_body(&message.body)
)),
};
}
if let Some(expected_headers) = &expect.headers {
let mut entries: Vec<(&String, &HeaderMatcher)> = expected_headers.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (key, matcher) in entries {
let actual = message.headers.get(key);
if matcher.matches(actual) {
continue;
}
let actual_render = match actual {
Some(value) => value.to_string(),
None => "<missing>".to_string(),
};
return EndpointResult {
endpoint: label.to_string(),
outcome: Err(format!(
"reply header mismatch '{key}': expected {matcher}, actual {actual_render}"
)),
};
}
}
EndpointResult {
endpoint: label.to_string(),
outcome: Ok(()),
}
}
fn stub_registry(
doc: &TestDocument,
defs: &[camel_core::RouteDefinition],
) -> Result<Option<Arc<std::sync::Mutex<camel_bean::BeanRegistry>>>, String> {
let Some(decls) = doc.bean_decls() else {
return Ok(None);
};
let calls = collect_bean_calls(defs);
let registry = camel_bean::BeanRegistry::new();
for (name, decl) in decls {
if let Some(declared) = decl.methods.as_ref() {
for (bean_name, method) in &calls {
if bean_name == name && !declared.contains(method) {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: method {method} is not declared"
))
.to_string());
}
}
}
let invoked: Vec<String> = calls
.iter()
.filter(|(bean_name, _)| bean_name == name)
.map(|(_, method)| method.clone())
.collect();
registry
.register(name.clone(), stub_from_decl(name, decl, &invoked))
.map_err(|e| format!("failed to register bean {name}: {e}"))?;
}
Ok(Some(Arc::new(std::sync::Mutex::new(registry))))
}
async fn run_phases(
ctx: &Arc<Mutex<CamelContext>>,
mock: &MockComponent,
doc: &TestDocument,
defs: Vec<camel_core::RouteDefinition>,
) -> TestDocResult {
let route_started_at = {
let mut guard = ctx.lock().await;
for def in defs {
if let Err(e) = guard.add_route_definition(def).await {
return TestDocResult {
endpoint_results: vec![],
doc_error: Some(format!("failed to add route: {e}")),
};
}
}
if let Err(e) = guard.start().await {
return TestDocResult {
endpoint_results: vec![],
doc_error: Some(format!("failed to start routes: {e}")),
};
}
Instant::now()
};
let mut replies: Vec<Exchange> = Vec::with_capacity(doc.inputs.len());
for input in &doc.inputs {
match deliver_input(ctx, input).await {
Ok(reply) => replies.push(reply),
Err(e) => {
return TestDocResult {
endpoint_results: vec![],
doc_error: Some(e),
};
}
}
}
let names: Vec<String> = doc.expects.keys().cloned().collect();
let quiet = doc.settle_duration().unwrap_or(DEFAULT_QUIET);
if let Err(e) = settle(mock, &names, quiet, route_started_at).await {
return TestDocResult {
endpoint_results: vec![EndpointResult {
endpoint: "<settle>".to_string(),
outcome: Err(e),
}],
doc_error: None,
};
}
let mut endpoint_results = Vec::with_capacity(doc.expects.len());
for (name, set) in &doc.expects {
endpoint_results.push(evaluate_endpoint(mock, name, set).await);
}
for (index, (input, reply)) in doc.inputs.iter().zip(&replies).enumerate() {
if let Some(expect) = input.expect_reply.as_ref() {
let label = format!("reply[{index}] {}", input.to);
endpoint_results.push(evaluate_reply_expectation(expect, reply, &label));
}
}
TestDocResult {
endpoint_results,
doc_error: None,
}
}
pub async fn run_test_doc(doc: &TestDocument, doc_dir: &Path) -> (TestDocResult, MockComponent) {
let defs = match load_routes(doc, doc_dir).await {
Ok(defs) => defs,
Err(e) => {
return (
TestDocResult {
endpoint_results: vec![],
doc_error: Some(e),
},
MockComponent::new(),
);
}
};
let beans = match stub_registry(doc, &defs) {
Ok(beans) => beans,
Err(e) => {
return (
TestDocResult {
endpoint_results: vec![],
doc_error: Some(e),
},
MockComponent::new(),
);
}
};
let (ctx, mock) = match boot_context(doc.intercept_rules(), beans, doc.repository_stubs()).await
{
Ok((ctx, mock)) => (Arc::new(Mutex::new(ctx)), mock),
Err(e) => {
return (
TestDocResult {
endpoint_results: vec![],
doc_error: Some(e),
},
MockComponent::new(),
);
}
};
let result = run_phases(&ctx, &mock, doc, defs).await;
{
let mut guard = ctx.lock().await;
let _ = guard.stop().await;
}
(result, mock)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_camel_toml_root_strict_walk() {
let root = tempfile::tempdir().expect("tempdir"); std::fs::write(root.path().join("Camel.toml"), "").expect("write Camel.toml"); let nested = root.path().join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested dir"); assert_eq!(
find_camel_toml_root(&nested),
Some(root.path().to_path_buf())
);
}
#[test]
fn find_camel_toml_root_no_marker_is_none() {
let root = tempfile::tempdir().expect("tempdir"); std::fs::write(root.path().join("Cargo.toml"), "[workspace]\n").expect("write Cargo.toml"); let nested = root.path().join("nested");
std::fs::create_dir_all(&nested).expect("create nested dir"); assert_eq!(find_camel_toml_root(&nested), None);
}
#[test]
fn reply_output_message_precedence() {
let mut exchange = Exchange::new(Message::new(Body::Text("A".to_string())));
exchange.output = Some(Message::new(Body::Text("B".to_string())));
let expect_b = ExpectReply {
body: Some(camel_component_mock::BodyMatcher::Equals(Body::Text(
"B".to_string(),
))),
headers: None,
};
let row = evaluate_reply_expectation(&expect_b, &exchange, "reply[0] direct:in");
assert_eq!(row.endpoint, "reply[0] direct:in");
assert!(
row.outcome.is_ok(),
"expected B must match output body B: {:?}",
row.outcome
);
let expect_a = ExpectReply {
body: Some(camel_component_mock::BodyMatcher::Equals(Body::Text(
"A".to_string(),
))),
headers: None,
};
let row = evaluate_reply_expectation(&expect_a, &exchange, "reply[0] direct:in");
assert!(
row.outcome.is_err(),
"expected A must NOT match output body B (output takes precedence)"
);
}
}