1use crate::error::AwaError;
7use crate::job::JobRow;
8use chrono::{DateTime, Utc};
9use croner::Cron;
10use serde::Serialize;
11use sqlx::PgExecutor;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum CronMissedFirePolicy {
17 Coalesce,
19 CatchUp,
21}
22
23impl CronMissedFirePolicy {
24 pub const fn as_str(self) -> &'static str {
25 match self {
26 Self::Coalesce => "coalesce",
27 Self::CatchUp => "catch_up",
28 }
29 }
30
31 pub fn parse(value: &str) -> Result<Self, AwaError> {
32 match value {
33 "coalesce" => Ok(Self::Coalesce),
34 "catch_up" => Ok(Self::CatchUp),
35 other => Err(AwaError::Validation(format!(
36 "invalid cron missed fire policy '{other}'"
37 ))),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
46pub struct PeriodicJob {
47 pub name: String,
49 pub cron_expr: String,
51 pub timezone: String,
53 pub kind: String,
55 pub queue: String,
57 pub args: serde_json::Value,
59 pub priority: i16,
61 pub max_attempts: i16,
63 pub tags: Vec<String>,
65 pub metadata: serde_json::Value,
67 pub missed_fire_policy: CronMissedFirePolicy,
69}
70
71impl PeriodicJob {
72 pub fn builder(name: impl Into<String>, cron_expr: impl Into<String>) -> PeriodicJobBuilder {
77 PeriodicJobBuilder {
78 name: name.into(),
79 cron_expr: cron_expr.into(),
80 timezone: "UTC".to_string(),
81 queue: "default".to_string(),
82 priority: 2,
83 max_attempts: 25,
84 tags: Vec::new(),
85 metadata: serde_json::json!({}),
86 missed_fire_policy: CronMissedFirePolicy::Coalesce,
87 }
88 }
89
90 pub fn latest_fire_time(
96 &self,
97 now: DateTime<Utc>,
98 after: Option<DateTime<Utc>>,
99 ) -> Option<DateTime<Utc>> {
100 let cron = Cron::new(&self.cron_expr)
101 .with_seconds_optional()
102 .parse()
103 .expect("cron_expr was validated at build time");
104
105 let tz: chrono_tz::Tz = self
106 .timezone
107 .parse()
108 .expect("timezone was validated at build time");
109
110 let now_tz = now.with_timezone(&tz);
111
112 let search_start = match after {
116 Some(after_time) => after_time.with_timezone(&tz),
117 None => now_tz - chrono::Duration::hours(24),
119 };
120
121 let mut latest_fire: Option<DateTime<Utc>> = None;
122
123 for fire_time in cron.clone().iter_from(search_start) {
125 let fire_utc = fire_time.with_timezone(&Utc);
126
127 if fire_utc > now {
129 break;
130 }
131
132 if let Some(after_time) = after {
134 if fire_utc <= after_time {
135 continue;
136 }
137 }
138
139 latest_fire = Some(fire_utc);
140 }
141
142 latest_fire
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct PeriodicJobBuilder {
149 name: String,
150 cron_expr: String,
151 timezone: String,
152 queue: String,
153 priority: i16,
154 max_attempts: i16,
155 tags: Vec<String>,
156 metadata: serde_json::Value,
157 missed_fire_policy: CronMissedFirePolicy,
158}
159
160impl PeriodicJobBuilder {
161 pub fn timezone(mut self, timezone: impl Into<String>) -> Self {
163 self.timezone = timezone.into();
164 self
165 }
166
167 pub fn queue(mut self, queue: impl Into<String>) -> Self {
169 self.queue = queue.into();
170 self
171 }
172
173 pub fn priority(mut self, priority: i16) -> Self {
175 self.priority = priority;
176 self
177 }
178
179 pub fn max_attempts(mut self, max_attempts: i16) -> Self {
181 self.max_attempts = max_attempts;
182 self
183 }
184
185 pub fn tags(mut self, tags: Vec<String>) -> Self {
187 self.tags = tags;
188 self
189 }
190
191 pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
193 self.metadata = metadata;
194 self
195 }
196
197 pub fn missed_fire_policy(mut self, policy: CronMissedFirePolicy) -> Self {
199 self.missed_fire_policy = policy;
200 self
201 }
202
203 pub fn build(self, args: &impl crate::JobArgs) -> Result<PeriodicJob, AwaError> {
208 self.build_raw(args.kind_str().to_string(), args.to_args()?)
209 }
210
211 pub fn build_raw(self, kind: String, args: serde_json::Value) -> Result<PeriodicJob, AwaError> {
213 Cron::new(&self.cron_expr)
215 .with_seconds_optional()
216 .parse()
217 .map_err(|err| AwaError::Validation(format!("invalid cron expression: {err}")))?;
218
219 self.timezone
221 .parse::<chrono_tz::Tz>()
222 .map_err(|err| AwaError::Validation(format!("invalid timezone: {err}")))?;
223
224 if !(1..=4).contains(&self.priority) {
226 return Err(AwaError::Validation(format!(
227 "priority must be between 1 and 4, got {}",
228 self.priority
229 )));
230 }
231
232 if !(1..=1000).contains(&self.max_attempts) {
234 return Err(AwaError::Validation(format!(
235 "max_attempts must be between 1 and 1000, got {}",
236 self.max_attempts
237 )));
238 }
239
240 Ok(PeriodicJob {
241 name: self.name,
242 cron_expr: self.cron_expr,
243 timezone: self.timezone,
244 kind,
245 queue: self.queue,
246 args,
247 priority: self.priority,
248 max_attempts: self.max_attempts,
249 tags: self.tags,
250 metadata: self.metadata,
251 missed_fire_policy: self.missed_fire_policy,
252 })
253 }
254}
255
256#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
258pub struct CronJobRow {
259 pub name: String,
260 pub cron_expr: String,
261 pub timezone: String,
262 pub kind: String,
263 pub queue: String,
264 pub args: serde_json::Value,
265 pub priority: i16,
266 pub max_attempts: i16,
267 pub tags: Vec<String>,
268 pub metadata: serde_json::Value,
269 pub missed_fire_policy: String,
270 pub last_enqueued_at: Option<DateTime<Utc>>,
271 pub created_at: DateTime<Utc>,
272 pub updated_at: DateTime<Utc>,
273 pub paused_at: Option<DateTime<Utc>>,
278 pub paused_by: Option<String>,
279}
280
281impl CronJobRow {
282 pub fn is_paused(&self) -> bool {
284 self.paused_at.is_some()
285 }
286}
287
288pub async fn upsert_cron_job<'e, E>(executor: E, job: &PeriodicJob) -> Result<(), AwaError>
292where
293 E: PgExecutor<'e>,
294{
295 sqlx::query(
296 r#"
297 INSERT INTO awa.cron_jobs (name, cron_expr, timezone, kind, queue, args, priority, max_attempts, tags, metadata, missed_fire_policy)
298 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
299 ON CONFLICT (name) DO UPDATE SET
300 cron_expr = EXCLUDED.cron_expr,
301 timezone = EXCLUDED.timezone,
302 kind = EXCLUDED.kind,
303 queue = EXCLUDED.queue,
304 args = EXCLUDED.args,
305 priority = EXCLUDED.priority,
306 max_attempts = EXCLUDED.max_attempts,
307 tags = EXCLUDED.tags,
308 metadata = EXCLUDED.metadata,
309 missed_fire_policy = EXCLUDED.missed_fire_policy,
310 updated_at = now()
311 "#,
312 )
313 .bind(&job.name)
314 .bind(&job.cron_expr)
315 .bind(&job.timezone)
316 .bind(&job.kind)
317 .bind(&job.queue)
318 .bind(&job.args)
319 .bind(job.priority)
320 .bind(job.max_attempts)
321 .bind(&job.tags)
322 .bind(&job.metadata)
323 .bind(job.missed_fire_policy.as_str())
324 .execute(executor)
325 .await?;
326
327 Ok(())
328}
329
330pub fn next_fire_time(cron_expr: &str, timezone: &str) -> Option<DateTime<Utc>> {
334 next_fire_time_after(cron_expr, timezone, Utc::now())
335}
336
337pub fn next_fire_time_after(
342 cron_expr: &str,
343 timezone: &str,
344 after: DateTime<Utc>,
345) -> Option<DateTime<Utc>> {
346 let cron = Cron::new(cron_expr).with_seconds_optional().parse().ok()?;
347 let tz: chrono_tz::Tz = timezone.parse().ok()?;
348 let after_tz = after.with_timezone(&tz);
349 let next = cron.iter_from(after_tz).next()?;
350 Some(next.with_timezone(&Utc))
351}
352
353pub async fn list_cron_jobs<'e, E>(executor: E) -> Result<Vec<CronJobRow>, AwaError>
355where
356 E: PgExecutor<'e>,
357{
358 let rows = sqlx::query_as::<_, CronJobRow>("SELECT * FROM awa.cron_jobs ORDER BY name")
359 .fetch_all(executor)
360 .await?;
361 Ok(rows)
362}
363
364pub async fn delete_cron_job<'e, E>(executor: E, name: &str) -> Result<bool, AwaError>
366where
367 E: PgExecutor<'e>,
368{
369 let result = sqlx::query("DELETE FROM awa.cron_jobs WHERE name = $1")
370 .bind(name)
371 .execute(executor)
372 .await?;
373 Ok(result.rows_affected() > 0)
374}
375
376pub async fn pause_cron_job<'e, E>(
385 executor: E,
386 name: &str,
387 paused_by: Option<&str>,
388) -> Result<bool, AwaError>
389where
390 E: PgExecutor<'e>,
391{
392 let result = sqlx::query(
393 r#"
394 UPDATE awa.cron_jobs
395 SET paused_at = now(), paused_by = $2, updated_at = now()
396 WHERE name = $1
397 "#,
398 )
399 .bind(name)
400 .bind(paused_by)
401 .execute(executor)
402 .await?;
403 Ok(result.rows_affected() > 0)
404}
405
406pub async fn resume_cron_job<'e, E>(executor: E, name: &str) -> Result<bool, AwaError>
412where
413 E: PgExecutor<'e>,
414{
415 let result = sqlx::query(
416 r#"
417 UPDATE awa.cron_jobs
418 SET paused_at = NULL, paused_by = NULL, updated_at = now()
419 WHERE name = $1
420 "#,
421 )
422 .bind(name)
423 .execute(executor)
424 .await?;
425 Ok(result.rows_affected() > 0)
426}
427
428pub async fn atomic_enqueue<'e, E>(
438 executor: E,
439 cron_name: &str,
440 fire_time: DateTime<Utc>,
441 previous_enqueued_at: Option<DateTime<Utc>>,
442) -> Result<Option<JobRow>, AwaError>
443where
444 E: PgExecutor<'e>,
445{
446 let row = sqlx::query_as::<_, JobRow>(
447 r#"
448 WITH mark AS (
449 UPDATE awa.cron_jobs
450 SET last_enqueued_at = $2, updated_at = now()
451 WHERE name = $1
452 AND (last_enqueued_at IS NOT DISTINCT FROM $3)
453 AND paused_at IS NULL
454 RETURNING name, kind, queue, args, priority, max_attempts, tags, metadata
455 )
456 SELECT inserted.*
457 FROM mark
458 CROSS JOIN LATERAL awa.insert_job_compat(
459 mark.kind,
460 mark.queue,
461 mark.args,
462 'available'::awa.job_state,
463 mark.priority,
464 mark.max_attempts,
465 NULL,
466 mark.metadata || jsonb_build_object('cron_name', mark.name, 'cron_fire_time', $2::text),
467 mark.tags,
468 NULL,
469 NULL
470 ) AS inserted
471 "#,
472 )
473 .bind(cron_name)
474 .bind(fire_time)
475 .bind(previous_enqueued_at)
476 .fetch_optional(executor)
477 .await?;
478
479 Ok(row)
480}
481
482pub async fn trigger_cron_job<'e, E>(executor: E, name: &str) -> Result<JobRow, AwaError>
489where
490 E: PgExecutor<'e>,
491{
492 let row = sqlx::query_as::<_, JobRow>(
493 r#"
494 WITH cron AS (
495 SELECT name, kind, queue, args, priority, max_attempts, tags, metadata
496 FROM awa.cron_jobs
497 WHERE name = $1
498 )
499 SELECT inserted.*
500 FROM cron
501 CROSS JOIN LATERAL awa.insert_job_compat(
502 cron.kind,
503 cron.queue,
504 cron.args,
505 'available'::awa.job_state,
506 cron.priority,
507 cron.max_attempts,
508 NULL,
509 cron.metadata || jsonb_build_object('cron_name', cron.name, 'triggered_manually', true),
510 cron.tags,
511 NULL,
512 NULL
513 ) AS inserted
514 "#,
515 )
516 .bind(name)
517 .fetch_optional(executor)
518 .await?;
519
520 row.ok_or_else(|| AwaError::Validation(format!("cron job not found: {name}")))
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use chrono::TimeZone;
527
528 fn make_periodic(cron_expr: &str, timezone: &str) -> PeriodicJob {
529 PeriodicJob {
530 name: "test".to_string(),
531 cron_expr: cron_expr.to_string(),
532 timezone: timezone.to_string(),
533 kind: "test_job".to_string(),
534 queue: "default".to_string(),
535 args: serde_json::json!({}),
536 priority: 2,
537 max_attempts: 25,
538 tags: vec![],
539 metadata: serde_json::json!({}),
540 missed_fire_policy: CronMissedFirePolicy::Coalesce,
541 }
542 }
543
544 #[test]
545 fn test_valid_cron_expression() {
546 let result = PeriodicJob::builder("test", "0 9 * * *")
547 .build_raw("test_job".to_string(), serde_json::json!({}));
548 assert!(result.is_ok());
549 }
550
551 #[test]
552 fn test_invalid_cron_expression() {
553 let result = PeriodicJob::builder("test", "not a cron")
554 .build_raw("test_job".to_string(), serde_json::json!({}));
555 assert!(result.is_err());
556 let err = result.unwrap_err();
557 assert!(
558 err.to_string().contains("invalid cron expression"),
559 "got: {err}"
560 );
561 }
562
563 #[test]
564 fn test_invalid_timezone() {
565 let result = PeriodicJob::builder("test", "0 9 * * *")
566 .timezone("Not/A/Timezone")
567 .build_raw("test_job".to_string(), serde_json::json!({}));
568 assert!(result.is_err());
569 let err = result.unwrap_err();
570 assert!(err.to_string().contains("invalid timezone"), "got: {err}");
571 }
572
573 #[test]
574 fn test_builder_defaults() {
575 let job = PeriodicJob::builder("daily_report", "0 9 * * *")
576 .build_raw(
577 "daily_report".to_string(),
578 serde_json::json!({"format": "pdf"}),
579 )
580 .unwrap();
581 assert_eq!(job.name, "daily_report");
582 assert_eq!(job.timezone, "UTC");
583 assert_eq!(job.queue, "default");
584 assert_eq!(job.priority, 2);
585 assert_eq!(job.max_attempts, 25);
586 assert!(job.tags.is_empty());
587 }
588
589 #[test]
590 fn test_builder_custom_fields() {
591 let job = PeriodicJob::builder("report", "0 9 * * *")
592 .timezone("Pacific/Auckland")
593 .queue("reports")
594 .priority(1)
595 .max_attempts(3)
596 .tags(vec!["important".to_string()])
597 .metadata(serde_json::json!({"source": "cron"}))
598 .build_raw("daily_report".to_string(), serde_json::json!({}))
599 .unwrap();
600 assert_eq!(job.timezone, "Pacific/Auckland");
601 assert_eq!(job.queue, "reports");
602 assert_eq!(job.priority, 1);
603 assert_eq!(job.max_attempts, 3);
604 assert_eq!(job.tags, vec!["important"]);
605 }
606
607 #[test]
608 fn test_latest_fire_time_finds_past_fire() {
609 let pj = make_periodic("0 * * * *", "UTC");
611 let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 0).unwrap();
612 let after = Some(Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap());
613
614 let fire = pj.latest_fire_time(now, after);
615 assert_eq!(
616 fire,
617 Some(Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap())
618 );
619 }
620
621 #[test]
622 fn test_no_fire_when_next_is_future() {
623 let pj = make_periodic("0 * * * *", "UTC");
625 let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 0).unwrap();
626 let after = Some(Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
628
629 let fire = pj.latest_fire_time(now, after);
630 assert!(fire.is_none(), "Should not fire until 15:00");
631 }
632
633 #[test]
634 fn test_first_registration_null_last_enqueued() {
635 let pj = make_periodic("0 * * * *", "UTC");
637 let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 0).unwrap();
638
639 let fire = pj.latest_fire_time(now, None);
640 assert_eq!(
641 fire,
642 Some(Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()),
643 "Should enqueue the most recent past fire on first registration"
644 );
645 }
646
647 #[test]
648 fn test_no_backfill_only_latest_fire() {
649 let pj = make_periodic("* * * * *", "UTC");
651 let now = Utc.with_ymd_and_hms(2025, 6, 15, 15, 0, 0).unwrap();
652 let after = Some(Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
653
654 let fire = pj.latest_fire_time(now, after);
655 assert_eq!(
657 fire,
658 Some(Utc.with_ymd_and_hms(2025, 6, 15, 15, 0, 0).unwrap())
659 );
660 }
661
662 #[test]
663 fn test_timezone_aware_fire_time() {
664 let pj = make_periodic("0 9 * * *", "Pacific/Auckland");
666 let now = Utc.with_ymd_and_hms(2025, 6, 15, 21, 30, 0).unwrap();
669 let after = Some(Utc.with_ymd_and_hms(2025, 6, 14, 21, 0, 0).unwrap());
670
671 let fire = pj.latest_fire_time(now, after);
672 assert_eq!(
674 fire,
675 Some(Utc.with_ymd_and_hms(2025, 6, 15, 21, 0, 0).unwrap())
676 );
677 }
678
679 #[test]
680 fn test_dst_spring_forward() {
681 let pj = make_periodic("30 2 * * *", "US/Eastern");
685 let now = Utc.with_ymd_and_hms(2025, 3, 9, 12, 0, 0).unwrap();
686 let after = Some(Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap());
687
688 let fire = pj.latest_fire_time(now, after);
689 let fire_count = if fire.is_some() { 1 } else { 0 };
692 assert!(
693 fire_count <= 1,
694 "Should fire at most once during spring-forward"
695 );
696 }
697
698 #[test]
699 fn test_dst_fall_back() {
700 let pj = make_periodic("30 1 * * *", "US/Eastern");
703 let now = Utc.with_ymd_and_hms(2025, 11, 2, 12, 0, 0).unwrap();
704 let after = Some(Utc.with_ymd_and_hms(2025, 11, 1, 12, 0, 0).unwrap());
705
706 let fire = pj.latest_fire_time(now, after);
707 assert!(fire.is_some(), "Should fire once during fall-back");
708
709 let fire_time = fire.unwrap();
711 let second_fire = pj.latest_fire_time(now, Some(fire_time));
712 assert!(
713 second_fire.is_none(),
714 "Should not fire a second time during fall-back"
715 );
716 }
717
718 #[test]
719 fn test_invalid_priority() {
720 let result = PeriodicJob::builder("test", "0 9 * * *")
721 .priority(5)
722 .build_raw("test_job".to_string(), serde_json::json!({}));
723 assert!(result.is_err());
724 }
725
726 #[test]
727 fn test_invalid_max_attempts() {
728 let result = PeriodicJob::builder("test", "0 9 * * *")
729 .max_attempts(0)
730 .build_raw("test_job".to_string(), serde_json::json!({}));
731 assert!(result.is_err());
732 }
733
734 #[test]
735 fn test_next_fire_time_exact() {
736 let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 0).unwrap();
738 let next = next_fire_time_after("0 * * * *", "UTC", now);
739 assert_eq!(
740 next,
741 Some(Utc.with_ymd_and_hms(2025, 6, 15, 15, 0, 0).unwrap())
742 );
743 }
744
745 #[test]
746 fn test_next_fire_time_respects_timezone() {
747 let now = Utc.with_ymd_and_hms(2025, 6, 15, 20, 0, 0).unwrap();
750 let next = next_fire_time_after("0 9 * * *", "Pacific/Auckland", now);
751 assert_eq!(
752 next,
753 Some(Utc.with_ymd_and_hms(2025, 6, 15, 21, 0, 0).unwrap())
754 );
755
756 let next_utc = next_fire_time_after("0 9 * * *", "UTC", now);
758 assert_eq!(
759 next_utc,
760 Some(Utc.with_ymd_and_hms(2025, 6, 16, 9, 0, 0).unwrap())
761 );
762 }
763
764 #[test]
765 fn test_next_fire_time_dst_boundary() {
766 let now = Utc.with_ymd_and_hms(2025, 3, 9, 6, 30, 0).unwrap();
769 let next = next_fire_time_after("0 * * * *", "US/Eastern", now);
770 assert!(next.is_some());
771 let next = next.unwrap();
773 assert!(
774 next >= Utc.with_ymd_and_hms(2025, 3, 9, 7, 0, 0).unwrap(),
775 "should skip the non-existent 2:00 AM, got {next}"
776 );
777 }
778
779 #[test]
780 fn test_next_fire_time_invalid_input() {
781 let now = Utc::now();
782 assert!(next_fire_time_after("not a cron", "UTC", now).is_none());
783 assert!(next_fire_time_after("* * * * *", "Not/A/Zone", now).is_none());
784 }
785
786 #[test]
789 fn test_six_field_cron_accepted_by_builder() {
790 let result = PeriodicJob::builder("test", "30 0 9 * * *")
791 .build_raw("test_job".to_string(), serde_json::json!({}));
792 assert!(result.is_ok(), "6-field cron should be accepted");
793 }
794
795 #[test]
796 fn test_six_field_cron_every_15_seconds() {
797 let result = PeriodicJob::builder("fast", "*/15 * * * * *")
799 .build_raw("fast_job".to_string(), serde_json::json!({}));
800 assert!(result.is_ok(), "every-15-seconds cron should be accepted");
801 }
802
803 #[test]
804 fn test_six_field_next_fire_time() {
805 let now = Utc.with_ymd_and_hms(2025, 6, 15, 8, 0, 0).unwrap();
807 let next = next_fire_time_after("30 0 9 * * *", "UTC", now);
808 assert_eq!(
809 next,
810 Some(
811 Utc.with_ymd_and_hms(2025, 6, 15, 9, 0, 0).unwrap() + chrono::Duration::seconds(30)
812 ),
813 "should fire at 09:00:30"
814 );
815 }
816
817 #[test]
818 fn test_six_field_every_15_seconds_next_fire() {
819 let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 7).unwrap();
821 let next = next_fire_time_after("*/15 * * * * *", "UTC", now);
822 assert_eq!(
823 next,
824 Some(Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 15).unwrap()),
825 "should fire at next 15-second boundary"
826 );
827 }
828
829 #[test]
830 fn test_five_field_still_works() {
831 let result = PeriodicJob::builder("classic", "0 9 * * *")
833 .build_raw("classic_job".to_string(), serde_json::json!({}));
834 assert!(result.is_ok(), "5-field cron should still be accepted");
835
836 let now = Utc.with_ymd_and_hms(2025, 6, 15, 8, 0, 0).unwrap();
837 let next = next_fire_time_after("0 9 * * *", "UTC", now);
838 assert_eq!(
839 next,
840 Some(Utc.with_ymd_and_hms(2025, 6, 15, 9, 0, 0).unwrap())
841 );
842 }
843
844 #[test]
845 fn test_six_field_latest_fire_time() {
846 let job = PeriodicJob::builder("sec_job", "0 */5 * * * *")
848 .build_raw("sec_job".to_string(), serde_json::json!({}))
849 .unwrap();
850
851 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 7, 0).unwrap();
852 let latest = job.latest_fire_time(now, None);
853 assert!(
854 latest.is_some(),
855 "6-field cron should produce a latest fire time"
856 );
857 assert_eq!(
860 latest.unwrap(),
861 Utc.with_ymd_and_hms(2025, 6, 15, 12, 5, 0).unwrap()
862 );
863 }
864}