Skip to main content

camel_component_api/
consumer.rs

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