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            /// Register `TimerComponent`.
88            pub fn with_timer(mut self) -> Self {
89                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
90                    ctx.register_component(TimerComponent::new());
91                }));
92                self
93            }
94
95            /// Register `LogComponent`.
96            pub fn with_log(mut self) -> Self {
97                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
98                    ctx.register_component(LogComponent::new());
99                }));
100                self
101            }
102
103            /// Register `DirectComponent`.
104            pub fn with_direct(mut self) -> Self {
105                self.registrations.push(Box::new(|ctx: &mut CamelContext| {
106                    ctx.register_component(DirectComponent::new());
107                }));
108                self
109            }
110
111            /// Register any component that implements the `Component` trait.
112            pub fn with_component<C>(mut self, component: C) -> Self
113            where
114                C: camel_component_api::Component + 'static,
115            {
116                self.registrations
117                    .push(Box::new(move |ctx: &mut CamelContext| {
118                        ctx.register_component(component);
119                    }));
120                self
121            }
122        }
123    };
124}
125
126impl_builder_methods!(NoTimeControl);
127impl_builder_methods!(WithTimeControl);
128
129// ---------------------------------------------------------------------------
130// Internal build helper
131// ---------------------------------------------------------------------------
132
133async fn build_context(registrations: Vec<Registration>, mock: MockComponent) -> CamelTestContext {
134    let mut ctx = CamelContext::builder().build().await.unwrap();
135
136    // MockComponent is always registered.
137    ctx.register_component(mock.clone());
138
139    // Run caller-declared registrations.
140    for register in registrations {
141        register(&mut ctx);
142    }
143
144    let ctx = Arc::new(Mutex::new(ctx));
145    let stopped = Arc::new(AtomicBool::new(false));
146
147    CamelTestContext {
148        ctx: ctx.clone(),
149        mock,
150        stopped: stopped.clone(),
151        _guard: TestGuard { ctx, stopped },
152    }
153}
154
155// ---------------------------------------------------------------------------
156// TestGuard — automatic stop on drop
157// ---------------------------------------------------------------------------
158
159pub(crate) struct TestGuard {
160    ctx: Arc<Mutex<CamelContext>>,
161    stopped: Arc<AtomicBool>,
162}
163
164impl Drop for TestGuard {
165    fn drop(&mut self) {
166        if self.stopped.swap(true, Ordering::SeqCst) {
167            // Already stopped explicitly — nothing to do.
168            return;
169        }
170        let ctx = self.ctx.clone();
171        if let Ok(handle) = tokio::runtime::Handle::try_current() {
172            match handle.runtime_flavor() {
173                tokio::runtime::RuntimeFlavor::MultiThread => {
174                    // Deterministic cleanup when blocking is supported.
175                    tokio::task::block_in_place(|| {
176                        handle.block_on(async move {
177                            let mut ctx = ctx.lock().await;
178                            let _ = ctx.stop().await;
179                        });
180                    });
181                }
182                tokio::runtime::RuntimeFlavor::CurrentThread => {
183                    // Best effort fallback for current-thread runtimes where
184                    // blocking in Drop is not possible.
185                    handle.spawn(async move {
186                        let mut ctx = ctx.lock().await;
187                        let _ = ctx.stop().await;
188                    });
189                }
190                _ => {
191                    handle.spawn(async move {
192                        let mut ctx = ctx.lock().await;
193                        let _ = ctx.stop().await;
194                    });
195                }
196            }
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// CamelTestContext
203// ---------------------------------------------------------------------------
204
205/// Test harness that wraps [`CamelContext`] with teardown helpers,
206/// pre-registered components, and a shared [`MockComponent`] accessor.
207///
208/// # Example
209///
210/// ```no_run
211/// # use camel_test::CamelTestContext;
212/// # use std::time::Duration;
213/// #[tokio::test]
214/// async fn my_test() {
215///     let h = CamelTestContext::builder()
216///         .with_timer()
217///         .with_mock()
218///         .build()
219///         .await;
220///
221///     // add routes, start, assert…
222///     h.stop().await; // deterministic teardown
223///     // Drop also performs best-effort cleanup if omitted
224/// }
225/// ```
226pub struct CamelTestContext {
227    ctx: Arc<Mutex<CamelContext>>,
228    mock: MockComponent,
229    stopped: Arc<AtomicBool>,
230    _guard: TestGuard,
231}
232
233impl CamelTestContext {
234    /// Obtain a builder.
235    pub fn builder() -> CamelTestContextBuilder<NoTimeControl> {
236        CamelTestContextBuilder::new()
237    }
238
239    /// Add a route definition to the context.
240    pub async fn add_route(&self, route: RouteDefinition) -> Result<(), CamelError> {
241        let ctx = self.ctx.lock().await;
242        ctx.add_route_definition(route).await
243    }
244
245    /// Start all routes.
246    pub async fn start(&self) {
247        let mut ctx = self.ctx.lock().await;
248        ctx.start().await.expect("CamelTestContext: start failed");
249    }
250
251    /// Stop all routes explicitly. Safe to call before the harness is dropped —
252    /// subsequent drop is a no-op.
253    pub async fn stop(&self) {
254        if self.stopped.swap(true, Ordering::SeqCst) {
255            return; // already stopped
256        }
257        let mut ctx = self.ctx.lock().await;
258        ctx.stop().await.expect("CamelTestContext: stop failed");
259    }
260
261    /// Consume the harness and stop routes deterministically.
262    pub async fn shutdown(self) {
263        self.stop().await;
264    }
265
266    /// Access the shared mock component for assertions.
267    pub fn mock(&self) -> &MockComponent {
268        &self.mock
269    }
270
271    /// Escape hatch: access the underlying [`CamelContext`] directly.
272    pub fn ctx(&self) -> &Arc<Mutex<CamelContext>> {
273        &self.ctx
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use camel_builder::{RouteBuilder, StepAccumulator};
281    use std::time::Duration;
282
283    #[tokio::test]
284    async fn builder_without_time_control_builds_context() {
285        let harness = CamelTestContext::builder()
286            .with_mock()
287            .with_timer()
288            .with_log()
289            .build()
290            .await;
291
292        assert!(harness.mock().get_endpoint("result").is_none());
293        let guard = harness.ctx().lock().await;
294        let _ = &*guard;
295    }
296
297    #[tokio::test]
298    async fn builder_with_time_control_builds_and_advances_clock() {
299        let (_harness, time) = CamelTestContext::builder()
300            .with_mock()
301            .with_timer()
302            .with_time_control()
303            .build()
304            .await;
305
306        time.advance(Duration::from_millis(1)).await;
307        time.resume();
308    }
309
310    #[tokio::test]
311    async fn stop_is_idempotent_and_shutdown_is_safe() {
312        let harness = CamelTestContext::builder().with_mock().build().await;
313        harness.stop().await;
314        harness.stop().await;
315        harness.shutdown().await;
316    }
317
318    #[tokio::test]
319    async fn add_route_returns_error_for_invalid_step_uri() {
320        let harness = CamelTestContext::builder().with_mock().build().await;
321
322        let route = RouteBuilder::from("direct:start")
323            .route_id("bad-route")
324            .to("not-a-uri")
325            .build()
326            .unwrap();
327
328        let err = harness.add_route(route).await.expect_err("must fail");
329        assert!(err.to_string().contains("Invalid") || err.to_string().contains("invalid"));
330    }
331
332    #[tokio::test]
333    async fn with_component_registers_custom_component() {
334        let harness = CamelTestContext::builder()
335            .with_component(camel_component_direct::DirectComponent::new())
336            .with_mock()
337            .build()
338            .await;
339
340        let route = RouteBuilder::from("direct:start")
341            .route_id("direct-route")
342            .to("mock:out")
343            .build()
344            .unwrap();
345
346        harness.add_route(route).await.unwrap();
347        harness.start().await;
348        harness.stop().await;
349
350        // Harness context remains accessible after lifecycle.
351        let _guard = harness.ctx().lock().await;
352    }
353}