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_builder::{RouteBuilder, StepAccumulator};
306    use std::time::Duration;
307
308    #[tokio::test]
309    async fn builder_without_time_control_builds_context() {
310        let harness = CamelTestContext::builder()
311            .with_mock()
312            .with_timer()
313            .with_log()
314            .build()
315            .await;
316
317        assert!(harness.mock().get_endpoint("result").is_none());
318        let guard = harness.ctx().lock().await;
319        let _ = &*guard;
320    }
321
322    #[tokio::test]
323    async fn builder_with_time_control_builds_and_advances_clock() {
324        let (_harness, time) = CamelTestContext::builder()
325            .with_mock()
326            .with_timer()
327            .with_time_control()
328            .build()
329            .await;
330
331        time.advance(Duration::from_millis(1)).await;
332        time.resume();
333    }
334
335    #[tokio::test]
336    async fn stop_is_idempotent_and_shutdown_is_safe() {
337        let harness = CamelTestContext::builder().with_mock().build().await;
338        harness.stop().await;
339        harness.stop().await;
340        harness.shutdown().await;
341    }
342
343    #[tokio::test]
344    async fn add_route_returns_error_for_invalid_step_uri() {
345        let harness = CamelTestContext::builder().with_mock().build().await;
346
347        let route = RouteBuilder::from("direct:start")
348            .route_id("bad-route")
349            .to("not-a-uri")
350            .build()
351            .unwrap();
352
353        let err = harness.add_route(route).await.expect_err("must fail");
354        assert!(err.to_string().contains("Invalid") || err.to_string().contains("invalid"));
355    }
356
357    #[tokio::test]
358    async fn with_component_registers_custom_component() {
359        let harness = CamelTestContext::builder()
360            .with_component(camel_component_direct::DirectComponent::new())
361            .with_mock()
362            .build()
363            .await;
364
365        let route = RouteBuilder::from("direct:start")
366            .route_id("direct-route")
367            .to("mock:out")
368            .build()
369            .unwrap();
370
371        harness.add_route(route).await.unwrap();
372        harness.start().await;
373        harness.stop().await;
374
375        // Harness context remains accessible after lifecycle.
376        let _guard = harness.ctx().lock().await;
377    }
378
379    // ── TST-004: Route lifecycle (start/stop/restart) ─────────────────────────
380
381    #[tokio::test]
382    async fn tst004_route_lifecycle_start_stop_restart() {
383        let harness = CamelTestContext::builder()
384            .with_direct()
385            .with_mock()
386            .build()
387            .await;
388
389        let route = RouteBuilder::from("direct:lifecycle")
390            .route_id("lifecycle-route")
391            .to("mock:lifecycle-out")
392            .build()
393            .unwrap();
394
395        harness.add_route(route).await.unwrap();
396
397        // Start the context — routes transition to Started.
398        harness.start().await;
399
400        // Stop the context — routes transition to Stopped.
401        harness.stop().await;
402
403        // Restart via the underlying CamelContext to verify start-after-stop works.
404        {
405            let mut ctx = harness.ctx().lock().await;
406            ctx.start().await.expect("restart should succeed");
407            ctx.stop().await.expect("stop after restart should succeed");
408        }
409    }
410
411    // ── TST-005: Concurrent exchange processing ───────────────────────────────
412
413    #[tokio::test]
414    async fn tst005_concurrent_exchange_processing() {
415        use camel_api::{BoxProcessor, BoxProcessorExt, Exchange, Message};
416        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
417        use std::sync::Arc;
418        use std::sync::atomic::{AtomicU32, Ordering};
419        use tower::ServiceExt;
420
421        let counter = Arc::new(AtomicU32::new(0));
422        let processor: BoxProcessor = {
423            let c = Arc::clone(&counter);
424            BoxProcessor::from_fn(move |ex: Exchange| {
425                let c = Arc::clone(&c);
426                Box::pin(async move {
427                    c.fetch_add(1, Ordering::Relaxed);
428                    tokio::task::yield_now().await;
429                    Ok(ex)
430                })
431            })
432        };
433
434        let pipeline = compose_pipeline(
435            vec![CompiledStep::Process {
436                processor,
437                body_contract: None,
438                lifecycle: None,
439            }],
440            PipelineRuntimeCtx::compile_time(),
441        );
442
443        let concurrency: u32 = 10;
444        let mut handles = Vec::with_capacity(concurrency as usize);
445        for i in 0..concurrency {
446            let p = pipeline.clone();
447            handles.push(tokio::spawn(async move {
448                let ex = Exchange::new(Message::new(format!("msg-{i}")));
449                p.oneshot(ex).await.unwrap()
450            }));
451        }
452
453        for h in handles {
454            let _ = h.await.unwrap();
455        }
456
457        assert_eq!(counter.load(Ordering::Relaxed), concurrency);
458    }
459
460    // ── TST-006: Error handler invocation ─────────────────────────────────────
461
462    #[tokio::test]
463    #[allow(deprecated)]
464    async fn tst006_error_handler_invoked_on_failure() {
465        use camel_api::error_handler::ExceptionPolicy;
466        use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message};
467        use camel_processor::ErrorHandlerService;
468        use std::sync::Arc;
469        use tower::ServiceExt;
470
471        let error_received = Arc::new(std::sync::Mutex::new(false));
472        let error_received_clone = Arc::clone(&error_received);
473
474        // Handler processor that records it was called.
475        let handler: BoxProcessor = BoxProcessor::from_fn(move |ex: Exchange| {
476            let r = Arc::clone(&error_received_clone);
477            Box::pin(async move {
478                *r.lock().unwrap() = true;
479                Ok(ex)
480            })
481        });
482
483        // Inner processor that always fails.
484        let failing: BoxProcessor = BoxProcessor::from_fn(|_| {
485            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
486        });
487
488        let policy = ExceptionPolicy::new(|_| true);
489        let svc = ErrorHandlerService::new(failing, Some(handler), vec![(policy, None)]);
490        let ex = Exchange::new(Message::new("test"));
491        let result = svc.oneshot(ex).await;
492
493        assert!(result.is_ok(), "error handler should absorb the error");
494        assert!(
495            result.unwrap().has_error(),
496            "exchange should have error set"
497        );
498        assert!(
499            *error_received.lock().unwrap(),
500            "error handler processor should have been invoked"
501        );
502    }
503
504    // ── TST-007: Dead letter channel ──────────────────────────────────────────
505
506    #[tokio::test]
507    #[allow(deprecated)]
508    async fn tst007_dead_letter_channel_receives_failed_exchange() {
509        use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message};
510        use camel_processor::ErrorHandlerService;
511        use std::sync::Arc;
512        use tower::ServiceExt;
513
514        let dlc_received = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
515        let dlc_received_clone = Arc::clone(&dlc_received);
516
517        // DLC processor that captures the exchange.
518        let dlc: BoxProcessor = BoxProcessor::from_fn(move |ex: Exchange| {
519            let r = Arc::clone(&dlc_received_clone);
520            Box::pin(async move {
521                r.lock().unwrap().push(ex.clone());
522                Ok(ex)
523            })
524        });
525
526        let failing: BoxProcessor = BoxProcessor::from_fn(|_| {
527            Box::pin(async { Err(CamelError::ProcessorError("fail".into())) })
528        });
529
530        let svc = ErrorHandlerService::new(failing, Some(dlc), vec![]);
531        let ex = Exchange::new(Message::new("dlc-test"));
532        let result = svc.oneshot(ex).await;
533
534        assert!(result.is_ok());
535        let exchanges = dlc_received.lock().unwrap();
536        assert_eq!(
537            exchanges.len(),
538            1,
539            "DLC should have received exactly one exchange"
540        );
541        assert!(exchanges[0].has_error());
542    }
543
544    // ── TST-008: Header propagation across processors ─────────────────────────
545
546    #[tokio::test]
547    async fn tst008_header_propagation_across_processors() {
548        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message, Value};
549        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
550        use tower::ServiceExt;
551
552        let step1: BoxProcessor = BoxProcessor::from_fn(|mut ex: Exchange| {
553            Box::pin(async move {
554                ex.input
555                    .set_header("trace-id", Value::String("abc-123".into()));
556                Ok(ex)
557            })
558        });
559
560        let step2: BoxProcessor = BoxProcessor::from_fn(|mut ex: Exchange| {
561            Box::pin(async move {
562                // Modify body but leave headers intact.
563                ex.input.body = Body::Text("processed".to_string());
564                Ok(ex)
565            })
566        });
567
568        let pipeline = compose_pipeline(
569            vec![
570                CompiledStep::Process {
571                    processor: step1,
572                    body_contract: None,
573                    lifecycle: None,
574                },
575                CompiledStep::Process {
576                    processor: step2,
577                    body_contract: None,
578                    lifecycle: None,
579                },
580            ],
581            PipelineRuntimeCtx::compile_time(),
582        );
583        let ex = Exchange::new(Message::new("input"));
584        let result = pipeline.oneshot(ex).await.unwrap();
585
586        assert_eq!(
587            result.input.header("trace-id"),
588            Some(&Value::String("abc-123".into())),
589            "header should survive across processors"
590        );
591        assert_eq!(result.input.body.as_text(), Some("processed"));
592    }
593
594    // ── TST-009: Exchange body type conversion ────────────────────────────────
595
596    #[tokio::test]
597    async fn tst009_exchange_body_type_conversion() {
598        use camel_api::body::Body;
599        use camel_api::body_converter::{BodyType, convert};
600
601        // String → Bytes
602        let text_body = Body::Text("hello".to_string());
603        let bytes_body = convert(text_body, BodyType::Bytes).unwrap();
604        assert!(matches!(bytes_body, Body::Bytes(_)));
605        if let Body::Bytes(ref b) = bytes_body {
606            assert_eq!(b.as_ref(), b"hello");
607        }
608
609        // Bytes → String
610        let text_body_back = convert(bytes_body, BodyType::Text).unwrap();
611        assert!(matches!(text_body_back, Body::Text(_)));
612        assert_eq!(text_body_back.as_text(), Some("hello"));
613    }
614
615    // ── TST-010: Multicast EIP ────────────────────────────────────────────────
616
617    #[tokio::test]
618    async fn tst010_multicast_delivers_to_multiple_endpoints() {
619        use camel_api::multicast::{MulticastConfig, MulticastStrategy};
620        use camel_api::{BoxProcessor, BoxProcessorExt, Exchange, Message};
621        use camel_processor::MulticastService;
622        use std::sync::Arc;
623        use tower::ServiceExt;
624
625        let received_a = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
626        let received_b = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
627
628        let endpoint_a: BoxProcessor = {
629            let r = Arc::clone(&received_a);
630            BoxProcessor::from_fn(move |ex: Exchange| {
631                let r = Arc::clone(&r);
632                Box::pin(async move {
633                    r.lock().unwrap().push(ex.clone());
634                    Ok(ex)
635                })
636            })
637        };
638
639        let endpoint_b: BoxProcessor = {
640            let r = Arc::clone(&received_b);
641            BoxProcessor::from_fn(move |ex: Exchange| {
642                let r = Arc::clone(&r);
643                Box::pin(async move {
644                    r.lock().unwrap().push(ex.clone());
645                    Ok(ex)
646                })
647            })
648        };
649
650        let config = MulticastConfig::new().aggregation(MulticastStrategy::LastWins);
651
652        let svc = MulticastService::new(vec![endpoint_a, endpoint_b], config)
653            .expect("multicast service creation should succeed");
654        let ex = Exchange::new(Message::new("multicast-test"));
655        let _result = svc.oneshot(ex).await.unwrap();
656
657        assert_eq!(
658            received_a.lock().unwrap().len(),
659            1,
660            "endpoint A should receive exactly one exchange"
661        );
662        assert_eq!(
663            received_b.lock().unwrap().len(),
664            1,
665            "endpoint B should receive exactly one exchange"
666        );
667    }
668}