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