Skip to main content

awa_model/
cron.rs

1//! Periodic/cron job types and database operations.
2//!
3//! Schedules are defined in application code, synced to `awa.cron_jobs` via UPSERT,
4//! and evaluated by the leader to atomically enqueue jobs.
5
6use crate::error::AwaError;
7use crate::job::JobRow;
8use chrono::{DateTime, Utc};
9use croner::Cron;
10use serde::Serialize;
11use sqlx::PgExecutor;
12
13/// How AWA handles cron fires missed because evaluation was delayed.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum CronMissedFirePolicy {
17    /// Enqueue only the latest due fire. This preserves pre-existing behavior.
18    Coalesce,
19    /// Enqueue each missed fire in timestamp order, subject to worker catch-up limits.
20    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/// A periodic job schedule definition.
43///
44/// Created via `PeriodicJob::builder(name, cron_expr).build(args)`.
45#[derive(Debug, Clone)]
46pub struct PeriodicJob {
47    /// Unique name identifying this schedule (e.g., "daily_report").
48    pub name: String,
49    /// Cron expression (e.g., "0 9 * * *").
50    pub cron_expr: String,
51    /// IANA timezone (e.g., "Pacific/Auckland"). Defaults to "UTC".
52    pub timezone: String,
53    /// Job kind (derived from JobArgs trait).
54    pub kind: String,
55    /// Target queue. Defaults to "default".
56    pub queue: String,
57    /// Serialized job arguments.
58    pub args: serde_json::Value,
59    /// Job priority (1-4). Defaults to 2.
60    pub priority: i16,
61    /// Max retry attempts. Defaults to 25.
62    pub max_attempts: i16,
63    /// Tags attached to created jobs.
64    pub tags: Vec<String>,
65    /// Extra metadata merged into created jobs.
66    pub metadata: serde_json::Value,
67    /// How delayed cron evaluation handles missed scheduled fire times.
68    pub missed_fire_policy: CronMissedFirePolicy,
69}
70
71impl PeriodicJob {
72    /// Start building a periodic job with a name and cron expression.
73    ///
74    /// The cron expression is validated eagerly — invalid expressions
75    /// cause `build()` to return an error.
76    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    /// Compute the latest fire time <= `now` that is strictly after `after`.
91    ///
92    /// Returns `None` if no fire time exists in the range (after, now].
93    /// This handles both "first registration" (after=None → find latest past fire)
94    /// and "regular evaluation" (after=Some(last_enqueued_at)).
95    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        // Walk backwards from now to find the most recent fire time.
113        // croner doesn't have a "previous" iterator, so we find the fire time
114        // by iterating forward from a start point.
115        let search_start = match after {
116            Some(after_time) => after_time.with_timezone(&tz),
117            // For first registration, search from 24h ago to avoid unbounded iteration
118            None => now_tz - chrono::Duration::hours(24),
119        };
120
121        let mut latest_fire: Option<DateTime<Utc>> = None;
122
123        // Iterate forward from search_start, collecting fire times <= now
124        for fire_time in cron.clone().iter_from(search_start) {
125            let fire_utc = fire_time.with_timezone(&Utc);
126
127            // Stop once we've passed now
128            if fire_utc > now {
129                break;
130            }
131
132            // Skip fires at or before the `after` boundary
133            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/// Builder for `PeriodicJob`.
147#[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    /// Set the IANA timezone (e.g., "Pacific/Auckland").
162    pub fn timezone(mut self, timezone: impl Into<String>) -> Self {
163        self.timezone = timezone.into();
164        self
165    }
166
167    /// Set the target queue.
168    pub fn queue(mut self, queue: impl Into<String>) -> Self {
169        self.queue = queue.into();
170        self
171    }
172
173    /// Set the job priority (1-4).
174    pub fn priority(mut self, priority: i16) -> Self {
175        self.priority = priority;
176        self
177    }
178
179    /// Set the max retry attempts.
180    pub fn max_attempts(mut self, max_attempts: i16) -> Self {
181        self.max_attempts = max_attempts;
182        self
183    }
184
185    /// Set tags for created jobs.
186    pub fn tags(mut self, tags: Vec<String>) -> Self {
187        self.tags = tags;
188        self
189    }
190
191    /// Set extra metadata for created jobs.
192    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
193        self.metadata = metadata;
194        self
195    }
196
197    /// Set how delayed cron evaluation handles missed scheduled fire times.
198    pub fn missed_fire_policy(mut self, policy: CronMissedFirePolicy) -> Self {
199        self.missed_fire_policy = policy;
200        self
201    }
202
203    /// Build the periodic job, validating the cron expression and timezone.
204    ///
205    /// The `args` parameter must implement `JobArgs` — the kind is derived
206    /// from the type and args are serialized to JSON.
207    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    /// Build from raw kind and args JSON (used by Python bindings).
212    pub fn build_raw(self, kind: String, args: serde_json::Value) -> Result<PeriodicJob, AwaError> {
213        // Validate cron expression
214        Cron::new(&self.cron_expr)
215            .with_seconds_optional()
216            .parse()
217            .map_err(|err| AwaError::Validation(format!("invalid cron expression: {err}")))?;
218
219        // Validate timezone
220        self.timezone
221            .parse::<chrono_tz::Tz>()
222            .map_err(|err| AwaError::Validation(format!("invalid timezone: {err}")))?;
223
224        // Validate priority
225        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        // Validate max_attempts
233        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/// A row from the `awa.cron_jobs` table.
257#[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    /// When the schedule was paused, or NULL if active. While paused,
274    /// the evaluator skips this row and `atomic_enqueue` refuses to
275    /// fire. `last_enqueued_at` is preserved across pause, so the
276    /// existing `missed_fire_policy` decides catch-up on resume.
277    pub paused_at: Option<DateTime<Utc>>,
278    pub paused_by: Option<String>,
279}
280
281impl CronJobRow {
282    /// Whether the schedule is currently paused.
283    pub fn is_paused(&self) -> bool {
284        self.paused_at.is_some()
285    }
286}
287
288/// Upsert a periodic job schedule into `awa.cron_jobs`.
289///
290/// Additive only — never deletes rows not in the input set.
291pub 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
330/// Compute the next fire time for a cron expression after now.
331///
332/// Returns `None` if the expression or timezone is invalid.
333pub fn next_fire_time(cron_expr: &str, timezone: &str) -> Option<DateTime<Utc>> {
334    next_fire_time_after(cron_expr, timezone, Utc::now())
335}
336
337/// Compute the next fire time for a cron expression after a given timestamp.
338///
339/// Testable variant — accepts an explicit `after` time instead of using the clock.
340/// Returns `None` if the expression or timezone is invalid.
341pub 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
353/// Load all cron job rows from `awa.cron_jobs`.
354pub 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
364/// Delete a cron job schedule by name.
365pub 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
376/// Pause a cron schedule. The evaluator skips paused schedules and
377/// `atomic_enqueue` refuses to fire while `paused_at IS NOT NULL`.
378///
379/// `last_enqueued_at` is left untouched so the schedule's existing
380/// `missed_fire_policy` decides catch-up behaviour on resume.
381///
382/// Pausing an already-paused schedule refreshes `paused_at` and
383/// `paused_by`. Returns `true` if a row was updated.
384pub 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
406/// Resume a paused cron schedule. Clears `paused_at` and `paused_by`.
407///
408/// Resuming an already-active schedule is a no-op at the row level
409/// (the UPDATE matches but the columns are already NULL). Returns
410/// `true` if a row was updated.
411pub 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
428/// Atomically mark a cron job as enqueued AND insert the resulting job.
429///
430/// Uses a single CTE so that both the UPDATE and INSERT happen in one
431/// atomic operation. If the process crashes mid-transaction, Postgres
432/// rolls back both. If another leader already claimed this fire time
433/// (last_enqueued_at no longer matches), the UPDATE matches 0 rows
434/// and the INSERT produces nothing.
435///
436/// Returns the inserted job row, or `None` if the fire was already claimed.
437pub 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
482/// Trigger an immediate run of a cron job without updating last_enqueued_at.
483///
484/// Reads the cron job config from `awa.cron_jobs` and inserts a new job
485/// directly. Does NOT update `last_enqueued_at` so the normal schedule
486/// is unaffected. Works on paused schedules — pause stops *automatic*
487/// fires; manual trigger is an explicit operator action.
488pub 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        // Every hour at :00
610        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        // Every hour at :00
624        let pj = make_periodic("0 * * * *", "UTC");
625        let now = Utc.with_ymd_and_hms(2025, 6, 15, 14, 35, 0).unwrap();
626        // Already fired at 14:00
627        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        // Every hour at :00, registered at 14:35 with no previous fire
636        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        // Every minute, last enqueued 1 hour ago
650        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        // Should return 15:00, not 14:01 — only the latest missed fire
656        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        // 9 AM daily in Auckland timezone
665        let pj = make_periodic("0 9 * * *", "Pacific/Auckland");
666        // It's 2025-06-15 21:30 UTC = 2025-06-16 09:30 NZST
667        // So 09:00 NZST on June 16 = 21:00 UTC on June 15
668        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        // 09:00 NZST on June 16 = 21:00 UTC on June 15
673        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        // 2:30 AM US/Eastern on March 9 2025 — clocks spring forward from 2:00 to 3:00
682        // Schedule at 2:30 AM should fire once (the 2:30 time doesn't exist, so croner
683        // should skip it or fire at the next valid time)
684        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        // On spring-forward day, 2:30 AM doesn't exist. croner may skip it entirely
690        // or map it to 3:30 AM. Either way, we should get at most one fire.
691        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        // 1:30 AM US/Eastern on Nov 2 2025 — clocks fall back from 2:00 to 1:00
701        // 1:30 AM happens twice. Should fire exactly once.
702        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        // Verify it's only one fire by checking that after this fire, no more fires exist
710        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        // At 14:35 UTC, next fire for "every hour at :00" should be 15:00
737        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        // At 2025-06-15 20:00 UTC, next "9 AM daily" in Auckland should be
748        // 2025-06-15 21:00 UTC (= 2025-06-16 09:00 NZST, UTC+12 in June)
749        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        // Same time, UTC schedule — next 9 AM UTC is the next day
757        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        // US/Eastern spring-forward: 2025-03-09 at 2:00 AM clocks jump to 3:00 AM
767        // At 1:30 AM EST (06:30 UTC), next "every hour at :00" should skip 2:00 AM
768        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        // The 2:00 AM hour doesn't exist; croner should give us 3:00 AM EDT (07:00 UTC)
772        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    // ── 6-field cron (seconds precision) ──────────────────────────
787
788    #[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        // "*/15 * * * * *" = every 15 seconds
798        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        // "30 0 9 * * *" = daily at 09:00:30 UTC
806        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        // "*/15 * * * * *" at 14:35:07 → next fire at 14:35:15
820        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        // Ensure 5-field expressions continue to work as before
832        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        // Verify latest_fire_time also handles 6-field
847        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        // "0 */5 * * * *" = at second 0 of every 5th minute
858        // At 12:07:00, latest fire was 12:05:00
859        assert_eq!(
860            latest.unwrap(),
861            Utc.with_ymd_and_hms(2025, 6, 15, 12, 5, 0).unwrap()
862        );
863    }
864}