Skip to main content

camel_component_timer/
lib.rs

1//! Timer component for rust-camel — fires `Exchange` events on configurable period, delay, and repeat-count schedules.
2//!
3//! Main types: `TimerComponent`, `TimerConsumer`, `TimerConfig`, `TimerEndpoint`.
4//! URI format: `timer:name?period=1000&delay=0&repeatCount=0`.
5//!
6//! # Features
7//!
8//! - **fixedRate**: When enabled, uses skip-missed-tick semantics instead of burst.
9//! - **includeMetadata**: Controls whether timer metadata headers are included in exchanges.
10//! - Double-start protection via `AtomicBool` guard.
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Duration;
14
15use async_trait::async_trait;
16use chrono::Utc;
17use tokio::time;
18use tracing::debug;
19
20use camel_api::component_metadata::{
21    ComponentCapabilities, ComponentMetadata, OptionKind, UriOption,
22};
23use camel_component_api::UriConfig;
24use camel_component_api::{BoxProcessor, CamelError, Exchange, Message};
25use camel_component_api::{Component, Consumer, ConsumerContext, Endpoint, ProducerContext};
26
27// ---------------------------------------------------------------------------
28// TimerConfig
29// ---------------------------------------------------------------------------
30
31/// Configuration parsed from a timer URI.
32///
33/// Format: `timer:name?period=1000&delay=0&repeatCount=0&fixedRate=false&includeMetadata=true`
34#[derive(Debug, Clone, UriConfig)]
35#[uri_scheme = "timer"]
36#[uri_config(skip_impl, crate = "camel_component_api")]
37pub struct TimerConfig {
38    /// Timer name (the path portion of the URI).
39    pub name: String,
40
41    /// Interval between ticks (milliseconds). Default: 1000.
42    #[allow(dead_code)] // Used by macro-generated Duration conversion
43    #[uri_param(name = "period", default = "1000")]
44    period_ms: u64,
45
46    /// Converted Duration for period.
47    pub period: Duration,
48
49    /// Initial delay before the first tick (milliseconds). Default: 0.
50    #[allow(dead_code)] // Used by macro-generated Duration conversion
51    #[uri_param(name = "delay", default = "0")]
52    delay_ms: u64,
53
54    /// Converted Duration for delay.
55    pub delay: Duration,
56
57    /// Maximum number of ticks. `None` means infinite.
58    #[uri_param(name = "repeatCount")]
59    pub repeat_count: Option<u32>,
60
61    /// When true, use fixed-rate semantics (skip missed ticks).
62    /// When false (default), use burst semantics (fire all missed ticks immediately).
63    #[uri_param(name = "fixedRate", default = "false")]
64    pub fixed_rate: bool,
65
66    /// When true (default), include metadata headers (CamelTimerFiredTime,
67    /// CamelMessageTimestamp, CamelTimerName) in each exchange.
68    /// When false, send a minimal exchange without metadata headers.
69    #[uri_param(name = "includeMetadata", default = "true")]
70    pub include_metadata: bool,
71}
72
73// Inherent validate — callable as TimerConfig::validate(&self)
74impl TimerConfig {
75    /// Validate the configuration without consuming self.
76    pub fn validate(&self) -> Result<(), CamelError> {
77        if self.name.trim().is_empty() {
78            return Err(CamelError::InvalidUri(
79                "timer name must not be empty".to_string(),
80            ));
81        }
82        if self.period.is_zero() {
83            return Err(CamelError::InvalidUri(
84                "timer period must be greater than 0".to_string(),
85            ));
86        }
87        Ok(())
88    }
89}
90
91impl UriConfig for TimerConfig {
92    fn scheme() -> &'static str {
93        "timer"
94    }
95
96    fn from_uri(uri: &str) -> Result<Self, CamelError> {
97        let parts = camel_component_api::parse_uri(uri)?;
98        Self::from_components(parts)
99    }
100
101    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
102        let config = Self::parse_uri_components(parts)?;
103        TimerConfig::validate(&config)?;
104        Ok(config)
105    }
106
107    fn validate(self) -> Result<Self, CamelError> {
108        // Delegate to the inherent validate(&self)
109        TimerConfig::validate(&self)?;
110        Ok(self)
111    }
112}
113
114// ---------------------------------------------------------------------------
115// TimerComponent
116// ---------------------------------------------------------------------------
117
118/// The Timer component produces exchanges on a periodic interval.
119pub struct TimerComponent;
120
121impl TimerComponent {
122    pub fn new() -> Self {
123        Self
124    }
125}
126
127impl Default for TimerComponent {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl Component for TimerComponent {
134    fn scheme(&self) -> &str {
135        "timer"
136    }
137
138    fn metadata(&self) -> ComponentMetadata {
139        ComponentMetadata {
140            scheme: "timer".to_string(),
141            version: env!("CARGO_PKG_VERSION").to_string(),
142            description: "Generates timer events at fixed intervals".to_string(),
143            uri_syntax: "timer:name?period=1000&delay=0&repeatCount=0".to_string(),
144            capabilities: ComponentCapabilities {
145                supports_consumer: true,
146                ..Default::default()
147            },
148            uri_options: vec![
149                UriOption::new(
150                    "period",
151                    "Interval between ticks in milliseconds",
152                    OptionKind::Int,
153                )
154                .with_default("1000"),
155                UriOption::new(
156                    "delay",
157                    "Initial delay before first tick in milliseconds",
158                    OptionKind::Int,
159                )
160                .with_default("0"),
161                UriOption::new(
162                    "repeatCount",
163                    "Maximum number of ticks. 0 or omitted means infinite",
164                    OptionKind::Int,
165                ),
166                UriOption::new(
167                    "fixedRate",
168                    "true=skip missed ticks, false=fire all missed",
169                    OptionKind::Bool,
170                )
171                .with_default("false"),
172                UriOption::new(
173                    "includeMetadata",
174                    "Include CamelTimer* headers in exchanges",
175                    OptionKind::Bool,
176                )
177                .with_default("true"),
178            ],
179            ..ComponentMetadata::minimal("timer")
180        }
181    }
182
183    fn create_endpoint(
184        &self,
185        uri: &str,
186        _ctx: &dyn camel_component_api::ComponentContext,
187    ) -> Result<Box<dyn Endpoint>, CamelError> {
188        let config = TimerConfig::from_uri(uri)?;
189        Ok(Box::new(TimerEndpoint {
190            uri: uri.to_string(),
191            config,
192        }))
193    }
194}
195
196// ---------------------------------------------------------------------------
197// TimerEndpoint
198// ---------------------------------------------------------------------------
199
200pub struct TimerEndpoint {
201    uri: String,
202    config: TimerConfig,
203}
204
205impl Endpoint for TimerEndpoint {
206    fn uri(&self) -> &str {
207        &self.uri
208    }
209
210    fn create_consumer(
211        &self,
212        _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
213    ) -> Result<Box<dyn Consumer>, CamelError> {
214        Ok(Box::new(TimerConsumer {
215            config: self.config.clone(),
216            started: AtomicBool::new(false),
217        }))
218    }
219
220    fn create_producer(
221        &self,
222        _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
223        _ctx: &ProducerContext,
224    ) -> Result<BoxProcessor, CamelError> {
225        Err(CamelError::EndpointCreationFailed(
226            "timer endpoint does not support producers".to_string(),
227        ))
228    }
229}
230
231// ---------------------------------------------------------------------------
232// TimerConsumer
233// ---------------------------------------------------------------------------
234
235pub struct TimerConsumer {
236    config: TimerConfig,
237    /// Guard against double-start (TIMER-003).
238    started: AtomicBool,
239}
240
241#[async_trait]
242impl Consumer for TimerConsumer {
243    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
244        // TIMER-003: Guard against double-start
245        self.started
246            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
247            .map_err(|_| {
248                CamelError::EndpointCreationFailed("timer consumer already started".to_string())
249            })?;
250
251        TimerConfig::validate(&self.config)?;
252        let config = self.config.clone();
253        let cancel_token = context.cancel_token();
254
255        // Initial delay (cancellable so shutdown isn't blocked by long delays)
256        if !config.delay.is_zero() {
257            tokio::select! {
258                _ = time::sleep(config.delay) => {}
259                _ = cancel_token.cancelled() => {
260                    debug!(timer = config.name, "Timer cancelled during initial delay");
261                    self.started.store(false, Ordering::SeqCst);
262                    return Ok(());
263                }
264            }
265        }
266
267        // If repeat_count is explicitly 0, fire zero times — stop immediately.
268        if config.repeat_count == Some(0) {
269            debug!(timer = config.name, "repeat_count=0, timer will not fire");
270            self.started.store(false, Ordering::SeqCst);
271            return Ok(());
272        }
273
274        let mut interval = time::interval(config.period);
275
276        // TIMER-002: fixedRate controls missed-tick behavior
277        if config.fixed_rate {
278            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
279        } else {
280            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst);
281        }
282
283        let mut count: u32 = 0;
284
285        loop {
286            tokio::select! {
287                _ = cancel_token.cancelled() => {
288                    debug!(timer = config.name, "Timer received cancellation, stopping");
289                    break;
290                }
291                _ = interval.tick() => {
292                    count += 1;
293
294                    debug!(timer = config.name, count, "Timer tick");
295
296                    let mut exchange = Exchange::new(Message::new(format!(
297                        "timer://{} tick #{}",
298                        config.name, count
299                    )));
300
301                    // TIMER-005 & TIMER-006: include metadata headers when enabled
302                    if config.include_metadata {
303                        exchange.input.set_header(
304                            "CamelTimerName",
305                            serde_json::Value::String(config.name.clone()),
306                        );
307                        exchange
308                            .input
309                            .set_header("CamelTimerCounter", serde_json::Value::Number(count.into()));
310
311                        // TIMER-005: CamelTimerFiredTime (ISO-8601) and CamelMessageTimestamp (epoch millis)
312                        let now = Utc::now();
313                        exchange.input.set_header(
314                            "CamelTimerFiredTime",
315                            serde_json::Value::String(now.to_rfc3339()),
316                        );
317                        exchange.input.set_header(
318                            "CamelMessageTimestamp",
319                            serde_json::Value::Number(
320                                now.timestamp_millis().into(),
321                            ),
322                        );
323                    }
324
325                    if context.send(exchange).await.is_err() {
326                        // Channel closed, route was stopped
327                        break;
328                    }
329
330                    if let Some(max) = config.repeat_count
331                        && count >= max
332                    {
333                        break;
334                    }
335                }
336            }
337        }
338
339        // Reset started flag so consumer can be restarted after stop
340        self.started.store(false, Ordering::SeqCst);
341        Ok(())
342    }
343
344    async fn stop(&mut self) -> Result<(), CamelError> {
345        self.started.store(false, Ordering::SeqCst);
346        debug!(timer = self.config.name, "timer consumer stopped");
347        Ok(())
348    }
349}
350
351impl TimerConsumer {
352    /// Test helper: pre-set the started flag to simulate an already-running consumer.
353    #[cfg(test)]
354    pub(crate) fn mark_started_for_test(&self) {
355        self.started.store(true, Ordering::SeqCst);
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use camel_component_api::test_support::PanicRuntimeObservability;
366    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
367        std::sync::Arc::new(PanicRuntimeObservability)
368    }
369
370    use super::*;
371    use camel_component_api::NoOpComponentContext;
372
373    #[test]
374    fn test_zero_period_rejected() {
375        let result = TimerConfig::from_uri("timer:tick?period=0");
376        assert!(result.is_err(), "period=0 should be rejected");
377        let err_msg = result.unwrap_err().to_string();
378        assert!(err_msg.contains("period"), "error should mention 'period'");
379    }
380
381    #[test]
382    fn test_timer_empty_name_rejected() {
383        let result = TimerConfig::from_uri("timer:");
384        assert!(result.is_err());
385        let err = result.unwrap_err().to_string();
386        assert!(err.contains("must not be empty"), "unexpected error: {err}");
387    }
388
389    #[test]
390    fn test_timer_config_defaults() {
391        let config = TimerConfig::from_uri("timer:tick").unwrap();
392        assert_eq!(config.name, "tick");
393        assert_eq!(config.period, Duration::from_millis(1000));
394        assert_eq!(config.delay, Duration::from_millis(0));
395        assert_eq!(config.repeat_count, None);
396    }
397
398    #[test]
399    fn test_timer_config_with_params() {
400        let config =
401            TimerConfig::from_uri("timer:myTimer?period=500&delay=100&repeatCount=5").unwrap();
402        assert_eq!(config.name, "myTimer");
403        assert_eq!(config.period, Duration::from_millis(500));
404        assert_eq!(config.delay, Duration::from_millis(100));
405        assert_eq!(config.repeat_count, Some(5));
406    }
407
408    #[test]
409    fn test_timer_config_wrong_scheme() {
410        let result = TimerConfig::from_uri("log:info");
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn test_timer_component_scheme() {
416        let component = TimerComponent::new();
417        assert_eq!(component.scheme(), "timer");
418    }
419
420    #[test]
421    fn test_timer_component_creates_endpoint() {
422        let component = TimerComponent::new();
423        let endpoint = component.create_endpoint("timer:tick?period=1000", &NoOpComponentContext);
424        assert!(endpoint.is_ok());
425    }
426
427    #[test]
428    fn test_timer_endpoint_no_producer() {
429        let ctx = ProducerContext::new();
430        let component = TimerComponent::new();
431        let endpoint = component
432            .create_endpoint("timer:tick", &NoOpComponentContext)
433            .unwrap();
434        let producer = endpoint.create_producer(rt(), &ctx);
435        assert!(producer.is_err());
436    }
437
438    #[test]
439    fn test_rejects_empty_timer_name() {
440        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
441        cfg.name = "".into();
442        assert!(cfg.validate().is_err());
443    }
444
445    #[test]
446    fn test_rejects_zero_period() {
447        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
448        cfg.period = Duration::ZERO;
449        assert!(cfg.validate().is_err());
450    }
451
452    #[test]
453    fn test_valid_config_passes() {
454        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
455        cfg.name = "myTimer".into();
456        cfg.period = Duration::from_millis(1000);
457        assert!(cfg.validate().is_ok());
458    }
459
460    #[tokio::test]
461    async fn test_repeat_count_zero_fires_never() {
462        let component = TimerComponent::new();
463        let endpoint = component
464            .create_endpoint(
465                "timer:zero-test?period=50&repeatCount=0",
466                &NoOpComponentContext,
467            )
468            .unwrap();
469        let mut consumer = endpoint.create_consumer(rt()).unwrap();
470
471        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
472        let ctx = ConsumerContext::new(
473            tx,
474            tokio_util::sync::CancellationToken::new(),
475            "timer-test-route".to_string(),
476        );
477
478        // Start the consumer (spawns internally, returns immediately)
479        consumer.start(ctx).await.unwrap();
480
481        // Wait longer than the period — no messages should arrive
482        tokio::time::sleep(Duration::from_millis(200)).await;
483
484        // Drain any pending messages
485        let mut count = 0;
486        while rx.try_recv().is_ok() {
487            count += 1;
488        }
489        assert_eq!(
490            count, 0,
491            "repeat_count=0 should produce zero fires, got {count}"
492        );
493
494        // Clean up
495        consumer.stop().await.unwrap();
496    }
497
498    #[tokio::test]
499    async fn test_timer_consumer_fires() {
500        let component = TimerComponent::new();
501        let endpoint = component
502            .create_endpoint("timer:test?period=50&repeatCount=3", &NoOpComponentContext)
503            .unwrap();
504        let mut consumer = endpoint.create_consumer(rt()).unwrap();
505
506        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
507        let ctx = ConsumerContext::new(
508            tx,
509            tokio_util::sync::CancellationToken::new(),
510            "timer-test-route".to_string(),
511        );
512
513        // Run consumer in background
514        tokio::spawn(async move {
515            consumer.start(ctx).await.unwrap();
516        });
517
518        // Collect exchanges
519        let mut received = Vec::new();
520        while let Some(envelope) = rx.recv().await {
521            received.push(envelope.exchange);
522            if received.len() == 3 {
523                break;
524            }
525        }
526
527        assert_eq!(received.len(), 3);
528
529        // Verify headers on the first exchange
530        let first = &received[0];
531        assert_eq!(
532            first.input.header("CamelTimerName"),
533            Some(&serde_json::Value::String("test".into()))
534        );
535        assert_eq!(
536            first.input.header("CamelTimerCounter"),
537            Some(&serde_json::Value::Number(1.into()))
538        );
539    }
540
541    #[tokio::test]
542    async fn test_timer_consumer_respects_cancellation() {
543        use tokio_util::sync::CancellationToken;
544
545        let token = CancellationToken::new();
546        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
547        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
548
549        let mut consumer = TimerConsumer {
550            config: TimerConfig::from_uri("timer:cancel-test?period=50").unwrap(),
551            started: AtomicBool::new(false),
552        };
553
554        let handle = tokio::spawn(async move {
555            consumer.start(ctx).await.unwrap();
556        });
557
558        // Let it fire a few times
559        tokio::time::sleep(Duration::from_millis(180)).await;
560        token.cancel();
561
562        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
563        assert!(
564            result.is_ok(),
565            "Consumer should have stopped after cancellation"
566        );
567
568        let mut count = 0;
569        while rx.try_recv().is_ok() {
570            count += 1;
571        }
572        assert!(
573            count >= 2,
574            "Expected at least 2 exchanges before cancellation, got {count}"
575        );
576    }
577
578    #[tokio::test]
579    async fn test_timer_consumer_stop_shuts_down() {
580        let component = TimerComponent::new();
581        let endpoint = component
582            .create_endpoint("timer:stop-test?period=50", &NoOpComponentContext)
583            .unwrap();
584        let mut consumer = endpoint.create_consumer(rt()).unwrap();
585
586        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
587        let token = tokio_util::sync::CancellationToken::new();
588        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
589
590        // Run consumer in background (start() blocks until cancelled)
591        tokio::spawn(async move {
592            consumer.start(ctx).await.unwrap();
593        });
594
595        // Let it fire a few times
596        tokio::time::sleep(Duration::from_millis(180)).await;
597
598        // Drain any pending exchanges
599        let mut count = 0;
600        while rx.try_recv().is_ok() {
601            count += 1;
602        }
603        assert!(count >= 2, "Expected at least 2 exchanges, got {count}");
604
605        // Cancel the token to stop the consumer
606        token.cancel();
607    }
608
609    // TIMER-002: fixedRate config round-trip
610    #[test]
611    fn test_fixed_rate_default_is_false() {
612        let config = TimerConfig::from_uri("timer:tick").unwrap();
613        assert!(!config.fixed_rate, "fixedRate should default to false");
614    }
615
616    #[test]
617    fn test_fixed_rate_parsed_from_uri() {
618        let config = TimerConfig::from_uri("timer:tick?fixedRate=true").unwrap();
619        assert!(
620            config.fixed_rate,
621            "fixedRate should be true when set in URI"
622        );
623    }
624
625    // TIMER-003: double-start guard
626    #[tokio::test]
627    async fn test_double_start_returns_error() {
628        let component = TimerComponent::new();
629        let endpoint = component
630            .create_endpoint(
631                "timer:double?period=50&repeatCount=2",
632                &NoOpComponentContext,
633            )
634            .unwrap(); // allow-unwrap: test setup
635
636        let mut consumer = TimerConsumer {
637            config: TimerConfig {
638                name: "double-test".to_string(),
639                period: Duration::from_millis(100),
640                period_ms: 100,
641                delay: Duration::ZERO,
642                delay_ms: 0,
643                repeat_count: None,
644                fixed_rate: false,
645                include_metadata: true,
646            },
647            started: AtomicBool::new(false),
648        };
649
650        // Simulate the consumer already being started by setting the flag.
651        consumer.mark_started_for_test();
652
653        let (tx, _rx) = tokio::sync::mpsc::channel(16);
654        let cancel_token = tokio_util::sync::CancellationToken::new();
655        let ctx = ConsumerContext::new(tx, cancel_token.clone(), "timer-test-route".to_string());
656
657        // Second start on an already-started consumer must return an error.
658        let result = consumer.start(ctx).await;
659        assert!(result.is_err(), "expected double-start to return Err");
660        let err_str = format!("{:?}", result.unwrap_err());
661        assert!(
662            err_str.contains("already started"),
663            "unexpected error: {err_str}"
664        );
665
666        drop(endpoint); // suppress unused-variable warning
667    }
668
669    // TIMER-005: CamelTimerFiredTime and CamelMessageTimestamp headers
670    #[tokio::test]
671    async fn test_timer_fired_time_and_message_timestamp_headers() {
672        let component = TimerComponent::new();
673        let endpoint = component
674            .create_endpoint(
675                "timer:headers?period=50&repeatCount=1",
676                &NoOpComponentContext,
677            )
678            .unwrap();
679        let mut consumer = endpoint.create_consumer(rt()).unwrap();
680
681        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
682        let ctx = ConsumerContext::new(
683            tx,
684            tokio_util::sync::CancellationToken::new(),
685            "timer-test-route".to_string(),
686        );
687
688        tokio::spawn(async move {
689            consumer.start(ctx).await.unwrap();
690        });
691
692        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
693            .await
694            .expect("should receive exchange")
695            .expect("envelope should exist");
696
697        let exchange = envelope.exchange;
698
699        // CamelTimerFiredTime should be an ISO-8601 string
700        let fired_time = exchange
701            .input
702            .header("CamelTimerFiredTime")
703            .expect("CamelTimerFiredTime header should be present");
704        assert!(
705            fired_time.is_string(),
706            "CamelTimerFiredTime should be a string"
707        );
708        let fired_str = fired_time.as_str().unwrap();
709        // Should parse as ISO-8601 / RFC 3339
710        assert!(
711            chrono::DateTime::parse_from_rfc3339(fired_str).is_ok(),
712            "CamelTimerFiredTime should be valid RFC 3339: {fired_str}"
713        );
714
715        // CamelMessageTimestamp should be a number (epoch millis)
716        let msg_ts = exchange
717            .input
718            .header("CamelMessageTimestamp")
719            .expect("CamelMessageTimestamp header should be present");
720        assert!(
721            msg_ts.is_number(),
722            "CamelMessageTimestamp should be a number"
723        );
724        let ts_millis = msg_ts.as_i64().expect("should be i64");
725        assert!(ts_millis > 0, "timestamp should be positive");
726    }
727
728    #[test]
729    fn test_timer_fired_time_header_format() {
730        // Verify the format independently
731        let now = chrono::Utc::now();
732        let rfc = now.to_rfc3339();
733        assert!(chrono::DateTime::parse_from_rfc3339(&rfc).is_ok());
734        let millis = now.timestamp_millis();
735        assert!(millis > 0);
736    }
737
738    // TIMER-006: includeMetadata option
739    #[test]
740    fn test_include_metadata_default_is_true() {
741        let config = TimerConfig::from_uri("timer:tick").unwrap();
742        assert!(
743            config.include_metadata,
744            "includeMetadata should default to true"
745        );
746    }
747
748    #[test]
749    fn test_include_metadata_false_from_uri() {
750        let config = TimerConfig::from_uri("timer:tick?includeMetadata=false").unwrap();
751        assert!(
752            !config.include_metadata,
753            "includeMetadata should be false when set in URI"
754        );
755    }
756
757    #[tokio::test]
758    async fn test_include_metadata_false_omits_headers() {
759        let component = TimerComponent::new();
760        let endpoint = component
761            .create_endpoint(
762                "timer:minimal?period=50&repeatCount=1&includeMetadata=false",
763                &NoOpComponentContext,
764            )
765            .unwrap();
766        let mut consumer = endpoint.create_consumer(rt()).unwrap();
767
768        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
769        let ctx = ConsumerContext::new(
770            tx,
771            tokio_util::sync::CancellationToken::new(),
772            "timer-test-route".to_string(),
773        );
774
775        tokio::spawn(async move {
776            consumer.start(ctx).await.unwrap();
777        });
778
779        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
780            .await
781            .expect("should receive exchange")
782            .expect("envelope should exist");
783
784        let exchange = envelope.exchange;
785
786        // No metadata headers should be present
787        assert!(
788            exchange.input.header("CamelTimerName").is_none(),
789            "CamelTimerName should not be present when includeMetadata=false"
790        );
791        assert!(
792            exchange.input.header("CamelTimerCounter").is_none(),
793            "CamelTimerCounter should not be present when includeMetadata=false"
794        );
795        assert!(
796            exchange.input.header("CamelTimerFiredTime").is_none(),
797            "CamelTimerFiredTime should not be present when includeMetadata=false"
798        );
799        assert!(
800            exchange.input.header("CamelMessageTimestamp").is_none(),
801            "CamelMessageTimestamp should not be present when includeMetadata=false"
802        );
803    }
804
805    #[tokio::test]
806    async fn test_include_metadata_true_includes_all_headers() {
807        let component = TimerComponent::new();
808        let endpoint = component
809            .create_endpoint(
810                "timer:full?period=50&repeatCount=1&includeMetadata=true",
811                &NoOpComponentContext,
812            )
813            .unwrap();
814        let mut consumer = endpoint.create_consumer(rt()).unwrap();
815
816        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
817        let ctx = ConsumerContext::new(
818            tx,
819            tokio_util::sync::CancellationToken::new(),
820            "timer-test-route".to_string(),
821        );
822
823        tokio::spawn(async move {
824            consumer.start(ctx).await.unwrap();
825        });
826
827        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
828            .await
829            .expect("should receive exchange")
830            .expect("envelope should exist");
831
832        let exchange = envelope.exchange;
833
834        assert!(exchange.input.header("CamelTimerName").is_some());
835        assert!(exchange.input.header("CamelTimerCounter").is_some());
836        assert!(exchange.input.header("CamelTimerFiredTime").is_some());
837        assert!(exchange.input.header("CamelMessageTimestamp").is_some());
838    }
839
840    // TIMER-011: TimerEndpoint and TimerConsumer are pub
841    #[test]
842    fn test_timer_endpoint_is_pub() {
843        let component = TimerComponent::new();
844        let endpoint = component
845            .create_endpoint("timer:pub-test", &NoOpComponentContext)
846            .unwrap();
847        assert_eq!(endpoint.uri(), "timer:pub-test");
848    }
849}