Skip to main content

uptrakit_web_api_types/
scheduler.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7/// Task type string for the fetch-releases scheduler task.
8///
9/// Use this constant instead of a raw string literal wherever the `task_type`
10/// field of [`ScheduledTaskResponse`] is compared or displayed, to avoid a
11/// silent mismatch if the DB-side string value is ever changed.
12///
13/// This task fetches the latest available versions from external APIs and
14/// dispatches agent-side package-index queries. It replaces the old
15/// `version_check` task, which was renamed in migration
16/// `m20260307_000001_split_version_check`.
17pub const TASK_TYPE_FETCH_RELEASES: &str = "fetch_releases";
18
19/// Task type string for the detect-version scheduler task.
20///
21/// This task detects the currently installed versions on all agent hosts.
22/// It is the counterpart to [`TASK_TYPE_FETCH_RELEASES`] and was introduced
23/// alongside it when the old `version_check` task was split in two.
24pub const TASK_TYPE_DETECT_VERSION: &str = "detect_version";
25
26/// Response for a single scheduled task.
27#[derive(Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub struct ScheduledTaskResponse {
30    pub id: Uuid,
31    pub task_type: String,
32    pub label: String,
33    pub interval_seconds: i32,
34    pub jitter_seconds: i32,
35    pub enabled: bool,
36    pub task_config: Option<serde_json::Value>,
37    #[serde(with = "time::serde::rfc3339::option")]
38    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
39    pub last_run_at: Option<OffsetDateTime>,
40    #[serde(with = "time::serde::rfc3339")]
41    #[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
42    pub next_run_at: OffsetDateTime,
43    pub is_running: bool,
44    pub last_error: Option<String>,
45    pub run_count: i64,
46    #[serde(with = "time::serde::rfc3339")]
47    #[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
48    pub created_at: OffsetDateTime,
49    #[serde(with = "time::serde::rfc3339")]
50    #[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
51    pub updated_at: OffsetDateTime,
52}
53
54/// Request to update a scheduled task.
55#[derive(Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57pub struct UpdateScheduledTaskRequest {
58    /// Base repeat interval in seconds. Must be > 0.
59    pub interval_seconds: Option<i32>,
60    /// Maximum random jitter added to each interval in seconds. Must be >= 0.
61    pub jitter_seconds: Option<i32>,
62    /// Enable or disable the task.
63    pub enabled: Option<bool>,
64    /// Per-task configuration (JSON). Send null to clear.
65    pub task_config: Option<serde_json::Value>,
66}
67
68/// Response when triggering immediate execution of a task.
69#[derive(Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct TriggerScheduledTaskResponse {
72    /// Whether the trigger was applied.
73    pub triggered: bool,
74    /// Human-readable status message.
75    pub message: String,
76}
77
78impl Validate for UpdateScheduledTaskRequest {
79    fn validate(&self) -> Result<(), ValidationError> {
80        if let Some(interval) = self.interval_seconds
81            && interval <= 0
82        {
83            return Err(ValidationError {
84                field: "interval_seconds",
85                message: "must be greater than 0".to_string(),
86            });
87        }
88
89        if let Some(jitter) = self.jitter_seconds
90            && jitter < 0
91        {
92            return Err(ValidationError {
93                field: "jitter_seconds",
94                message: "must be >= 0".to_string(),
95            });
96        }
97
98        Ok(())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn sample_uuid() -> Uuid {
107        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
108            .expect("hard-coded UUID should be valid")
109    }
110
111    // ── ScheduledTaskResponse ────────────────────────────────────────
112
113    #[test]
114    fn scheduled_task_response_round_trip_all_fields() {
115        use time::macros::datetime;
116        let resp = ScheduledTaskResponse {
117            id: sample_uuid(),
118            task_type: "fetch_releases".to_string(),
119            label: "Fetch Latest Releases".to_string(),
120            interval_seconds: 21600,
121            jitter_seconds: 300,
122            enabled: true,
123            task_config: Some(serde_json::json!({"timeout": 30})),
124            last_run_at: Some(datetime!(2025-06-01 00:00:00 UTC)),
125            next_run_at: datetime!(2025-06-02 00:00:00 UTC),
126            is_running: false,
127            last_error: Some("timeout exceeded".to_string()),
128            run_count: 42,
129            created_at: datetime!(2025-01-01 00:00:00 UTC),
130            updated_at: datetime!(2025-06-01 00:00:00 UTC),
131        };
132        let json = serde_json::to_string(&resp).expect("serialization should succeed");
133        let deserialized: ScheduledTaskResponse =
134            serde_json::from_str(&json).expect("deserialization should succeed");
135        assert_eq!(deserialized.id, sample_uuid());
136        assert_eq!(deserialized.task_type, "fetch_releases");
137        assert_eq!(deserialized.label, "Fetch Latest Releases");
138        assert_eq!(deserialized.interval_seconds, 21600);
139        assert_eq!(deserialized.jitter_seconds, 300);
140        assert!(deserialized.enabled);
141        assert!(deserialized.task_config.is_some());
142        assert_eq!(
143            deserialized.last_run_at,
144            Some(datetime!(2025-06-01 00:00:00 UTC))
145        );
146        assert_eq!(deserialized.next_run_at, datetime!(2025-06-02 00:00:00 UTC));
147        assert!(!deserialized.is_running);
148        assert_eq!(deserialized.last_error.as_deref(), Some("timeout exceeded"));
149        assert_eq!(deserialized.run_count, 42);
150    }
151
152    #[test]
153    fn scheduled_task_response_round_trip_none_fields() {
154        use time::macros::datetime;
155        let resp = ScheduledTaskResponse {
156            id: sample_uuid(),
157            task_type: "cleanup".to_string(),
158            label: "Weekly cleanup".to_string(),
159            interval_seconds: 86400,
160            jitter_seconds: 300,
161            enabled: false,
162            task_config: None,
163            last_run_at: None,
164            next_run_at: datetime!(2025-06-08 00:00:00 UTC),
165            is_running: false,
166            last_error: None,
167            run_count: 0,
168            created_at: datetime!(2025-01-01 00:00:00 UTC),
169            updated_at: datetime!(2025-01-01 00:00:00 UTC),
170        };
171        let json = serde_json::to_string(&resp).expect("serialization should succeed");
172        let deserialized: ScheduledTaskResponse =
173            serde_json::from_str(&json).expect("deserialization should succeed");
174        assert!(deserialized.task_config.is_none());
175        assert!(deserialized.last_run_at.is_none());
176        assert!(deserialized.last_error.is_none());
177        assert!(!deserialized.enabled);
178        assert_eq!(deserialized.run_count, 0);
179    }
180
181    #[test]
182    fn scheduled_task_response_is_running_true() {
183        use time::macros::datetime;
184        let resp = ScheduledTaskResponse {
185            id: sample_uuid(),
186            task_type: "sync".to_string(),
187            label: "Sync".to_string(),
188            interval_seconds: 300,
189            jitter_seconds: 30,
190            enabled: true,
191            task_config: None,
192            last_run_at: None,
193            next_run_at: datetime!(2025-06-01 00:05:00 UTC),
194            is_running: true,
195            last_error: None,
196            run_count: 10,
197            created_at: datetime!(2025-01-01 00:00:00 UTC),
198            updated_at: datetime!(2025-01-01 00:00:00 UTC),
199        };
200        let json = serde_json::to_string(&resp).expect("serialization should succeed");
201        let deserialized: ScheduledTaskResponse =
202            serde_json::from_str(&json).expect("deserialization should succeed");
203        assert!(deserialized.is_running);
204    }
205
206    // ── UpdateScheduledTaskRequest ───────────────────────────────────
207
208    #[test]
209    fn update_scheduled_task_request_round_trip_all_fields() {
210        let req = UpdateScheduledTaskRequest {
211            interval_seconds: Some(7200),
212            jitter_seconds: Some(60),
213            enabled: Some(false),
214            task_config: Some(serde_json::json!({"retries": 3})),
215        };
216        let json = serde_json::to_string(&req).expect("serialization should succeed");
217        let deserialized: UpdateScheduledTaskRequest =
218            serde_json::from_str(&json).expect("deserialization should succeed");
219        assert_eq!(deserialized.interval_seconds, Some(7200));
220        assert_eq!(deserialized.jitter_seconds, Some(60));
221        assert_eq!(deserialized.enabled, Some(false));
222        assert!(deserialized.task_config.is_some());
223    }
224
225    #[test]
226    fn update_scheduled_task_request_round_trip_none_fields() {
227        let req = UpdateScheduledTaskRequest {
228            interval_seconds: None,
229            jitter_seconds: None,
230            enabled: None,
231            task_config: None,
232        };
233        let json = serde_json::to_string(&req).expect("serialization should succeed");
234        let deserialized: UpdateScheduledTaskRequest =
235            serde_json::from_str(&json).expect("deserialization should succeed");
236        assert!(deserialized.interval_seconds.is_none());
237        assert!(deserialized.jitter_seconds.is_none());
238        assert!(deserialized.enabled.is_none());
239        assert!(deserialized.task_config.is_none());
240    }
241
242    #[test]
243    fn update_scheduled_task_request_from_empty_json() {
244        let json = r#"{}"#;
245        let req: UpdateScheduledTaskRequest =
246            serde_json::from_str(json).expect("deserialization should succeed");
247        assert!(req.interval_seconds.is_none());
248        assert!(req.jitter_seconds.is_none());
249        assert!(req.enabled.is_none());
250        assert!(req.task_config.is_none());
251    }
252
253    // ── UpdateScheduledTaskRequest validation ────────────────────────
254
255    #[test]
256    fn validate_valid_interval() {
257        let req = UpdateScheduledTaskRequest {
258            interval_seconds: Some(300),
259            jitter_seconds: Some(30),
260            enabled: None,
261            task_config: None,
262        };
263        assert!(req.validate().is_ok());
264    }
265
266    #[test]
267    fn validate_none_interval_passes() {
268        let req = UpdateScheduledTaskRequest {
269            interval_seconds: None,
270            jitter_seconds: None,
271            enabled: Some(true),
272            task_config: None,
273        };
274        assert!(req.validate().is_ok());
275    }
276
277    #[test]
278    fn validate_zero_interval_fails() {
279        let req = UpdateScheduledTaskRequest {
280            interval_seconds: Some(0),
281            jitter_seconds: None,
282            enabled: None,
283            task_config: None,
284        };
285        let err = req
286            .validate()
287            .expect_err("zero interval should fail validation");
288        assert_eq!(err.field, "interval_seconds");
289        assert!(err.message.contains("greater than 0"));
290    }
291
292    #[test]
293    fn validate_negative_interval_fails() {
294        let req = UpdateScheduledTaskRequest {
295            interval_seconds: Some(-1),
296            jitter_seconds: None,
297            enabled: None,
298            task_config: None,
299        };
300        let err = req
301            .validate()
302            .expect_err("negative interval should fail validation");
303        assert_eq!(err.field, "interval_seconds");
304    }
305
306    #[test]
307    fn validate_negative_jitter_fails() {
308        let req = UpdateScheduledTaskRequest {
309            interval_seconds: None,
310            jitter_seconds: Some(-1),
311            enabled: None,
312            task_config: None,
313        };
314        let err = req
315            .validate()
316            .expect_err("negative jitter should fail validation");
317        assert_eq!(err.field, "jitter_seconds");
318        assert!(err.message.contains(">= 0"));
319    }
320
321    #[test]
322    fn validate_zero_jitter_passes() {
323        let req = UpdateScheduledTaskRequest {
324            interval_seconds: Some(300),
325            jitter_seconds: Some(0),
326            enabled: None,
327            task_config: None,
328        };
329        assert!(req.validate().is_ok());
330    }
331
332    // ── TriggerScheduledTaskResponse ─────────────────────────────────
333
334    #[test]
335    fn trigger_scheduled_task_response_round_trip() {
336        let resp = TriggerScheduledTaskResponse {
337            triggered: true,
338            message: "Task started".to_string(),
339        };
340        let json = serde_json::to_string(&resp).expect("serialization should succeed");
341        let deserialized: TriggerScheduledTaskResponse =
342            serde_json::from_str(&json).expect("deserialization should succeed");
343        assert!(deserialized.triggered);
344        assert_eq!(deserialized.message, "Task started");
345    }
346
347    #[test]
348    fn trigger_scheduled_task_response_not_triggered() {
349        let resp = TriggerScheduledTaskResponse {
350            triggered: false,
351            message: "Task is already running".to_string(),
352        };
353        let json = serde_json::to_string(&resp).expect("serialization should succeed");
354        let deserialized: TriggerScheduledTaskResponse =
355            serde_json::from_str(&json).expect("deserialization should succeed");
356        assert!(!deserialized.triggered);
357        assert_eq!(deserialized.message, "Task is already running");
358    }
359}