Skip to main content

camel_component_api/
consumer.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use tokio::sync::{mpsc, oneshot, watch};
5use tokio::task::JoinHandle;
6use tokio_util::sync::CancellationToken;
7
8use camel_api::security_policy::SecurityPolicy;
9use camel_api::{CamelError, Exchange};
10use camel_auth::{CredentialSource, TokenAuthenticator};
11
12/// A message sent from a consumer to the route pipeline.
13///
14/// Fire-and-forget exchanges use `reply_tx = None`.
15/// Request-reply exchanges (e.g. `direct:`) provide a `reply_tx` so the
16/// pipeline result can be sent back to the consumer.
17pub struct ExchangeEnvelope {
18    pub exchange: Exchange,
19    pub reply_tx: Option<oneshot::Sender<Result<Exchange, CamelError>>>,
20}
21
22/// Declares when the runtime may consider a Consumer "started".
23///
24/// `Immediate` (default) preserves the classic fire-and-forget semantics:
25/// `spawn_consumer_task` returns as soon as the consumer task is spawned,
26/// matching the behaviour of timer, file, direct and similar polling
27/// consumers whose `start()` IS the lifetime loop.
28///
29/// `Explicit` is for resource-binding consumers (HTTP, WebSocket, …) whose
30/// `start()` returns control only after the resource (e.g. `TcpListener`)
31/// is bound and ready. The consumer MUST call `ConsumerContext::mark_ready`
32/// after a successful bind so the runtime can await readiness and propagate
33/// pre-ready `start()` errors as proper startup failures.
34///
35/// Adding this as a default-returning trait method keeps every existing
36/// `Consumer` impl backward compatible.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38#[non_exhaustive]
39pub enum ConsumerStartupMode {
40    /// Consumer's `start()` IS the lifetime loop. The runtime treats the
41    /// consumer as ready the moment `start()` is invoked. (Default)
42    #[default]
43    Immediate,
44    /// Consumer binds/registers a resource inside `start()` and signals
45    /// readiness explicitly via `ConsumerContext::mark_ready()`.
46    Explicit,
47}
48
49/// Internal state of a [`StartupSignal`].
50#[derive(Clone, Debug)]
51enum StartupState {
52    /// Consumer has not yet signalled readiness or failure.
53    Pending,
54    /// Consumer signalled readiness via `mark_ready()`.
55    Ready,
56    /// Consumer's `start()` returned an `Err` before signalling readiness.
57    Failed(String),
58}
59
60/// Shared handle used by a Consumer to signal readiness (or failure) to the
61/// runtime's [`StartupReceiver`].
62///
63/// Constructed in a pair via [`StartupSignal::pair`]. The signal is held by
64/// the consumer side (via [`ConsumerContext`]); the receiver is returned to
65/// the route controller.
66#[derive(Clone)]
67pub struct StartupSignal {
68    tx: watch::Sender<StartupState>,
69}
70
71impl StartupSignal {
72    /// Create a `(signal, receiver)` pair seeded in the `Pending` state.
73    pub fn pair() -> (Self, StartupReceiver) {
74        let (tx, rx) = watch::channel(StartupState::Pending);
75        (Self { tx }, StartupReceiver { rx })
76    }
77
78    /// Mark the consumer as ready. Idempotent — subsequent calls are no-ops
79    /// once the state has transitioned out of `Pending`.
80    ///
81    /// Returns `true` if this call transitioned `Pending → Ready`, `false`
82    /// if the state was already `Ready` or `Failed`. The runtime uses the
83    /// return value to detect Explicit consumers that returned `Ok` from
84    /// `start()` without calling `mark_ready` (a contract violation that
85    /// would hang the controller without the defensive fallback in
86    /// `spawn_consumer_task`).
87    pub fn mark_ready(&self) -> bool {
88        self.tx.send_if_modified(|s| {
89            if matches!(*s, StartupState::Pending) {
90                *s = StartupState::Ready;
91                true
92            } else {
93                false
94            }
95        })
96    }
97
98    /// Mark the consumer's startup as failed with `err`. Idempotent.
99    pub fn mark_failed(&self, err: String) {
100        self.tx.send_if_modified(|s| {
101            if matches!(*s, StartupState::Pending) {
102                *s = StartupState::Failed(err);
103                true
104            } else {
105                false
106            }
107        });
108    }
109}
110
111impl std::fmt::Debug for StartupSignal {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("StartupSignal")
114            .field("state", &self.tx.borrow())
115            .finish()
116    }
117}
118
119/// Receiver half of the consumer startup handshake. Resolves once the
120/// consumer calls [`ConsumerContext::mark_ready`] (Ok) or its `start()`
121/// returns an `Err` first (Err).
122///
123/// For [`ConsumerStartupMode::Immediate`] consumers the receiver is
124/// pre-resolved at construction time (see [`StartupReceiver::immediate`]).
125pub struct StartupReceiver {
126    rx: watch::Receiver<StartupState>,
127}
128
129impl StartupReceiver {
130    /// Construct a receiver that is already resolved as `Ok`. Used for
131    /// [`ConsumerStartupMode::Immediate`] consumers so the controller can
132    /// uniformly `await` every receiver without changing behaviour.
133    pub fn immediate() -> Self {
134        let (tx, rx) = watch::channel(StartupState::Ready);
135        // Drop the sender — state is fixed at Ready. Receiver will never
136        // observe a closure error since it already holds Ready.
137        let _ = tx;
138        Self { rx }
139    }
140
141    /// Wait for the consumer to become ready or fail. Resolves:
142    /// - `Ok(())` if the consumer signalled readiness.
143    /// - `Err(CamelError::RouteError(_))` if the consumer's `start()`
144    ///   returned an error before signalling readiness.
145    /// - `Err(CamelError::RouteError(_))` if the signal sender was dropped
146    ///   without either transition (programming-contract violation).
147    pub async fn await_ready(mut self) -> Result<(), CamelError> {
148        loop {
149            match &*self.rx.borrow() {
150                StartupState::Pending => {}
151                StartupState::Ready => return Ok(()),
152                StartupState::Failed(msg) => {
153                    return Err(CamelError::RouteError(msg.clone()));
154                }
155            }
156            if self.rx.changed().await.is_err() {
157                return Err(CamelError::RouteError(
158                    "consumer startup signal dropped without resolving".to_string(),
159                ));
160            }
161        }
162    }
163}
164
165impl std::fmt::Debug for StartupReceiver {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("StartupReceiver")
168            .field("state", &self.rx.borrow())
169            .finish()
170    }
171}
172
173/// Context provided to a Consumer, allowing it to send exchanges into the route.
174#[derive(Clone)]
175pub struct ConsumerContext {
176    sender: mpsc::Sender<ExchangeEnvelope>,
177    cancel_token: CancellationToken,
178    route_id: String,
179    startup: StartupSignal,
180}
181
182impl ConsumerContext {
183    /// Create a new consumer context wrapping the given channel sender.
184    ///
185    /// The `route_id` identifies the route this consumer is bound to, enabling
186    /// ADR-0012 per-route metrics and health observations.
187    ///
188    /// The startup signal defaults to a fresh `Pending` pair; the consumer
189    /// can call [`Self::mark_ready`] once it has bound its resource. For
190    /// [`ConsumerStartupMode::Immediate`] consumers the runtime ignores
191    /// the signal (it constructs an already-resolved receiver instead).
192    pub fn new(
193        sender: mpsc::Sender<ExchangeEnvelope>,
194        cancel_token: CancellationToken,
195        route_id: String,
196    ) -> Self {
197        let (startup, _unused_receiver) = StartupSignal::pair();
198        // The receiver is dropped here: `spawn_consumer_task` constructs its
199        // own `(signal, receiver)` pair and replaces this one via
200        // `with_startup` so the controller holds the matching receiver.
201        let _ = _unused_receiver;
202        Self {
203            sender,
204            cancel_token,
205            route_id,
206            startup,
207        }
208    }
209
210    /// Replace the startup signal carried by this context. Used by
211    /// `spawn_consumer_task` to install the signal whose matching receiver
212    /// is returned to the route controller.
213    pub fn with_startup(mut self, startup: StartupSignal) -> Self {
214        self.startup = startup;
215        self
216    }
217
218    /// Returns a clone of the internal [`StartupSignal`] so callers (e.g.
219    /// `spawn_consumer_task`) can drive failure propagation independently of
220    /// the consumer's own `mark_ready()` call.
221    pub fn startup_signal(&self) -> StartupSignal {
222        self.startup.clone()
223    }
224
225    /// Mark this consumer's startup as complete. Only meaningful for
226    /// [`ConsumerStartupMode::Explicit`] consumers — `Immediate` consumers
227    /// never need to call this because the runtime resolves their startup
228    /// receiver at construction time.
229    ///
230    /// Idempotent.
231    pub fn mark_ready(&self) {
232        let _ = self.startup.mark_ready();
233    }
234
235    /// Mark this consumer's startup as FAILED with `err`. Only meaningful for
236    /// [`ConsumerStartupMode::Explicit`] consumers whose readiness is gated on
237    /// an asynchronous event that may never arrive (e.g. Kafka partition
238    /// assignment). Calling this resolves the runtime's startup await with a
239    /// startup error instead of hanging.
240    ///
241    /// Idempotent — the first transition out of `Pending` wins, so a later
242    /// `mark_ready()` cannot override an earlier `mark_failed()` and vice
243    /// versa.
244    pub fn mark_failed(&self, err: String) {
245        self.startup.mark_failed(err);
246    }
247
248    /// Returns a future that resolves when shutdown is requested.
249    /// Use in `tokio::select!` inside consumer loops.
250    pub async fn cancelled(&self) {
251        self.cancel_token.cancelled().await
252    }
253
254    /// Returns true if shutdown has been requested.
255    pub fn is_cancelled(&self) -> bool {
256        self.cancel_token.is_cancelled()
257    }
258
259    /// Returns the route_id this consumer is bound to.
260    ///
261    /// Available for ADR-0012 metrics/health calls that require a route_id
262    /// (categories (b′), (e), (g)). Set at construction time by the route
263    /// controller when spawning the consumer task.
264    pub fn route_id(&self) -> &str {
265        &self.route_id
266    }
267
268    /// Returns a clone of the `CancellationToken`.
269    ///
270    /// Useful for consumers that spawn per-request tasks and need to propagate
271    /// shutdown to each task. See `HttpConsumer` for an example.
272    pub fn cancel_token(&self) -> CancellationToken {
273        self.cancel_token.clone()
274    }
275
276    /// Returns a clone of the channel sender for manual exchange submission.
277    ///
278    /// Useful for consumers that spawn per-request tasks (e.g., `HttpConsumer`)
279    /// where each task independently sends exchanges into the pipeline.
280    /// For simple consumers, prefer `send()` or `send_and_wait()` instead.
281    pub fn sender(&self) -> mpsc::Sender<ExchangeEnvelope> {
282        self.sender.clone()
283    }
284
285    /// Send an exchange into the route pipeline (fire-and-forget).
286    pub async fn send(&self, exchange: Exchange) -> Result<(), CamelError> {
287        self.sender
288            .send(ExchangeEnvelope {
289                exchange,
290                reply_tx: None,
291            })
292            .await
293            .map_err(|_| CamelError::ChannelClosed)
294    }
295
296    /// Send an exchange and wait for the pipeline result (request-reply).
297    ///
298    /// Returns `Ok(exchange)` on success or `Err(e)` if the pipeline failed
299    /// without an error handler absorbing the error.
300    pub async fn send_and_wait(&self, exchange: Exchange) -> Result<Exchange, CamelError> {
301        let (reply_tx, reply_rx) = oneshot::channel();
302        self.sender
303            .send(ExchangeEnvelope {
304                exchange,
305                reply_tx: Some(reply_tx),
306            })
307            .await
308            .map_err(|_| CamelError::ChannelClosed)?;
309        reply_rx.await.map_err(|_| CamelError::ChannelClosed)?
310    }
311}
312
313/// Security context passed to a consumer before `start()`.
314///
315/// Carries the `SecurityPolicy` and `TokenAuthenticator` from the route
316/// controller so consumers (e.g. WebSocket) can register auth state
317/// before accepting connections.
318pub struct SecurityContext {
319    pub policy: Arc<dyn SecurityPolicy>,
320    pub authenticator: Arc<dyn TokenAuthenticator>,
321    pub credential_sources: Vec<CredentialSource>,
322}
323
324impl SecurityContext {
325    pub fn new(
326        policy: impl SecurityPolicy + 'static,
327        authenticator: Arc<dyn TokenAuthenticator>,
328    ) -> Self {
329        Self {
330            policy: Arc::new(policy),
331            authenticator,
332            credential_sources: vec![CredentialSource::AuthorizationHeader],
333        }
334    }
335
336    pub fn from_arc(
337        policy: Arc<dyn SecurityPolicy>,
338        authenticator: Arc<dyn TokenAuthenticator>,
339    ) -> Self {
340        Self {
341            policy,
342            authenticator,
343            credential_sources: vec![CredentialSource::AuthorizationHeader],
344        }
345    }
346
347    pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
348        self.credential_sources = sources;
349        self
350    }
351}
352
353impl Clone for SecurityContext {
354    fn clone(&self) -> Self {
355        Self {
356            policy: Arc::clone(&self.policy),
357            authenticator: Arc::clone(&self.authenticator),
358            credential_sources: self.credential_sources.clone(),
359        }
360    }
361}
362
363impl std::fmt::Debug for SecurityContext {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        f.debug_struct("SecurityContext")
366            .field("policy", &"<SecurityPolicy>")
367            .field("authenticator", &"<TokenAuthenticator>")
368            .field("credential_sources", &self.credential_sources)
369            .finish()
370    }
371}
372
373/// How a consumer's exchanges should be processed by the pipeline.
374#[derive(Debug, Clone, PartialEq, Eq)]
375#[non_exhaustive]
376pub enum ConcurrencyModel {
377    /// Exchanges are processed one at a time, in order. Default for polling
378    /// consumers (timer, file) and synchronous consumers (direct).
379    Sequential,
380    /// Exchanges are processed concurrently via `tokio::spawn`. Optional
381    /// semaphore limit (`max`). `None` means unbounded (channel buffer is
382    /// the only backpressure).
383    Concurrent { max: Option<usize> },
384}
385
386/// A Consumer receives data from an external system and submits Exchanges
387/// to the Route's Pipeline via the [`ConsumerContext`].
388///
389/// # Shutdown Contract
390///
391/// The Runtime guarantees the following lifecycle:
392///
393/// 1. `start()` is called once. The Runtime spawns a task that owns the Consumer.
394/// 2. On route stop, the Runtime cancels the [`ConsumerContext`] cancel token.
395/// 3. The spawned task calls `stop()` on ALL exit paths after `start()` succeeds
396///    (clean exit, crash, cancellation, natural completion).
397/// 4. `background_task_handle()` is a supervision hook for crash propagation (ADR-0007),
398///    NOT the shutdown API.
399///
400/// Component authors MUST ensure:
401///
402/// - `stop()` cancels all component-owned inner tasks and cleans up registrations/resources.
403/// - If inner tasks use a private `CancellationToken`, `stop()` MUST cancel it.
404/// - Best practice: inner tasks should use the [`ConsumerContext`] cancel token (or a child)
405///   so they respond to runtime shutdown without waiting for `stop()`.
406/// - If using a private token, `stop()` must cancel it to ensure prompt cleanup.
407/// - `background_task_handle()` returns the `JoinHandle` of the primary background task,
408///   if any. The Runtime monitors this handle for unexpected exits (crash propagation).
409#[async_trait]
410pub trait Consumer: Send + Sync {
411    /// Start consuming messages, sending them through the provided context.
412    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError>;
413
414    /// Stop consuming messages and clean up all resources.
415    ///
416    /// Called by the Runtime on every exit path after `start()` succeeds.
417    /// See the [Shutdown Contract](#shutdown-contract) above.
418    async fn stop(&mut self) -> Result<(), CamelError>;
419
420    /// Temporarily suspend consuming messages without fully stopping.
421    ///
422    /// Default: no-op, returns `Ok(())`.
423    async fn suspend(&self) -> Result<(), CamelError> {
424        Ok(())
425    }
426
427    /// Resume consuming after a previous suspension.
428    ///
429    /// Default: no-op, returns `Ok(())`.
430    async fn resume(&self) -> Result<(), CamelError> {
431        Ok(())
432    }
433
434    /// Declares this consumer's natural concurrency model.
435    ///
436    /// The runtime uses this to decide whether to process exchanges
437    /// sequentially or spawn per-exchange. Consumers that accept inbound
438    /// connections (HTTP, WebSocket, Kafka) should override this to return
439    /// `ConcurrencyModel::Concurrent`.
440    ///
441    /// Default: `Sequential`.
442    fn concurrency_model(&self) -> ConcurrencyModel {
443        ConcurrencyModel::Sequential
444    }
445
446    /// Declares how the runtime should wait for this consumer's startup.
447    ///
448    /// - [`ConsumerStartupMode::Immediate`] (default): the consumer's
449    ///   `start()` IS the lifetime loop. The runtime treats the route as
450    ///   started as soon as `start()` is invoked, preserving the existing
451    ///   fire-and-forget semantics for timer/file/direct/… consumers.
452    /// - [`ConsumerStartupMode::Explicit`]: the consumer binds/registers a
453    ///   resource inside `start()` and MUST call
454    ///   [`ConsumerContext::mark_ready`] after a successful bind. The
455    ///   runtime awaits this signal (or an early `start()` error) before
456    ///   treating the route as started, so HTTP/WebSocket listeners fail
457    ///   fast when the bind fails instead of crashing the background task.
458    ///
459    /// Default: `Immediate`.
460    fn startup_mode(&self) -> ConsumerStartupMode {
461        ConsumerStartupMode::Immediate
462    }
463
464    /// Return a handle to the consumer's long-running background task so the
465    /// runtime can monitor it for unexpected exits after `start()` returns `Ok`.
466    ///
467    /// Default: `None` — consumer's work completes entirely within `start()`.
468    /// Override: return `Some(handle)` if `start()` spawns a detached task.
469    ///
470    /// **Contract:** the task must observe `ConsumerContext::cancelled()` so
471    /// runtime shutdown is distinguishable from crash exits.
472    ///
473    /// This method is called at most once; implementations should use `.take()`
474    /// to transfer ownership of the handle.
475    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
476        None
477    }
478
479    /// Set the security context for this consumer.
480    ///
481    /// Called by the route controller before `start()` so the consumer
482    /// can register auth state (e.g. WebSocket auth in `WsAppState`).
483    ///
484    /// Default: no-op, returns `Ok(())`.
485    fn set_security_context(&mut self, _ctx: SecurityContext) {}
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn consumer_context_exposes_route_id() {
494        let (tx, _rx) = mpsc::channel(1);
495        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "test-route".to_string());
496        assert_eq!(ctx.route_id(), "test-route");
497    }
498
499    #[tokio::test]
500    async fn test_consumer_context_cancelled() {
501        let (tx, _rx) = mpsc::channel(16);
502        let token = CancellationToken::new();
503        let ctx = ConsumerContext::new(tx, token.clone(), "test-route".to_string());
504
505        assert!(!ctx.is_cancelled());
506        token.cancel();
507        ctx.cancelled().await;
508        assert!(ctx.is_cancelled());
509    }
510
511    #[test]
512    fn test_concurrency_model_default_is_sequential() {
513        use super::ConcurrencyModel;
514
515        struct DummyConsumer;
516
517        #[async_trait::async_trait]
518        impl super::Consumer for DummyConsumer {
519            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
520                Ok(())
521            }
522            async fn stop(&mut self) -> Result<(), CamelError> {
523                Ok(())
524            }
525        }
526
527        let consumer = DummyConsumer;
528        assert_eq!(consumer.concurrency_model(), ConcurrencyModel::Sequential);
529    }
530
531    #[test]
532    fn test_concurrency_model_concurrent_override() {
533        use super::ConcurrencyModel;
534
535        struct ConcurrentConsumer;
536
537        #[async_trait::async_trait]
538        impl super::Consumer for ConcurrentConsumer {
539            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
540                Ok(())
541            }
542            async fn stop(&mut self) -> Result<(), CamelError> {
543                Ok(())
544            }
545            fn concurrency_model(&self) -> ConcurrencyModel {
546                ConcurrencyModel::Concurrent { max: Some(16) }
547            }
548        }
549
550        let consumer = ConcurrentConsumer;
551        assert_eq!(
552            consumer.concurrency_model(),
553            ConcurrencyModel::Concurrent { max: Some(16) }
554        );
555    }
556
557    // --- ConsumerStartupMode tests ---
558
559    #[test]
560    fn test_default_startup_mode_is_immediate() {
561        struct DummyConsumer;
562
563        #[async_trait::async_trait]
564        impl super::Consumer for DummyConsumer {
565            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
566                Ok(())
567            }
568            async fn stop(&mut self) -> Result<(), CamelError> {
569                Ok(())
570            }
571        }
572
573        let consumer = DummyConsumer;
574        assert_eq!(
575            consumer.startup_mode(),
576            super::ConsumerStartupMode::Immediate
577        );
578    }
579
580    #[test]
581    fn test_startup_mode_explicit_override() {
582        struct ExplicitConsumer;
583
584        #[async_trait::async_trait]
585        impl super::Consumer for ExplicitConsumer {
586            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
587                Ok(())
588            }
589            async fn stop(&mut self) -> Result<(), CamelError> {
590                Ok(())
591            }
592            fn startup_mode(&self) -> super::ConsumerStartupMode {
593                super::ConsumerStartupMode::Explicit
594            }
595        }
596
597        let consumer = ExplicitConsumer;
598        assert_eq!(
599            consumer.startup_mode(),
600            super::ConsumerStartupMode::Explicit
601        );
602    }
603
604    #[tokio::test]
605    async fn test_startup_signal_mark_ready_resolves_receiver_ok() {
606        let (signal, receiver) = StartupSignal::pair();
607        // Not yet signalled — receiver should still be pending.
608        assert!(matches!(*receiver.rx.borrow(), StartupState::Pending));
609
610        // Mark ready — receiver must observe Ok.
611        signal.mark_ready();
612        let result = receiver.await_ready().await;
613        assert!(result.is_ok(), "expected Ok after mark_ready");
614    }
615
616    #[tokio::test]
617    async fn test_startup_signal_mark_failed_propagates_error() {
618        let (signal, receiver) = StartupSignal::pair();
619        signal.mark_failed("bind failed".to_string());
620        let err = receiver
621            .await_ready()
622            .await
623            .expect_err("expected Err after mark_failed");
624        match err {
625            CamelError::RouteError(msg) => assert!(msg.contains("bind failed")),
626            other => panic!("expected RouteError, got {other:?}"),
627        }
628    }
629
630    #[tokio::test]
631    async fn test_startup_signal_idempotent_first_wins() {
632        let (signal, receiver) = StartupSignal::pair();
633        signal.mark_ready();
634        // mark_failed after mark_ready must NOT override.
635        signal.mark_failed("late failure".to_string());
636        let result = receiver.await_ready().await;
637        assert!(result.is_ok(), "first transition (Ready) wins");
638    }
639
640    #[tokio::test]
641    async fn test_startup_receiver_immediate_is_pre_resolved_ok() {
642        let receiver = StartupReceiver::immediate();
643        let result = receiver.await_ready().await;
644        assert!(result.is_ok(), "immediate receiver must resolve Ok");
645    }
646
647    #[tokio::test]
648    async fn test_consumer_context_mark_ready_drives_signal() {
649        let (tx, _rx) = mpsc::channel(1);
650        let ctx = ConsumerContext::new(
651            tx,
652            CancellationToken::new(),
653            "startup-test-route".to_string(),
654        );
655        let (signal, receiver) = StartupSignal::pair();
656        let ctx = ctx.with_startup(signal);
657        ctx.mark_ready();
658        let result = receiver.await_ready().await;
659        assert!(result.is_ok(), "ctx.mark_ready must resolve the receiver");
660    }
661
662    #[tokio::test]
663    async fn test_consumer_context_mark_failed_drives_signal() {
664        let (tx, _rx) = mpsc::channel(1);
665        let ctx = ConsumerContext::new(
666            tx,
667            CancellationToken::new(),
668            "startup-fail-route".to_string(),
669        );
670        let (signal, receiver) = StartupSignal::pair();
671        let ctx = ctx.with_startup(signal);
672        ctx.mark_failed("assignment window elapsed".to_string());
673        let err = receiver
674            .await_ready()
675            .await
676            .expect_err("ctx.mark_failed must resolve the receiver as Err");
677        match err {
678            CamelError::RouteError(msg) => assert!(msg.contains("assignment window elapsed")),
679            other => panic!("expected RouteError, got {other:?}"),
680        }
681    }
682
683    #[tokio::test]
684    async fn test_startup_receiver_dropped_sender_returns_err() {
685        // Build a signal/receiver pair and drop the signal without ever
686        // transitioning — receiver must surface a contract-violation error.
687        let (_signal, receiver) = StartupSignal::pair();
688        drop(_signal);
689        let err = receiver
690            .await_ready()
691            .await
692            .expect_err("dropped signal must surface as Err");
693        match err {
694            CamelError::RouteError(msg) => assert!(msg.contains("dropped")),
695            other => panic!("expected RouteError, got {other:?}"),
696        }
697    }
698
699    #[tokio::test]
700    async fn test_consumer_default_suspend_resume() {
701        struct DummyConsumer;
702
703        #[async_trait::async_trait]
704        impl super::Consumer for DummyConsumer {
705            async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
706                Ok(())
707            }
708            async fn stop(&mut self) -> Result<(), CamelError> {
709                Ok(())
710            }
711        }
712
713        let consumer = DummyConsumer;
714        assert!(consumer.suspend().await.is_ok());
715        assert!(consumer.resume().await.is_ok());
716    }
717
718    // --- SecurityContext tests ---
719
720    struct StubPolicy;
721
722    #[async_trait::async_trait]
723    impl SecurityPolicy for StubPolicy {
724        async fn evaluate(
725            &self,
726            _exchange: &mut Exchange,
727        ) -> Result<camel_api::security_policy::AuthorizationDecision, CamelError> {
728            Ok(camel_api::security_policy::AuthorizationDecision::Granted {
729                principal: camel_api::security_policy::Principal {
730                    subject: "stub".into(),
731                    issuer: "stub".into(),
732                    audience: vec![],
733                    scopes: vec![],
734                    roles: vec![],
735                    claims: serde_json::json!({}),
736                },
737            })
738        }
739    }
740
741    struct StubAuthenticator;
742
743    #[async_trait::async_trait]
744    impl camel_auth::TokenAuthenticator for StubAuthenticator {
745        async fn authenticate_bearer(
746            &self,
747            _token: &str,
748        ) -> Result<camel_api::security_policy::Principal, CamelError> {
749            Ok(camel_api::security_policy::Principal {
750                subject: "stub".into(),
751                issuer: "stub".into(),
752                audience: vec![],
753                scopes: vec![],
754                roles: vec![],
755                claims: serde_json::json!({}),
756            })
757        }
758    }
759
760    #[test]
761    fn test_security_context_new() {
762        let ctx = SecurityContext::new(StubPolicy, Arc::new(StubAuthenticator));
763        assert!(Arc::strong_count(&ctx.policy) == 1);
764        assert!(Arc::strong_count(&ctx.authenticator) == 1);
765        assert_eq!(
766            ctx.credential_sources,
767            vec![camel_auth::CredentialSource::AuthorizationHeader]
768        );
769    }
770
771    #[test]
772    fn test_security_context_from_arc() {
773        let policy: Arc<dyn SecurityPolicy> = Arc::new(StubPolicy);
774        let authenticator: Arc<dyn camel_auth::TokenAuthenticator> = Arc::new(StubAuthenticator);
775        let ctx = SecurityContext::from_arc(Arc::clone(&policy), Arc::clone(&authenticator));
776        assert!(Arc::ptr_eq(&ctx.policy, &policy));
777        assert!(Arc::ptr_eq(&ctx.authenticator, &authenticator));
778        assert_eq!(
779            ctx.credential_sources,
780            vec![camel_auth::CredentialSource::AuthorizationHeader]
781        );
782    }
783
784    #[test]
785    fn test_security_context_clone_independent() {
786        let ctx = SecurityContext::new(StubPolicy, Arc::new(StubAuthenticator));
787        let cloned = ctx.clone();
788        assert!(Arc::ptr_eq(&ctx.policy, &cloned.policy));
789        assert!(Arc::ptr_eq(&ctx.authenticator, &cloned.authenticator));
790        assert_eq!(ctx.credential_sources, cloned.credential_sources);
791    }
792
793    #[test]
794    fn test_security_context_debug_redacts_traits() {
795        let ctx = SecurityContext::new(StubPolicy, Arc::new(StubAuthenticator));
796        let debug_str = format!("{ctx:?}");
797        assert!(debug_str.contains("<SecurityPolicy>"));
798        assert!(debug_str.contains("<TokenAuthenticator>"));
799        assert!(debug_str.contains("credential_sources"));
800    }
801
802    #[test]
803    fn test_security_context_with_credential_sources() {
804        let ctx = SecurityContext::new(StubPolicy, Arc::new(StubAuthenticator))
805            .with_credential_sources(vec![
806                camel_auth::CredentialSource::Cookie {
807                    name: "session".into(),
808                },
809                camel_auth::CredentialSource::AuthorizationHeader,
810            ]);
811        assert_eq!(ctx.credential_sources.len(), 2);
812        assert!(matches!(
813            &ctx.credential_sources[0],
814            camel_auth::CredentialSource::Cookie { .. }
815        ));
816    }
817}