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