Skip to main content

camel_test/
harness.rs

1// crates/camel-test/src/harness.rs
2
3use std::sync::{
4    Arc,
5    atomic::{AtomicBool, Ordering},
6};
7
8use camel_api::CamelError;
9use camel_component_direct::DirectComponent;
10use camel_component_log::LogComponent;
11use camel_component_mock::MockComponent;
12use camel_component_timer::TimerComponent;
13use camel_core::CamelContext;
14use camel_core::route::RouteDefinition;
15use tokio::sync::Mutex;
16
17use crate::time::TimeController;
18
19// ---------------------------------------------------------------------------
20// Typestates
21// ---------------------------------------------------------------------------
22
23pub struct NoTimeControl;
24pub struct WithTimeControl;
25
26// ---------------------------------------------------------------------------
27// Builder
28// ---------------------------------------------------------------------------
29
30type Registration = Box<dyn FnOnce(&mut CamelContext) + Send>;
31
32/// Builder for [`CamelTestContext`].
33///
34/// Use [`CamelTestContext::builder()`] to obtain one.
35pub struct CamelTestContextBuilder<S = NoTimeControl> {
36    registrations: Vec<Registration>,
37    mock: MockComponent,
38    _state: std::marker::PhantomData<S>,
39}
40
41impl CamelTestContextBuilder<NoTimeControl> {
42    pub(crate) fn new() -> Self {
43        Self {
44            registrations: Vec::new(),
45            mock: MockComponent::new(),
46            _state: std::marker::PhantomData,
47        }
48    }
49
50    /// Activate tokio mock-time. `build()` will call `tokio::time::pause()`
51    /// and return a [`TimeController`] alongside the harness.
52    pub fn with_time_control(self) -> CamelTestContextBuilder<WithTimeControl> {
53        CamelTestContextBuilder {
54            registrations: self.registrations,
55            mock: self.mock,
56            _state: std::marker::PhantomData,
57        }
58    }
59
60    /// Build the harness without time control.
61    pub async fn build(self) -> CamelTestContext {
62        build_context(self.registrations, self.mock).await
63    }
64}
65
66impl CamelTestContextBuilder<WithTimeControl> {
67    /// Build the harness with time control.
68    ///
69    /// Calls `tokio::time::pause()` before returning. Use the returned
70    /// [`TimeController`] to advance the clock inside the test.
71    pub async fn build(self) -> (CamelTestContext, TimeController) {
72        tokio::time::pause();
73        let ctx = build_context(self.registrations, self.mock).await;
74        (ctx, TimeController)
75    }
76}
77
78macro_rules! impl_builder_methods {
79    ($S:ty) => {
80        impl CamelTestContextBuilder<$S> {
81            /// Include `MockComponent` explicitly (always registered; this is a
82            /// documentation signal for call sites).
83            pub fn with_mock(self) -> Self {
84                self
85            }
86
87            /// Replace the default `MockComponent` with one configured for
88            /// fail-fast mode. Every mock endpoint created in this harness will
89            /// honour the per-endpoint `trigger_fail_fast` latch: once tripped,
90            /// subsequent `MockProducer::call` invocations return
91            /// `Err(ProcessorError)`. Use this to build endpoints that resolve
92            /// successfully but fail on call — e.g. to drive a `recipient_list`
93            /// into the zero-success guard of ADR-0058.
94            pub fn with_mock_fail_fast(mut self) -> Self {
95                self.mock = camel_component_mock::MockComponent::with_config(
96                    camel_component_mock::MockConfig {
97                        fail_fast: true,
98                        ..Default::default()
99                    },
100                );
101                self
102            }
103
104            /// Register `TimerComponent`.
105            pub fn with_timer(mut self) -> Self {
106                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
107                    ctx.register_component(TimerComponent::new());
108                }));
109                self
110            }
111
112            /// Register `LogComponent`.
113            pub fn with_log(mut self) -> Self {
114                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
115                    ctx.register_component(LogComponent::new());
116                }));
117                self
118            }
119
120            /// Register `DirectComponent`.
121            pub fn with_direct(mut self) -> Self {
122                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
123                    ctx.register_component(DirectComponent::new());
124                }));
125                self
126            }
127
128            /// Register `SedaComponent`.
129            pub fn with_seda(mut self) -> Self {
130                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
131                    ctx.register_component(camel_component_seda::SedaComponent::new());
132                }));
133                self
134            }
135
136            /// Register any component that implements the `Component` trait.
137            pub fn with_component<C>(mut self, component: C) -> Self
138            where
139                C: camel_component_api::Component + 'static,
140            {
141                self.registrations
142                    .push(Box::new(move |ctx: &mut CamelContext| {
143                        ctx.register_component(component);
144                    }));
145                self
146            }
147        }
148    };
149}
150
151impl_builder_methods!(NoTimeControl);
152impl_builder_methods!(WithTimeControl);
153
154// ---------------------------------------------------------------------------
155// Internal build helper
156// ---------------------------------------------------------------------------
157
158async fn build_context(registrations: Vec<Registration>, mock: MockComponent) -> CamelTestContext {
159    let mut ctx = CamelContext::builder().build().await.unwrap(); // allow-unwrap
160
161    // MockComponent is always registered.
162    ctx.register_component(mock.clone());
163
164    // Run caller-declared registrations.
165    for register in registrations {
166        register(&mut ctx);
167    }
168
169    let ctx = Arc::new(Mutex::new(ctx));
170    let stopped = Arc::new(AtomicBool::new(false));
171
172    CamelTestContext {
173        ctx: ctx.clone(),
174        mock,
175        stopped: stopped.clone(),
176        _guard: TestGuard { ctx, stopped },
177    }
178}
179
180// ---------------------------------------------------------------------------
181// TestGuard — automatic stop on drop
182// ---------------------------------------------------------------------------
183
184pub(crate) struct TestGuard {
185    ctx: Arc<Mutex<CamelContext>>,
186    stopped: Arc<AtomicBool>,
187}
188
189impl Drop for TestGuard {
190    fn drop(&mut self) {
191        if self.stopped.swap(true, Ordering::SeqCst) {
192            // Already stopped explicitly — nothing to do.
193            return;
194        }
195        let ctx = self.ctx.clone();
196        if let Ok(handle) = tokio::runtime::Handle::try_current() {
197            match handle.runtime_flavor() {
198                tokio::runtime::RuntimeFlavor::MultiThread => {
199                    // Deterministic cleanup when blocking is supported.
200                    tokio::task::block_in_place(|| {
201                        handle.block_on(async move {
202                            let mut ctx = ctx.lock().await;
203                            let _ = ctx.stop().await;
204                        });
205                    });
206                }
207                tokio::runtime::RuntimeFlavor::CurrentThread => {
208                    // Best effort fallback for current-thread runtimes where
209                    // blocking in Drop is not possible.
210                    handle.spawn(async move {
211                        let mut ctx = ctx.lock().await;
212                        let _ = ctx.stop().await;
213                    });
214                }
215                _ => {
216                    handle.spawn(async move {
217                        let mut ctx = ctx.lock().await;
218                        let _ = ctx.stop().await;
219                    });
220                }
221            }
222        }
223    }
224}
225
226// ---------------------------------------------------------------------------
227// CamelTestContext
228// ---------------------------------------------------------------------------
229
230/// Test harness that wraps [`CamelContext`] with teardown helpers,
231/// pre-registered components, and a shared [`MockComponent`] accessor.
232///
233/// # Example
234///
235/// ```no_run
236/// # use camel_test::CamelTestContext;
237/// # use std::time::Duration;
238/// #[tokio::test]
239/// async fn my_test() {
240///     let h = CamelTestContext::builder()
241///         .with_timer()
242///         .with_mock()
243///         .build()
244///         .await;
245///
246///     // add routes, start, assert…
247///     h.stop().await; // deterministic teardown
248///     // Drop also performs best-effort cleanup if omitted
249/// }
250/// ```
251pub struct CamelTestContext {
252    ctx: Arc<Mutex<CamelContext>>,
253    mock: MockComponent,
254    stopped: Arc<AtomicBool>,
255    _guard: TestGuard,
256}
257
258impl CamelTestContext {
259    /// Obtain a builder.
260    pub fn builder() -> CamelTestContextBuilder<NoTimeControl> {
261        CamelTestContextBuilder::new()
262    }
263
264    /// Add a route definition to the context.
265    pub async fn add_route(&self, route: RouteDefinition) -> Result<(), CamelError> {
266        let ctx = self.ctx.lock().await;
267        ctx.add_route_definition(route).await
268    }
269
270    /// Start all routes.
271    pub async fn start(&self) {
272        let mut ctx = self.ctx.lock().await;
273        ctx.start().await.expect("CamelTestContext: start failed"); // allow-unwrap
274    }
275
276    /// Stop all routes explicitly. Safe to call before the harness is dropped —
277    /// subsequent drop is a no-op.
278    pub async fn stop(&self) {
279        if self.stopped.swap(true, Ordering::SeqCst) {
280            return; // already stopped
281        }
282        let mut ctx = self.ctx.lock().await;
283        ctx.stop().await.expect("CamelTestContext: stop failed"); // allow-unwrap
284    }
285
286    /// Consume the harness and stop routes deterministically.
287    pub async fn shutdown(self) {
288        self.stop().await;
289    }
290
291    /// Access the shared mock component for assertions.
292    pub fn mock(&self) -> &MockComponent {
293        &self.mock
294    }
295
296    /// Escape hatch: access the underlying [`CamelContext`] directly.
297    pub fn ctx(&self) -> &Arc<Mutex<CamelContext>> {
298        &self.ctx
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use camel_api::SpanKindHint;
306    use camel_builder::{RouteBuilder, StepAccumulator};
307    use std::time::Duration;
308
309    #[tokio::test]
310    async fn builder_without_time_control_builds_context() {
311        let harness = CamelTestContext::builder()
312            .with_mock()
313            .with_timer()
314            .with_log()
315            .build()
316            .await;
317
318        assert!(harness.mock().get_endpoint("result").is_none());
319        let guard = harness.ctx().lock().await;
320        let _ = &*guard;
321    }
322
323    #[tokio::test]
324    async fn builder_with_time_control_builds_and_advances_clock() {
325        let (_harness, time) = CamelTestContext::builder()
326            .with_mock()
327            .with_timer()
328            .with_time_control()
329            .build()
330            .await;
331
332        time.advance(Duration::from_millis(1)).await;
333        time.resume();
334    }
335
336    #[tokio::test]
337    async fn stop_is_idempotent_and_shutdown_is_safe() {
338        let harness = CamelTestContext::builder().with_mock().build().await;
339        harness.stop().await;
340        harness.stop().await;
341        harness.shutdown().await;
342    }
343
344    #[tokio::test]
345    async fn add_route_returns_error_for_invalid_step_uri() {
346        let harness = CamelTestContext::builder().with_mock().build().await;
347
348        let route = RouteBuilder::from("direct:start")
349            .route_id("bad-route")
350            .to("not-a-uri")
351            .build()
352            .unwrap();
353
354        let err = harness.add_route(route).await.expect_err("must fail");
355        assert!(err.to_string().contains("Invalid") || err.to_string().contains("invalid"));
356    }
357
358    #[tokio::test]
359    async fn with_component_registers_custom_component() {
360        let harness = CamelTestContext::builder()
361            .with_component(camel_component_direct::DirectComponent::new())
362            .with_mock()
363            .build()
364            .await;
365
366        let route = RouteBuilder::from("direct:start")
367            .route_id("direct-route")
368            .to("mock:out")
369            .build()
370            .unwrap();
371
372        harness.add_route(route).await.unwrap();
373        harness.start().await;
374        harness.stop().await;
375
376        // Harness context remains accessible after lifecycle.
377        let _guard = harness.ctx().lock().await;
378    }
379
380    // ── TST-004: Route lifecycle (start/stop/restart) ─────────────────────────
381
382    #[tokio::test]
383    async fn tst004_route_lifecycle_start_stop_restart() {
384        let harness = CamelTestContext::builder()
385            .with_direct()
386            .with_mock()
387            .build()
388            .await;
389
390        let route = RouteBuilder::from("direct:lifecycle")
391            .route_id("lifecycle-route")
392            .to("mock:lifecycle-out")
393            .build()
394            .unwrap();
395
396        harness.add_route(route).await.unwrap();
397
398        // Start the context — routes transition to Started.
399        harness.start().await;
400
401        // Stop the context — routes transition to Stopped.
402        harness.stop().await;
403
404        // Restart via the underlying CamelContext to verify start-after-stop works.
405        {
406            let mut ctx = harness.ctx().lock().await;
407            ctx.start().await.expect("restart should succeed");
408            ctx.stop().await.expect("stop after restart should succeed");
409        }
410    }
411
412    // ── TST-005: Concurrent exchange processing ───────────────────────────────
413
414    #[tokio::test]
415    async fn tst005_concurrent_exchange_processing() {
416        use camel_api::{BoxProcessor, BoxProcessorExt, Exchange, Message};
417        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
418        use std::sync::Arc;
419        use std::sync::atomic::{AtomicU32, Ordering};
420        use tower::ServiceExt;
421
422        let counter = Arc::new(AtomicU32::new(0));
423        let processor: BoxProcessor = {
424            let c = Arc::clone(&counter);
425            BoxProcessor::from_fn(move |ex: Exchange| {
426                let c = Arc::clone(&c);
427                Box::pin(async move {
428                    c.fetch_add(1, Ordering::Relaxed);
429                    tokio::task::yield_now().await;
430                    Ok(ex)
431                })
432            })
433        };
434
435        let pipeline = compose_pipeline(
436            vec![CompiledStep::Process {
437                kind_hint: SpanKindHint::Internal,
438                processor,
439                body_contract: None,
440                lifecycle: None,
441                label: None,
442            }],
443            PipelineRuntimeCtx::compile_time(),
444        );
445
446        let concurrency: u32 = 10;
447        let mut handles = Vec::with_capacity(concurrency as usize);
448        for i in 0..concurrency {
449            let p = pipeline.clone();
450            handles.push(tokio::spawn(async move {
451                let ex = Exchange::new(Message::new(format!("msg-{i}")));
452                p.oneshot(ex).await.unwrap()
453            }));
454        }
455
456        for h in handles {
457            let _ = h.await.unwrap();
458        }
459
460        assert_eq!(counter.load(Ordering::Relaxed), concurrency);
461    }
462
463    // ── TST-006: Error handler invocation ─────────────────────────────────────
464
465    #[tokio::test]
466    #[allow(deprecated)]
467    async fn tst006_error_handler_invoked_on_failure() {
468        use camel_api::error_handler::ExceptionPolicy;
469        use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message};
470        use camel_processor::ErrorHandlerService;
471        use std::sync::Arc;
472        use tower::ServiceExt;
473
474        let error_received = Arc::new(std::sync::Mutex::new(false));
475        let error_received_clone = Arc::clone(&error_received);
476
477        // Handler processor that records it was called.
478        let handler: BoxProcessor = BoxProcessor::from_fn(move |ex: Exchange| {
479            let r = Arc::clone(&error_received_clone);
480            Box::pin(async move {
481                *r.lock().unwrap() = true;
482                Ok(ex)
483            })
484        });
485
486        // Inner processor that always fails.
487        let failing: BoxProcessor = BoxProcessor::from_fn(|_| {
488            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
489        });
490
491        let policy = ExceptionPolicy::new(|_| true);
492        let svc = ErrorHandlerService::new(failing, Some(handler), vec![(policy, None)]);
493        let ex = Exchange::new(Message::new("test"));
494        let result = svc.oneshot(ex).await;
495
496        assert!(result.is_ok(), "error handler should absorb the error");
497        assert!(
498            result.unwrap().has_error(),
499            "exchange should have error set"
500        );
501        assert!(
502            *error_received.lock().unwrap(),
503            "error handler processor should have been invoked"
504        );
505    }
506
507    // ── TST-007: Dead letter channel ──────────────────────────────────────────
508
509    #[tokio::test]
510    #[allow(deprecated)]
511    async fn tst007_dead_letter_channel_receives_failed_exchange() {
512        use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message};
513        use camel_processor::ErrorHandlerService;
514        use std::sync::Arc;
515        use tower::ServiceExt;
516
517        let dlc_received = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
518        let dlc_received_clone = Arc::clone(&dlc_received);
519
520        // DLC processor that captures the exchange.
521        let dlc: BoxProcessor = BoxProcessor::from_fn(move |ex: Exchange| {
522            let r = Arc::clone(&dlc_received_clone);
523            Box::pin(async move {
524                r.lock().unwrap().push(ex.clone());
525                Ok(ex)
526            })
527        });
528
529        let failing: BoxProcessor = BoxProcessor::from_fn(|_| {
530            Box::pin(async { Err(CamelError::ProcessorError("fail".into())) })
531        });
532
533        let svc = ErrorHandlerService::new(failing, Some(dlc), vec![]);
534        let ex = Exchange::new(Message::new("dlc-test"));
535        let result = svc.oneshot(ex).await;
536
537        assert!(result.is_ok());
538        let exchanges = dlc_received.lock().unwrap();
539        assert_eq!(
540            exchanges.len(),
541            1,
542            "DLC should have received exactly one exchange"
543        );
544        assert!(exchanges[0].has_error());
545    }
546
547    // ── TST-008: Header propagation across processors ─────────────────────────
548
549    #[tokio::test]
550    async fn tst008_header_propagation_across_processors() {
551        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message, Value};
552        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
553        use tower::ServiceExt;
554
555        let step1: BoxProcessor = BoxProcessor::from_fn(|mut ex: Exchange| {
556            Box::pin(async move {
557                ex.input
558                    .set_header("trace-id", Value::String("abc-123".into()));
559                Ok(ex)
560            })
561        });
562
563        let step2: BoxProcessor = BoxProcessor::from_fn(|mut ex: Exchange| {
564            Box::pin(async move {
565                // Modify body but leave headers intact.
566                ex.input.body = Body::Text("processed".to_string());
567                Ok(ex)
568            })
569        });
570
571        let pipeline = compose_pipeline(
572            vec![
573                CompiledStep::Process {
574                    kind_hint: SpanKindHint::Internal,
575                    processor: step1,
576                    body_contract: None,
577                    lifecycle: None,
578                    label: None,
579                },
580                CompiledStep::Process {
581                    kind_hint: SpanKindHint::Internal,
582                    processor: step2,
583                    body_contract: None,
584                    lifecycle: None,
585                    label: None,
586                },
587            ],
588            PipelineRuntimeCtx::compile_time(),
589        );
590        let ex = Exchange::new(Message::new("input"));
591        let result = pipeline.oneshot(ex).await.unwrap();
592
593        assert_eq!(
594            result.input.header("trace-id"),
595            Some(&Value::String("abc-123".into())),
596            "header should survive across processors"
597        );
598        assert_eq!(result.input.body.as_text(), Some("processed"));
599    }
600
601    // ── TST-009: Exchange body type conversion ────────────────────────────────
602
603    #[tokio::test]
604    async fn tst009_exchange_body_type_conversion() {
605        use camel_api::body::Body;
606        use camel_api::body_converter::{BodyType, convert};
607
608        // String → Bytes
609        let text_body = Body::Text("hello".to_string());
610        let bytes_body = convert(text_body, BodyType::Bytes).unwrap();
611        assert!(matches!(bytes_body, Body::Bytes(_)));
612        if let Body::Bytes(ref b) = bytes_body {
613            assert_eq!(b.as_ref(), b"hello");
614        }
615
616        // Bytes → String
617        let text_body_back = convert(bytes_body, BodyType::Text).unwrap();
618        assert!(matches!(text_body_back, Body::Text(_)));
619        assert_eq!(text_body_back.as_text(), Some("hello"));
620    }
621
622    // ── TST-010: Multicast EIP ────────────────────────────────────────────────
623
624    #[tokio::test]
625    async fn tst010_multicast_delivers_to_multiple_endpoints() {
626        use camel_api::multicast::{MulticastConfig, MulticastStrategy};
627        use camel_api::{BoxProcessor, BoxProcessorExt, Exchange, Message};
628        use camel_processor::MulticastService;
629        use std::sync::Arc;
630        use tower::ServiceExt;
631
632        let received_a = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
633        let received_b = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
634
635        let endpoint_a: BoxProcessor = {
636            let r = Arc::clone(&received_a);
637            BoxProcessor::from_fn(move |ex: Exchange| {
638                let r = Arc::clone(&r);
639                Box::pin(async move {
640                    r.lock().unwrap().push(ex.clone());
641                    Ok(ex)
642                })
643            })
644        };
645
646        let endpoint_b: BoxProcessor = {
647            let r = Arc::clone(&received_b);
648            BoxProcessor::from_fn(move |ex: Exchange| {
649                let r = Arc::clone(&r);
650                Box::pin(async move {
651                    r.lock().unwrap().push(ex.clone());
652                    Ok(ex)
653                })
654            })
655        };
656
657        let config = MulticastConfig::new().aggregation(MulticastStrategy::LastWins);
658
659        let svc = MulticastService::new(vec![endpoint_a, endpoint_b], config)
660            .expect("multicast service creation should succeed");
661        let ex = Exchange::new(Message::new("multicast-test"));
662        let _result = svc.oneshot(ex).await.unwrap();
663
664        assert_eq!(
665            received_a.lock().unwrap().len(),
666            1,
667            "endpoint A should receive exactly one exchange"
668        );
669        assert_eq!(
670            received_b.lock().unwrap().len(),
671            1,
672            "endpoint B should receive exactly one exchange"
673        );
674    }
675}