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            runtime: rt,
182        }))
183    }
184
185    fn create_producer(
186        &self,
187        _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
188        _ctx: &ProducerContext,
189    ) -> Result<BoxProcessor, CamelError> {
190        Err(CamelError::EndpointCreationFailed(
191            "timer endpoint does not support producers".to_string(),
192        ))
193    }
194}
195
196// ---------------------------------------------------------------------------
197// TimerConsumer
198// ---------------------------------------------------------------------------
199
200pub struct TimerConsumer {
201    config: TimerConfig,
202    /// Guard against double-start (TIMER-003).
203    started: AtomicBool,
204    /// Used for `rt.metrics().increment_errors(...)` calls per ADR-0012
205    /// (`b-prime:timer:fire-send` on tick fire-send failure).
206    runtime: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
207}
208
209#[async_trait]
210impl Consumer for TimerConsumer {
211    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
212        // TIMER-003: Guard against double-start
213        self.started
214            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
215            .map_err(|_| {
216                CamelError::EndpointCreationFailed("timer consumer already started".to_string())
217            })?;
218
219        TimerConfig::validate(&self.config)?;
220        let config = self.config.clone();
221        let cancel_token = context.cancel_token();
222
223        // Initial delay (cancellable so shutdown isn't blocked by long delays)
224        if !config.delay.is_zero() {
225            tokio::select! {
226                _ = time::sleep(config.delay) => {}
227                _ = cancel_token.cancelled() => {
228                    debug!(timer = config.name, "Timer cancelled during initial delay");
229                    self.started.store(false, Ordering::SeqCst);
230                    return Ok(());
231                }
232            }
233        }
234
235        // If repeat_count is explicitly 0, fire zero times — stop immediately.
236        if config.repeat_count == Some(0) {
237            debug!(timer = config.name, "repeat_count=0, timer will not fire");
238            self.started.store(false, Ordering::SeqCst);
239            return Ok(());
240        }
241
242        let mut interval = time::interval(config.period);
243
244        // TIMER-002: fixedRate controls missed-tick behavior
245        if config.fixed_rate {
246            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
247        } else {
248            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst);
249        }
250
251        let mut count: u32 = 0;
252
253        loop {
254            tokio::select! {
255                _ = cancel_token.cancelled() => {
256                    debug!(timer = config.name, "Timer received cancellation, stopping");
257                    break;
258                }
259                _ = interval.tick() => {
260                    count += 1;
261
262                    debug!(timer = config.name, count, "Timer tick");
263
264                    let mut exchange = Exchange::new(Message::new(format!(
265                        "timer://{} tick #{}",
266                        config.name, count
267                    )));
268
269                    // TIMER-005 & TIMER-006: include metadata headers when enabled
270                    if config.include_metadata {
271                        exchange.input.set_header(
272                            "CamelTimerName",
273                            serde_json::Value::String(config.name.clone()),
274                        );
275                        exchange
276                            .input
277                            .set_header("CamelTimerCounter", serde_json::Value::Number(count.into()));
278
279                        // TIMER-005: CamelTimerFiredTime (ISO-8601) and CamelMessageTimestamp (epoch millis)
280                        let now = Utc::now();
281                        exchange.input.set_header(
282                            "CamelTimerFiredTime",
283                            serde_json::Value::String(now.to_rfc3339()),
284                        );
285                        exchange.input.set_header(
286                            "CamelMessageTimestamp",
287                            serde_json::Value::Number(
288                                now.timestamp_millis().into(),
289                            ),
290                        );
291                    }
292
293                    if context.send(exchange).await.is_err() {
294                        // (category b′ per ADR-0012: locally terminal fire
295                        // send — the route channel is closed and the loop
296                        // exits, so this is the only signal.)
297                        self.runtime
298                            .metrics()
299                            .increment_errors(context.route_id(), "b-prime:timer:fire-send");
300                        // Channel closed, route was stopped
301                        break;
302                    }
303
304                    if let Some(max) = config.repeat_count
305                        && count >= max
306                    {
307                        break;
308                    }
309                }
310            }
311        }
312
313        // Reset started flag so consumer can be restarted after stop
314        self.started.store(false, Ordering::SeqCst);
315        Ok(())
316    }
317
318    async fn stop(&mut self) -> Result<(), CamelError> {
319        self.started.store(false, Ordering::SeqCst);
320        debug!(timer = self.config.name, "timer consumer stopped");
321        Ok(())
322    }
323}
324
325impl TimerConsumer {
326    /// Test helper: pre-set the started flag to simulate an already-running consumer.
327    #[cfg(test)]
328    pub(crate) fn mark_started_for_test(&self) {
329        self.started.store(true, Ordering::SeqCst);
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[cfg(test)]
338mod tests {
339    use std::sync::{Arc, Mutex};
340
341    use camel_component_api::test_support::PanicRuntimeObservability;
342    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
343        std::sync::Arc::new(PanicRuntimeObservability)
344    }
345
346    use super::*;
347    use camel_component_api::NoOpComponentContext;
348
349    #[test]
350    fn test_zero_period_rejected() {
351        let result = TimerConfig::from_uri("timer:tick?period=0");
352        assert!(result.is_err(), "period=0 should be rejected");
353        let err_msg = result.unwrap_err().to_string();
354        assert!(err_msg.contains("period"), "error should mention 'period'");
355    }
356
357    #[test]
358    fn test_timer_empty_name_rejected() {
359        let result = TimerConfig::from_uri("timer:");
360        assert!(result.is_err());
361        let err = result.unwrap_err().to_string();
362        assert!(err.contains("must not be empty"), "unexpected error: {err}");
363    }
364
365    #[test]
366    fn test_timer_config_defaults() {
367        let config = TimerConfig::from_uri("timer:tick").unwrap();
368        assert_eq!(config.name, "tick");
369        assert_eq!(config.period, Duration::from_millis(1000));
370        assert_eq!(config.delay, Duration::from_millis(0));
371        assert_eq!(config.repeat_count, None);
372    }
373
374    #[test]
375    fn test_timer_config_with_params() {
376        let config =
377            TimerConfig::from_uri("timer:myTimer?period=500&delay=100&repeatCount=5").unwrap();
378        assert_eq!(config.name, "myTimer");
379        assert_eq!(config.period, Duration::from_millis(500));
380        assert_eq!(config.delay, Duration::from_millis(100));
381        assert_eq!(config.repeat_count, Some(5));
382    }
383
384    #[test]
385    fn test_timer_config_wrong_scheme() {
386        let result = TimerConfig::from_uri("log:info");
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_timer_component_scheme() {
392        let component = TimerComponent::new();
393        assert_eq!(component.scheme(), "timer");
394    }
395
396    #[test]
397    fn test_timer_component_creates_endpoint() {
398        let component = TimerComponent::new();
399        let endpoint = component.create_endpoint("timer:tick?period=1000", &NoOpComponentContext);
400        assert!(endpoint.is_ok());
401    }
402
403    #[test]
404    fn test_timer_endpoint_no_producer() {
405        let ctx = ProducerContext::new();
406        let component = TimerComponent::new();
407        let endpoint = component
408            .create_endpoint("timer:tick", &NoOpComponentContext)
409            .unwrap();
410        let producer = endpoint.create_producer(rt(), &ctx);
411        assert!(producer.is_err());
412    }
413
414    #[test]
415    fn test_rejects_empty_timer_name() {
416        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
417        cfg.name = "".into();
418        assert!(cfg.validate().is_err());
419    }
420
421    #[test]
422    fn test_rejects_zero_period() {
423        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
424        cfg.period = Duration::ZERO;
425        assert!(cfg.validate().is_err());
426    }
427
428    #[test]
429    fn test_valid_config_passes() {
430        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
431        cfg.name = "myTimer".into();
432        cfg.period = Duration::from_millis(1000);
433        assert!(cfg.validate().is_ok());
434    }
435
436    #[tokio::test]
437    async fn test_repeat_count_zero_fires_never() {
438        let component = TimerComponent::new();
439        let endpoint = component
440            .create_endpoint(
441                "timer:zero-test?period=50&repeatCount=0",
442                &NoOpComponentContext,
443            )
444            .unwrap();
445        let mut consumer = endpoint.create_consumer(rt()).unwrap();
446
447        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
448        let ctx = ConsumerContext::new(
449            tx,
450            tokio_util::sync::CancellationToken::new(),
451            "timer-test-route".to_string(),
452        );
453
454        // Start the consumer (spawns internally, returns immediately)
455        consumer.start(ctx).await.unwrap();
456
457        // Wait longer than the period — no messages should arrive
458        tokio::time::sleep(Duration::from_millis(200)).await;
459
460        // Drain any pending messages
461        let mut count = 0;
462        while rx.try_recv().is_ok() {
463            count += 1;
464        }
465        assert_eq!(
466            count, 0,
467            "repeat_count=0 should produce zero fires, got {count}"
468        );
469
470        // Clean up
471        consumer.stop().await.unwrap();
472    }
473
474    #[tokio::test]
475    async fn test_repeat_count_omitted_fires_indefinitely() {
476        use tokio_util::sync::CancellationToken;
477
478        let token = CancellationToken::new();
479        let (tx, mut rx) = tokio::sync::mpsc::channel(32);
480        let ctx = ConsumerContext::new(tx, token.clone(), "timer-infinite-route".to_string());
481
482        // No repeatCount in the URI — must fire indefinitely until cancelled.
483        let mut consumer = TimerConsumer {
484            config: TimerConfig::from_uri("timer:infinite-test?period=20").unwrap(),
485            started: AtomicBool::new(false),
486            runtime: rt(),
487        };
488
489        let handle = tokio::spawn(async move {
490            consumer.start(ctx).await.unwrap();
491        });
492
493        // Window long enough that any finite repeat_count <= 5 would have
494        // stopped well before the cap we assert (6 fires at 20ms = ~120ms).
495        tokio::time::sleep(Duration::from_millis(200)).await;
496        token.cancel();
497        let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
498
499        let mut count = 0;
500        while rx.try_recv().is_ok() {
501            count += 1;
502        }
503        assert!(
504            count >= 6,
505            "Omitted repeatCount should fire indefinitely; expected >= 6 fires in window, got {count}"
506        );
507    }
508
509    #[tokio::test]
510    async fn test_timer_consumer_fires() {
511        let component = TimerComponent::new();
512        let endpoint = component
513            .create_endpoint("timer:test?period=50&repeatCount=3", &NoOpComponentContext)
514            .unwrap();
515        let mut consumer = endpoint.create_consumer(rt()).unwrap();
516
517        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
518        let ctx = ConsumerContext::new(
519            tx,
520            tokio_util::sync::CancellationToken::new(),
521            "timer-test-route".to_string(),
522        );
523
524        // Run consumer in background
525        tokio::spawn(async move {
526            consumer.start(ctx).await.unwrap();
527        });
528
529        // Collect exchanges
530        let mut received = Vec::new();
531        loop {
532            match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
533                Ok(Some(envelope)) => {
534                    received.push(envelope.exchange);
535                    if received.len() == 3 {
536                        break;
537                    }
538                }
539                Ok(None) => break,
540                Err(_) => panic!("timer rx drain stalled past 2s"),
541            }
542        }
543
544        assert_eq!(received.len(), 3);
545
546        // Verify headers on the first exchange
547        let first = &received[0];
548        assert_eq!(
549            first.input.header("CamelTimerName"),
550            Some(&serde_json::Value::String("test".into()))
551        );
552        assert_eq!(
553            first.input.header("CamelTimerCounter"),
554            Some(&serde_json::Value::Number(1.into()))
555        );
556    }
557
558    #[tokio::test]
559    async fn test_timer_consumer_respects_cancellation() {
560        use tokio_util::sync::CancellationToken;
561
562        let token = CancellationToken::new();
563        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
564        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
565
566        let mut consumer = TimerConsumer {
567            config: TimerConfig::from_uri("timer:cancel-test?period=50").unwrap(),
568            started: AtomicBool::new(false),
569            runtime: rt(),
570        };
571
572        let handle = tokio::spawn(async move {
573            consumer.start(ctx).await.unwrap();
574        });
575
576        // Let it fire a few times
577        tokio::time::sleep(Duration::from_millis(180)).await;
578        token.cancel();
579
580        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
581        assert!(
582            result.is_ok(),
583            "Consumer should have stopped after cancellation"
584        );
585
586        let mut count = 0;
587        while rx.try_recv().is_ok() {
588            count += 1;
589        }
590        assert!(
591            count >= 2,
592            "Expected at least 2 exchanges before cancellation, got {count}"
593        );
594    }
595
596    #[tokio::test]
597    async fn test_timer_consumer_stop_shuts_down() {
598        let component = TimerComponent::new();
599        let endpoint = component
600            .create_endpoint("timer:stop-test?period=50", &NoOpComponentContext)
601            .unwrap();
602        let mut consumer = endpoint.create_consumer(rt()).unwrap();
603
604        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
605        let token = tokio_util::sync::CancellationToken::new();
606        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
607
608        // Run consumer in background (start() blocks until cancelled)
609        tokio::spawn(async move {
610            consumer.start(ctx).await.unwrap();
611        });
612
613        // Let it fire a few times
614        tokio::time::sleep(Duration::from_millis(180)).await;
615
616        // Drain any pending exchanges
617        let mut count = 0;
618        while rx.try_recv().is_ok() {
619            count += 1;
620        }
621        assert!(count >= 2, "Expected at least 2 exchanges, got {count}");
622
623        // Cancel the token to stop the consumer
624        token.cancel();
625    }
626
627    // TIMER-002: fixedRate config round-trip
628    #[test]
629    fn test_fixed_rate_default_is_false() {
630        let config = TimerConfig::from_uri("timer:tick").unwrap();
631        assert!(!config.fixed_rate, "fixedRate should default to false");
632    }
633
634    #[test]
635    fn test_fixed_rate_parsed_from_uri() {
636        let config = TimerConfig::from_uri("timer:tick?fixedRate=true").unwrap();
637        assert!(
638            config.fixed_rate,
639            "fixedRate should be true when set in URI"
640        );
641    }
642
643    // TIMER-003: double-start guard
644    #[tokio::test]
645    async fn test_double_start_returns_error() {
646        let component = TimerComponent::new();
647        let endpoint = component
648            .create_endpoint(
649                "timer:double?period=50&repeatCount=2",
650                &NoOpComponentContext,
651            )
652            .unwrap(); // allow-unwrap: test setup
653
654        let mut consumer = TimerConsumer {
655            config: TimerConfig {
656                name: "double-test".to_string(),
657                period: Duration::from_millis(100),
658                period_ms: 100,
659                delay: Duration::ZERO,
660                delay_ms: 0,
661                repeat_count: None,
662                fixed_rate: false,
663                include_metadata: true,
664            },
665            started: AtomicBool::new(false),
666            runtime: rt(),
667        };
668
669        // Simulate the consumer already being started by setting the flag.
670        consumer.mark_started_for_test();
671
672        let (tx, _rx) = tokio::sync::mpsc::channel(16);
673        let cancel_token = tokio_util::sync::CancellationToken::new();
674        let ctx = ConsumerContext::new(tx, cancel_token.clone(), "timer-test-route".to_string());
675
676        // Second start on an already-started consumer must return an error.
677        let result = consumer.start(ctx).await;
678        assert!(result.is_err(), "expected double-start to return Err");
679        let err_str = format!("{:?}", result.unwrap_err());
680        assert!(
681            err_str.contains("already started"),
682            "unexpected error: {err_str}"
683        );
684
685        drop(endpoint); // suppress unused-variable warning
686    }
687
688    // TIMER-005: CamelTimerFiredTime and CamelMessageTimestamp headers
689    #[tokio::test]
690    async fn test_timer_fired_time_and_message_timestamp_headers() {
691        let component = TimerComponent::new();
692        let endpoint = component
693            .create_endpoint(
694                "timer:headers?period=50&repeatCount=1",
695                &NoOpComponentContext,
696            )
697            .unwrap();
698        let mut consumer = endpoint.create_consumer(rt()).unwrap();
699
700        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
701        let ctx = ConsumerContext::new(
702            tx,
703            tokio_util::sync::CancellationToken::new(),
704            "timer-test-route".to_string(),
705        );
706
707        tokio::spawn(async move {
708            consumer.start(ctx).await.unwrap();
709        });
710
711        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
712            .await
713            .expect("should receive exchange")
714            .expect("envelope should exist");
715
716        let exchange = envelope.exchange;
717
718        // CamelTimerFiredTime should be an ISO-8601 string
719        let fired_time = exchange
720            .input
721            .header("CamelTimerFiredTime")
722            .expect("CamelTimerFiredTime header should be present");
723        assert!(
724            fired_time.is_string(),
725            "CamelTimerFiredTime should be a string"
726        );
727        let fired_str = fired_time.as_str().unwrap();
728        // Should parse as ISO-8601 / RFC 3339
729        assert!(
730            chrono::DateTime::parse_from_rfc3339(fired_str).is_ok(),
731            "CamelTimerFiredTime should be valid RFC 3339: {fired_str}"
732        );
733
734        // CamelMessageTimestamp should be a number (epoch millis)
735        let msg_ts = exchange
736            .input
737            .header("CamelMessageTimestamp")
738            .expect("CamelMessageTimestamp header should be present");
739        assert!(
740            msg_ts.is_number(),
741            "CamelMessageTimestamp should be a number"
742        );
743        let ts_millis = msg_ts.as_i64().expect("should be i64");
744        assert!(ts_millis > 0, "timestamp should be positive");
745    }
746
747    #[test]
748    fn test_timer_fired_time_header_format() {
749        // Verify the format independently
750        let now = chrono::Utc::now();
751        let rfc = now.to_rfc3339();
752        assert!(chrono::DateTime::parse_from_rfc3339(&rfc).is_ok());
753        let millis = now.timestamp_millis();
754        assert!(millis > 0);
755    }
756
757    // TIMER-006: includeMetadata option
758    #[test]
759    fn test_include_metadata_default_is_true() {
760        let config = TimerConfig::from_uri("timer:tick").unwrap();
761        assert!(
762            config.include_metadata,
763            "includeMetadata should default to true"
764        );
765    }
766
767    #[test]
768    fn test_include_metadata_false_from_uri() {
769        let config = TimerConfig::from_uri("timer:tick?includeMetadata=false").unwrap();
770        assert!(
771            !config.include_metadata,
772            "includeMetadata should be false when set in URI"
773        );
774    }
775
776    #[tokio::test]
777    async fn test_include_metadata_false_omits_headers() {
778        let component = TimerComponent::new();
779        let endpoint = component
780            .create_endpoint(
781                "timer:minimal?period=50&repeatCount=1&includeMetadata=false",
782                &NoOpComponentContext,
783            )
784            .unwrap();
785        let mut consumer = endpoint.create_consumer(rt()).unwrap();
786
787        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
788        let ctx = ConsumerContext::new(
789            tx,
790            tokio_util::sync::CancellationToken::new(),
791            "timer-test-route".to_string(),
792        );
793
794        tokio::spawn(async move {
795            consumer.start(ctx).await.unwrap();
796        });
797
798        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
799            .await
800            .expect("should receive exchange")
801            .expect("envelope should exist");
802
803        let exchange = envelope.exchange;
804
805        // No metadata headers should be present
806        assert!(
807            exchange.input.header("CamelTimerName").is_none(),
808            "CamelTimerName should not be present when includeMetadata=false"
809        );
810        assert!(
811            exchange.input.header("CamelTimerCounter").is_none(),
812            "CamelTimerCounter should not be present when includeMetadata=false"
813        );
814        assert!(
815            exchange.input.header("CamelTimerFiredTime").is_none(),
816            "CamelTimerFiredTime should not be present when includeMetadata=false"
817        );
818        assert!(
819            exchange.input.header("CamelMessageTimestamp").is_none(),
820            "CamelMessageTimestamp should not be present when includeMetadata=false"
821        );
822    }
823
824    #[tokio::test]
825    async fn test_include_metadata_true_includes_all_headers() {
826        let component = TimerComponent::new();
827        let endpoint = component
828            .create_endpoint(
829                "timer:full?period=50&repeatCount=1&includeMetadata=true",
830                &NoOpComponentContext,
831            )
832            .unwrap();
833        let mut consumer = endpoint.create_consumer(rt()).unwrap();
834
835        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
836        let ctx = ConsumerContext::new(
837            tx,
838            tokio_util::sync::CancellationToken::new(),
839            "timer-test-route".to_string(),
840        );
841
842        tokio::spawn(async move {
843            consumer.start(ctx).await.unwrap();
844        });
845
846        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
847            .await
848            .expect("should receive exchange")
849            .expect("envelope should exist");
850
851        let exchange = envelope.exchange;
852
853        assert!(exchange.input.header("CamelTimerName").is_some());
854        assert!(exchange.input.header("CamelTimerCounter").is_some());
855        assert!(exchange.input.header("CamelTimerFiredTime").is_some());
856        assert!(exchange.input.header("CamelMessageTimestamp").is_some());
857    }
858
859    // TIMER-011: TimerEndpoint and TimerConsumer are pub
860    #[test]
861    fn test_timer_endpoint_is_pub() {
862        let component = TimerComponent::new();
863        let endpoint = component
864            .create_endpoint("timer:pub-test", &NoOpComponentContext)
865            .unwrap();
866        assert_eq!(endpoint.uri(), "timer:pub-test");
867    }
868
869    // -------------------------------------------------------------------
870    // Recording metrics collector for testing increment_errors calls
871    // (pattern: camel-direct tests::RecordingMetrics)
872    // -------------------------------------------------------------------
873
874    struct RecordingMetrics {
875        errors: Arc<Mutex<Vec<(String, String)>>>,
876    }
877
878    impl camel_api::MetricsCollector for RecordingMetrics {
879        fn record_exchange_duration(&self, _: &str, _: Duration) {}
880        fn increment_errors(&self, route_id: &str, error_type: &str) {
881            self.errors
882                .lock()
883                .unwrap()
884                .push((route_id.to_string(), error_type.to_string()));
885        }
886        fn increment_exchanges(&self, _: &str) {}
887        fn set_queue_depth(&self, _: &str, _: usize) {}
888        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
889    }
890
891    struct RecordingRuntime {
892        metrics_collector: Arc<RecordingMetrics>,
893    }
894
895    impl RecordingRuntime {
896        fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
897            Self {
898                metrics_collector: Arc::new(RecordingMetrics { errors }),
899            }
900        }
901    }
902
903    impl camel_component_api::RuntimeObservability for RecordingRuntime {
904        fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
905            Arc::clone(&self.metrics_collector) as Arc<dyn camel_api::MetricsCollector>
906        }
907        fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
908            panic!("RecordingRuntime::health not used in this test")
909        }
910    }
911
912    #[tokio::test]
913    async fn timer_fire_send_failure_counts_b_prime() {
914        let errors = Arc::new(Mutex::new(Vec::new()));
915        let component = TimerComponent::new();
916        let endpoint = component
917            .create_endpoint("timer:fire-send?period=20", &NoOpComponentContext)
918            .unwrap();
919
920        // Recording runtime through the same seam the other tests fill with
921        // the Panic helper (`rt()`).
922        let mut consumer = endpoint
923            .create_consumer(Arc::new(RecordingRuntime::new(Arc::clone(&errors))))
924            .unwrap();
925        // Receiver dropped: every fire send fails with `ChannelClosed`.
926        let (tx, _) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
927        let ctx = ConsumerContext::new(
928            tx,
929            tokio_util::sync::CancellationToken::new(),
930            "timer-test-route".to_string(),
931        );
932
933        // First tick fails the send; the consumer records it and exits its loop.
934        consumer.start(ctx).await.unwrap();
935
936        let recorded = errors.lock().unwrap().clone();
937        assert_eq!(
938            recorded,
939            vec![(
940                "timer-test-route".to_string(),
941                "b-prime:timer:fire-send".to_string()
942            )]
943        );
944
945        consumer.stop().await.unwrap();
946    }
947}