1use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use axum::Router;
17use axum::body::Bytes;
18use axum::extract::{DefaultBodyLimit, State};
19use axum::http::{HeaderMap, StatusCode, header};
20use axum::middleware;
21use axum::routing::{get, post};
22use serde_json::{Value, from_slice};
23use tokio::sync::{Mutex, Semaphore};
24use tokio::task::JoinSet;
25use tokio_cron_scheduler::{Job, JobScheduler};
26use tracing::{error, info, warn};
27
28use crate::cron::CronJob;
29use crate::error::RuntimeError;
30use crate::webhook::WebhookAuth;
31
32const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
34
35const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64;
37
38type WebhookHandler = Arc<dyn Fn(Value) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
39type ShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
40
41#[cfg(feature = "prometheus")]
43mod metric_names {
44 pub const WEBHOOK_RECEIVED_TOTAL: &str = "ironflow_webhook_received_total";
45 pub const CRON_RUNS_TOTAL: &str = "ironflow_cron_runs_total";
46
47 pub const AUTH_REJECTED: &str = "rejected";
48 pub const AUTH_ACCEPTED: &str = "accepted";
49 pub const AUTH_INVALID_BODY: &str = "invalid_body";
50}
51
52struct WebhookRoute {
53 path: String,
54 auth: WebhookAuth,
55 handler: WebhookHandler,
56}
57
58pub struct Runtime {
94 webhooks: Vec<WebhookRoute>,
95 crons: Vec<CronJob>,
96 max_body_size: usize,
97 max_concurrent_handlers: usize,
98 custom_shutdown: Option<ShutdownSignal>,
99}
100
101impl Runtime {
102 pub fn new() -> Self {
112 Self {
113 webhooks: Vec::new(),
114 crons: Vec::new(),
115 max_body_size: DEFAULT_MAX_BODY_SIZE,
116 max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS,
117 custom_shutdown: None,
118 }
119 }
120
121 pub fn max_body_size(mut self, bytes: usize) -> Self {
134 self.max_body_size = bytes;
135 self
136 }
137
138 pub fn max_concurrent_handlers(mut self, limit: usize) -> Self {
156 assert!(limit > 0, "max_concurrent_handlers must be greater than 0");
157 self.max_concurrent_handlers = limit;
158 self
159 }
160
161 pub fn with_shutdown<F>(mut self, signal: F) -> Self
189 where
190 F: Future<Output = ()> + Send + 'static,
191 {
192 self.custom_shutdown = Some(Box::pin(signal));
193 self
194 }
195
196 pub fn webhook<F, Fut>(mut self, path: &str, auth: WebhookAuth, handler: F) -> Self
220 where
221 F: Fn(Value) -> Fut + Send + Sync + Clone + 'static,
222 Fut: Future<Output = ()> + Send + 'static,
223 {
224 assert!(
225 path.starts_with('/'),
226 "webhook path must start with '/', got: {path}"
227 );
228 if matches!(auth, WebhookAuth::None) {
229 warn!(path = %path, "webhook registered with WebhookAuth::None - all requests will be accepted without authentication");
230 }
231 let handler: WebhookHandler = Arc::new(move |payload| {
232 let handler = handler.clone();
233 Box::pin(async move { handler(payload).await })
234 });
235 self.webhooks.push(WebhookRoute {
236 path: path.to_string(),
237 auth,
238 handler,
239 });
240 self
241 }
242
243 pub fn cron<F, Fut>(mut self, schedule: &str, name: &str, handler: F) -> Self
265 where
266 F: Fn() -> Fut + Send + Sync + 'static,
267 Fut: Future<Output = ()> + Send + 'static,
268 {
269 let handler_fn: Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
270 Box::new(move || Box::pin(handler()));
271 self.crons.push(CronJob {
272 schedule: schedule.to_string(),
273 name: name.to_string(),
274 handler: handler_fn,
275 });
276 self
277 }
278
279 fn build_router(
285 webhooks: Vec<WebhookRoute>,
286 handler_tracker: Arc<HandlerTracker>,
287 max_body_size: usize,
288 #[cfg(feature = "prometheus")] prom_handle: Option<
289 metrics_exporter_prometheus::PrometheusHandle,
290 >,
291 ) -> Router {
292 let mut router = Router::new();
293
294 for webhook in webhooks {
295 let auth = Arc::new(webhook.auth);
296 let handler = webhook.handler;
297 let path = webhook.path.clone();
298
299 let name: Arc<str> = Arc::from(path.as_str());
300 let route_state = WebhookState {
301 auth,
302 handler,
303 name,
304 tracker: handler_tracker.clone(),
305 };
306
307 router = router.route(&path, post(webhook_handler).with_state(route_state));
308 info!(path = %path, "registered webhook");
309 }
310
311 router = router.route("/health", get(|| async { "ok" }));
312
313 #[cfg(feature = "prometheus")]
314 if let Some(handle) = prom_handle {
315 router = router.route(
316 "/metrics",
317 get(move || {
318 let h = handle.clone();
319 async move { h.render() }
320 }),
321 );
322 info!("registered /metrics endpoint");
323 }
324
325 router
326 .layer(middleware::from_fn(security_headers))
327 .layer(DefaultBodyLimit::max(max_body_size))
328 }
329
330 pub fn into_router(self) -> Router {
346 if !self.crons.is_empty() {
347 warn!(
348 cron_count = self.crons.len(),
349 "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
350 );
351 }
352 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
353 Self::build_router(
354 self.webhooks,
355 tracker,
356 self.max_body_size,
357 #[cfg(feature = "prometheus")]
358 None,
359 )
360 }
361
362 async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
367 let scheduler = JobScheduler::new().await?;
368
369 for cron_job in crons {
370 let handler = Arc::new(cron_job.handler);
371 let name = cron_job.name.clone();
372 let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
373 let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
374 let handler = handler.clone();
375 let name = name.clone();
376 let running = running.clone();
377 Box::pin(async move {
378 if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
379 warn!(cron = %name, "cron job still running, skipping this tick");
380 return;
381 }
382 info!(cron = %name, "cron job triggered");
383 #[cfg(feature = "prometheus")]
384 metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
385 .increment(1);
386 (handler)().await;
387 running.store(false, std::sync::atomic::Ordering::Release);
388 })
389 })?;
390 info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
391 scheduler.add(job).await?;
392 }
393
394 scheduler.start().await?;
395 Ok(scheduler)
396 }
397
398 pub async fn run_crons(self) -> Result<(), RuntimeError> {
429 let _ = dotenvy::dotenv();
430
431 if !self.webhooks.is_empty() {
432 warn!(
433 webhook_count = self.webhooks.len(),
434 "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
435 );
436 }
437
438 #[cfg(feature = "prometheus")]
439 {
440 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
441 Ok(_) => info!("prometheus metrics recorder installed"),
442 Err(_) => {
443 info!("prometheus metrics recorder already installed, reusing existing")
444 }
445 }
446 }
447
448 let mut scheduler = Self::start_scheduler(self.crons).await?;
449
450 info!("ironflow cron scheduler running (no HTTP server)");
451 match self.custom_shutdown {
452 Some(signal) => signal.await,
453 None => shutdown_signal().await,
454 }
455
456 info!("shutting down scheduler");
457 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
458 info!("ironflow cron scheduler stopped");
459
460 Ok(())
461 }
462
463 pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
499 let _ = dotenvy::dotenv();
500
501 #[cfg(feature = "prometheus")]
502 let prom_handle = {
503 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
504 Ok(handle) => {
505 info!("prometheus metrics recorder installed");
506 Some(handle)
507 }
508 Err(_) => {
509 info!("prometheus metrics recorder already installed, reusing existing");
510 None
511 }
512 }
513 };
514
515 let mut scheduler = Self::start_scheduler(self.crons).await?;
516
517 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
518 let router = Self::build_router(
519 self.webhooks,
520 tracker.clone(),
521 self.max_body_size,
522 #[cfg(feature = "prometheus")]
523 prom_handle,
524 );
525
526 let listener = tokio::net::TcpListener::bind(addr)
527 .await
528 .map_err(RuntimeError::Bind)?;
529 info!(addr = %addr, "ironflow runtime listening");
530
531 let graceful_shutdown = match self.custom_shutdown {
532 Some(signal) => signal,
533 None => Box::pin(shutdown_signal()),
534 };
535 axum::serve(listener, router)
536 .with_graceful_shutdown(graceful_shutdown)
537 .await
538 .map_err(RuntimeError::Serve)?;
539
540 info!("waiting for in-flight webhook handlers to complete");
542 tracker.wait().await;
543
544 info!("shutting down scheduler");
545 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
546 info!("ironflow runtime stopped");
547
548 Ok(())
549 }
550}
551
552impl Default for Runtime {
553 fn default() -> Self {
554 Self::new()
555 }
556}
557
558struct HandlerTracker {
563 semaphore: Arc<Semaphore>,
564 join_set: Mutex<JoinSet<()>>,
565}
566
567impl HandlerTracker {
568 fn new(max_concurrent: usize) -> Self {
569 Self {
570 semaphore: Arc::new(Semaphore::new(max_concurrent)),
571 join_set: Mutex::new(JoinSet::new()),
572 }
573 }
574
575 async fn spawn(&self, name: String, handler: WebhookHandler, payload: Value) {
577 let semaphore = self.semaphore.clone();
578 let mut js = self.join_set.lock().await;
579 while let Some(result) = js.try_join_next() {
581 if let Err(e) = result {
582 error!(error = %e, "webhook handler panicked");
583 }
584 }
585 use tracing::Instrument;
586 let span = tracing::info_span!("webhook", path = %name);
587 js.spawn(
588 async move {
589 let _permit = semaphore
590 .acquire()
591 .await
592 .expect("semaphore closed unexpectedly");
593 info!("webhook workflow started");
594 handler(payload).await;
595 info!("webhook workflow completed");
596 }
597 .instrument(span),
598 );
599 }
600
601 async fn wait(&self) {
603 let mut js = self.join_set.lock().await;
604 while let Some(result) = js.join_next().await {
605 if let Err(e) = result {
606 error!(error = %e, "webhook handler panicked");
607 }
608 }
609 }
610}
611
612#[derive(Clone)]
613struct WebhookState {
614 auth: Arc<WebhookAuth>,
615 handler: WebhookHandler,
616 name: Arc<str>,
617 tracker: Arc<HandlerTracker>,
618}
619
620async fn webhook_handler(
621 State(state): State<WebhookState>,
622 headers: HeaderMap,
623 body: Bytes,
624) -> StatusCode {
625 let name = &state.name;
626 if !state.auth.verify(&headers, &body) {
627 warn!(webhook = %name, "webhook auth failed");
628 #[cfg(feature = "prometheus")]
629 {
630 let label: String = name.to_string();
631 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
632 }
633 return StatusCode::UNAUTHORIZED;
634 }
635
636 let payload: Value = match from_slice(&body) {
637 Ok(v) => v,
638 Err(e) => {
639 warn!(webhook = %name, error = %e, "invalid JSON body");
640 #[cfg(feature = "prometheus")]
641 {
642 let label: String = name.to_string();
643 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
644 }
645 return StatusCode::BAD_REQUEST;
646 }
647 };
648
649 #[cfg(feature = "prometheus")]
650 {
651 let label: String = name.to_string();
652 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
653 }
654
655 state
656 .tracker
657 .spawn(name.to_string(), state.handler.clone(), payload)
658 .await;
659
660 StatusCode::ACCEPTED
661}
662
663async fn security_headers(
664 request: axum::http::Request<axum::body::Body>,
665 next: axum::middleware::Next,
666) -> axum::response::Response {
667 let mut response = next.run(request).await;
668 let headers = response.headers_mut();
669 headers.insert(
670 header::X_CONTENT_TYPE_OPTIONS,
671 "nosniff".parse().expect("valid header value"),
672 );
673 headers.insert(
674 header::X_FRAME_OPTIONS,
675 "DENY".parse().expect("valid header value"),
676 );
677 headers.insert(
678 "x-xss-protection",
679 "1; mode=block".parse().expect("valid header value"),
680 );
681 headers.insert(
682 header::STRICT_TRANSPORT_SECURITY,
683 "max-age=31536000; includeSubDomains"
684 .parse()
685 .expect("valid header value"),
686 );
687 headers.insert(
688 header::CONTENT_SECURITY_POLICY,
689 "default-src 'none'".parse().expect("valid header value"),
690 );
691 response
692}
693
694async fn shutdown_signal() {
695 let ctrl_c = async {
696 if let Err(e) = tokio::signal::ctrl_c().await {
697 warn!("failed to install ctrl+c handler: {e}");
698 }
699 };
700
701 #[cfg(unix)]
702 {
703 use tokio::signal::unix::{SignalKind, signal};
704 let mut sigterm =
705 signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
706 tokio::select! {
707 () = ctrl_c => info!("received SIGINT, shutting down"),
708 _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
709 }
710 }
711
712 #[cfg(not(unix))]
713 {
714 ctrl_c.await;
715 info!("received ctrl+c, shutting down");
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
725 fn runtime_new_creates_with_defaults() {
726 let rt = Runtime::new();
727 assert_eq!(rt.webhooks.len(), 0);
728 assert_eq!(rt.crons.len(), 0);
729 assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
730 assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
731 assert!(rt.custom_shutdown.is_none());
732 }
733
734 #[test]
736 fn runtime_default_equals_new() {
737 let rt_new = Runtime::new();
738 let rt_default = Runtime::default();
739 assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
740 assert_eq!(rt_new.crons.len(), rt_default.crons.len());
741 assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
742 assert_eq!(
743 rt_new.max_concurrent_handlers,
744 rt_default.max_concurrent_handlers
745 );
746 }
747
748 #[test]
750 fn max_body_size_sets_value_and_returns_self() {
751 let rt = Runtime::new().max_body_size(512 * 1024);
752 assert_eq!(rt.max_body_size, 512 * 1024);
753 }
754
755 #[test]
757 fn max_body_size_chainable() {
758 let rt =
759 Runtime::new()
760 .max_body_size(1024)
761 .webhook("/test", WebhookAuth::none(), |_| async {});
762 assert_eq!(rt.max_body_size, 1024);
763 assert_eq!(rt.webhooks.len(), 1);
764 }
765
766 #[test]
768 fn max_body_size_can_be_zero() {
769 let rt = Runtime::new().max_body_size(0);
770 assert_eq!(rt.max_body_size, 0);
771 }
772
773 #[test]
775 fn max_body_size_can_be_large() {
776 let large_size = 1024 * 1024 * 1024; let rt = Runtime::new().max_body_size(large_size);
778 assert_eq!(rt.max_body_size, large_size);
779 }
780
781 #[test]
783 #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
784 fn max_concurrent_handlers_zero_panics() {
785 let _ = Runtime::new().max_concurrent_handlers(0);
786 }
787
788 #[test]
790 fn max_concurrent_handlers_sets_valid_values() {
791 let rt = Runtime::new().max_concurrent_handlers(16);
792 assert_eq!(rt.max_concurrent_handlers, 16);
793 }
794
795 #[test]
797 fn max_concurrent_handlers_one_is_valid() {
798 let rt = Runtime::new().max_concurrent_handlers(1);
799 assert_eq!(rt.max_concurrent_handlers, 1);
800 }
801
802 #[test]
804 fn max_concurrent_handlers_large_value_is_valid() {
805 let large_limit = 10000;
806 let rt = Runtime::new().max_concurrent_handlers(large_limit);
807 assert_eq!(rt.max_concurrent_handlers, large_limit);
808 }
809
810 #[test]
812 fn max_concurrent_handlers_chainable() {
813 let rt = Runtime::new().max_concurrent_handlers(32).webhook(
814 "/test",
815 WebhookAuth::none(),
816 |_| async {},
817 );
818 assert_eq!(rt.max_concurrent_handlers, 32);
819 assert_eq!(rt.webhooks.len(), 1);
820 }
821
822 #[tokio::test]
824 async fn with_shutdown_sets_signal_and_returns_self() {
825 let (tx, rx) = tokio::sync::oneshot::channel();
826 let rt = Runtime::new().with_shutdown(async move {
827 let _ = rx.await;
828 });
829 assert!(rt.custom_shutdown.is_some());
830
831 let _ = tx.send(());
833 }
834
835 #[tokio::test]
837 async fn with_shutdown_chainable() {
838 let (tx, rx) = tokio::sync::oneshot::channel();
839 let rt = Runtime::new()
840 .with_shutdown(async move {
841 let _ = rx.await;
842 })
843 .webhook("/test", WebhookAuth::none(), |_| async {});
844 assert!(rt.custom_shutdown.is_some());
845 assert_eq!(rt.webhooks.len(), 1);
846
847 let _ = tx.send(());
848 }
849
850 #[test]
852 fn webhook_registers_route_and_returns_self() {
853 let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
854 assert_eq!(rt.webhooks.len(), 1);
855 assert_eq!(rt.webhooks[0].path, "/hooks/test");
856 }
857
858 #[test]
860 #[should_panic(expected = "webhook path must start with '/'")]
861 fn webhook_path_without_slash_panics() {
862 let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
863 }
864
865 #[test]
867 fn webhook_accepts_valid_paths() {
868 let rt = Runtime::new()
869 .webhook("/", WebhookAuth::none(), |_| async {})
870 .webhook("/simple", WebhookAuth::none(), |_| async {})
871 .webhook("/nested/path", WebhookAuth::none(), |_| async {})
872 .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
873 .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
874 .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
875 assert_eq!(rt.webhooks.len(), 6);
876 }
877
878 #[test]
880 fn webhook_chainable() {
881 let rt = Runtime::new()
882 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
883 .webhook("/hook-b", WebhookAuth::none(), |_| async {})
884 .webhook("/hook-c", WebhookAuth::none(), |_| async {});
885 assert_eq!(rt.webhooks.len(), 3);
886 assert_eq!(rt.webhooks[0].path, "/hook-a");
887 assert_eq!(rt.webhooks[1].path, "/hook-b");
888 assert_eq!(rt.webhooks[2].path, "/hook-c");
889 }
890
891 #[test]
893 fn webhook_with_various_auth_types() {
894 let rt = Runtime::new()
895 .webhook("/none", WebhookAuth::none(), |_| async {})
896 .webhook(
897 "/header",
898 WebhookAuth::header("x-api-key", "secret"),
899 |_| async {},
900 )
901 .webhook("/github", WebhookAuth::github("secret"), |_| async {})
902 .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
903 assert_eq!(rt.webhooks.len(), 4);
904 }
905
906 #[test]
908 fn cron_registers_job_and_returns_self() {
909 let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
910 assert_eq!(rt.crons.len(), 1);
911 assert_eq!(rt.crons[0].name, "daily-task");
912 assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
913 }
914
915 #[test]
917 fn cron_chainable() {
918 let rt = Runtime::new()
919 .cron("0 0 * * * *", "midnight", || async {})
920 .cron("0 */5 * * * *", "every-5-minutes", || async {});
921 assert_eq!(rt.crons.len(), 2);
922 }
923
924 #[test]
926 fn cron_preserves_schedule_and_name() {
927 let rt = Runtime::new()
928 .cron("0 12 * * * MON", "noon-mondays", || async {})
929 .cron("0 0 1 * * *", "first-of-month", || async {});
930 assert_eq!(rt.crons[0].name, "noon-mondays");
931 assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
932 assert_eq!(rt.crons[1].name, "first-of-month");
933 assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
934 }
935
936 #[test]
938 fn into_router_returns_router() {
939 let rt = Runtime::new();
940 let _router = rt.into_router();
941 }
943
944 #[test]
946 fn into_router_with_webhooks_returns_router() {
947 let rt = Runtime::new()
948 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
949 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
950 let _router = rt.into_router();
951 }
953
954 #[test]
956 fn into_router_with_crons_returns_router() {
957 let rt = Runtime::new()
958 .cron("0 0 * * * *", "daily", || async {})
959 .cron("0 */5 * * * *", "every-5-min", || async {});
960 let _router = rt.into_router();
961 }
963
964 #[test]
966 fn into_router_respects_max_body_size_config() {
967 let rt =
968 Runtime::new()
969 .max_body_size(100)
970 .webhook("/hook", WebhookAuth::none(), |_| async {});
971 let _router = rt.into_router();
972 }
974
975 #[test]
977 fn into_router_respects_max_concurrent_handlers_config() {
978 let rt = Runtime::new().max_concurrent_handlers(16).webhook(
979 "/hook",
980 WebhookAuth::none(),
981 |_| async {},
982 );
983 let _router = rt.into_router();
984 }
986
987 #[test]
989 fn builder_chain_multiple_methods() {
990 let rt = Runtime::new()
991 .max_body_size(512 * 1024)
992 .max_concurrent_handlers(32)
993 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
994 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
995 .cron("0 0 * * * *", "daily", || async {});
996
997 assert_eq!(rt.max_body_size, 512 * 1024);
998 assert_eq!(rt.max_concurrent_handlers, 32);
999 assert_eq!(rt.webhooks.len(), 2);
1000 assert_eq!(rt.crons.len(), 1);
1001 }
1002
1003 #[test]
1005 fn into_router_with_crons_doesnt_start_them() {
1006 let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1007 let _router = rt.into_router();
1009 }
1010}