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    #![expect(
105        clippy::assertions_on_result_states,
106        reason = "test assertions — is_ok/is_err provides readable failure messages"
107    )]
108    use super::*;
109
110    fn sample_uuid() -> Uuid {
111        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
112            .expect("hard-coded UUID should be valid")
113    }
114
115    // ── ScheduledTaskResponse ────────────────────────────────────────
116
117    #[test]
118    fn scheduled_task_response_round_trip_all_fields() {
119        use time::macros::datetime;
120        let resp = ScheduledTaskResponse {
121            id: sample_uuid(),
122            task_type: "fetch_releases".to_string(),
123            label: "Fetch Latest Releases".to_string(),
124            interval_seconds: 21600,
125            jitter_seconds: 300,
126            enabled: true,
127            task_config: Some(serde_json::json!({"timeout": 30})),
128            last_run_at: Some(datetime!(2025-06-01 00:00:00 UTC)),
129            next_run_at: datetime!(2025-06-02 00:00:00 UTC),
130            is_running: false,
131            last_error: Some("timeout exceeded".to_string()),
132            run_count: 42,
133            created_at: datetime!(2025-01-01 00:00:00 UTC),
134            updated_at: datetime!(2025-06-01 00:00:00 UTC),
135        };
136        let json = serde_json::to_string(&resp).expect("serialization should succeed");
137        let deserialized: ScheduledTaskResponse =
138            serde_json::from_str(&json).expect("deserialization should succeed");
139        assert_eq!(deserialized.id, sample_uuid());
140        assert_eq!(deserialized.task_type, "fetch_releases");
141        assert_eq!(deserialized.label, "Fetch Latest Releases");
142        assert_eq!(deserialized.interval_seconds, 21600);
143        assert_eq!(deserialized.jitter_seconds, 300);
144        assert!(deserialized.enabled);
145        assert!(deserialized.task_config.is_some());
146        assert_eq!(
147            deserialized.last_run_at,
148            Some(datetime!(2025-06-01 00:00:00 UTC))
149        );
150        assert_eq!(deserialized.next_run_at, datetime!(2025-06-02 00:00:00 UTC));
151        assert!(!deserialized.is_running);
152        assert_eq!(deserialized.last_error.as_deref(), Some("timeout exceeded"));
153        assert_eq!(deserialized.run_count, 42);
154    }
155
156    #[test]
157    fn scheduled_task_response_round_trip_none_fields() {
158        use time::macros::datetime;
159        let resp = ScheduledTaskResponse {
160            id: sample_uuid(),
161            task_type: "cleanup".to_string(),
162            label: "Weekly cleanup".to_string(),
163            interval_seconds: 86400,
164            jitter_seconds: 300,
165            enabled: false,
166            task_config: None,
167            last_run_at: None,
168            next_run_at: datetime!(2025-06-08 00:00:00 UTC),
169            is_running: false,
170            last_error: None,
171            run_count: 0,
172            created_at: datetime!(2025-01-01 00:00:00 UTC),
173            updated_at: datetime!(2025-01-01 00:00:00 UTC),
174        };
175        let json = serde_json::to_string(&resp).expect("serialization should succeed");
176        let deserialized: ScheduledTaskResponse =
177            serde_json::from_str(&json).expect("deserialization should succeed");
178        assert!(deserialized.task_config.is_none());
179        assert!(deserialized.last_run_at.is_none());
180        assert!(deserialized.last_error.is_none());
181        assert!(!deserialized.enabled);
182        assert_eq!(deserialized.run_count, 0);
183    }
184
185    #[test]
186    fn scheduled_task_response_is_running_true() {
187        use time::macros::datetime;
188        let resp = ScheduledTaskResponse {
189            id: sample_uuid(),
190            task_type: "sync".to_string(),
191            label: "Sync".to_string(),
192            interval_seconds: 300,
193            jitter_seconds: 30,
194            enabled: true,
195            task_config: None,
196            last_run_at: None,
197            next_run_at: datetime!(2025-06-01 00:05:00 UTC),
198            is_running: true,
199            last_error: None,
200            run_count: 10,
201            created_at: datetime!(2025-01-01 00:00:00 UTC),
202            updated_at: datetime!(2025-01-01 00:00:00 UTC),
203        };
204        let json = serde_json::to_string(&resp).expect("serialization should succeed");
205        let deserialized: ScheduledTaskResponse =
206            serde_json::from_str(&json).expect("deserialization should succeed");
207        assert!(deserialized.is_running);
208    }
209
210    // ── UpdateScheduledTaskRequest ───────────────────────────────────
211
212    #[test]
213    fn update_scheduled_task_request_round_trip_all_fields() {
214        let req = UpdateScheduledTaskRequest {
215            interval_seconds: Some(7200),
216            jitter_seconds: Some(60),
217            enabled: Some(false),
218            task_config: Some(serde_json::json!({"retries": 3})),
219        };
220        let json = serde_json::to_string(&req).expect("serialization should succeed");
221        let deserialized: UpdateScheduledTaskRequest =
222            serde_json::from_str(&json).expect("deserialization should succeed");
223        assert_eq!(deserialized.interval_seconds, Some(7200));
224        assert_eq!(deserialized.jitter_seconds, Some(60));
225        assert_eq!(deserialized.enabled, Some(false));
226        assert!(deserialized.task_config.is_some());
227    }
228
229    #[test]
230    fn update_scheduled_task_request_round_trip_none_fields() {
231        let req = UpdateScheduledTaskRequest {
232            interval_seconds: None,
233            jitter_seconds: None,
234            enabled: None,
235            task_config: None,
236        };
237        let json = serde_json::to_string(&req).expect("serialization should succeed");
238        let deserialized: UpdateScheduledTaskRequest =
239            serde_json::from_str(&json).expect("deserialization should succeed");
240        assert!(deserialized.interval_seconds.is_none());
241        assert!(deserialized.jitter_seconds.is_none());
242        assert!(deserialized.enabled.is_none());
243        assert!(deserialized.task_config.is_none());
244    }
245
246    #[test]
247    fn update_scheduled_task_request_from_empty_json() {
248        let json = r#"{}"#;
249        let req: UpdateScheduledTaskRequest =
250            serde_json::from_str(json).expect("deserialization should succeed");
251        assert!(req.interval_seconds.is_none());
252        assert!(req.jitter_seconds.is_none());
253        assert!(req.enabled.is_none());
254        assert!(req.task_config.is_none());
255    }
256
257    // ── UpdateScheduledTaskRequest validation ────────────────────────
258
259    #[test]
260    fn validate_valid_interval() {
261        let req = UpdateScheduledTaskRequest {
262            interval_seconds: Some(300),
263            jitter_seconds: Some(30),
264            enabled: None,
265            task_config: None,
266        };
267        assert!(req.validate().is_ok());
268    }
269
270    #[test]
271    fn validate_none_interval_passes() {
272        let req = UpdateScheduledTaskRequest {
273            interval_seconds: None,
274            jitter_seconds: None,
275            enabled: Some(true),
276            task_config: None,
277        };
278        assert!(req.validate().is_ok());
279    }
280
281    #[test]
282    fn validate_zero_interval_fails() {
283        let req = UpdateScheduledTaskRequest {
284            interval_seconds: Some(0),
285            jitter_seconds: None,
286            enabled: None,
287            task_config: None,
288        };
289        let err = req
290            .validate()
291            .expect_err("zero interval should fail validation");
292        assert_eq!(err.field, "interval_seconds");
293        assert!(err.message.contains("greater than 0"));
294    }
295
296    #[test]
297    fn validate_negative_interval_fails() {
298        let req = UpdateScheduledTaskRequest {
299            interval_seconds: Some(-1),
300            jitter_seconds: None,
301            enabled: None,
302            task_config: None,
303        };
304        let err = req
305            .validate()
306            .expect_err("negative interval should fail validation");
307        assert_eq!(err.field, "interval_seconds");
308    }
309
310    #[test]
311    fn validate_negative_jitter_fails() {
312        let req = UpdateScheduledTaskRequest {
313            interval_seconds: None,
314            jitter_seconds: Some(-1),
315            enabled: None,
316            task_config: None,
317        };
318        let err = req
319            .validate()
320            .expect_err("negative jitter should fail validation");
321        assert_eq!(err.field, "jitter_seconds");
322        assert!(err.message.contains(">= 0"));
323    }
324
325    #[test]
326    fn validate_zero_jitter_passes() {
327        let req = UpdateScheduledTaskRequest {
328            interval_seconds: Some(300),
329            jitter_seconds: Some(0),
330            enabled: None,
331            task_config: None,
332        };
333        assert!(req.validate().is_ok());
334    }
335
336    // ── TriggerScheduledTaskResponse ─────────────────────────────────
337
338    #[test]
339    fn trigger_scheduled_task_response_round_trip() {
340        let resp = TriggerScheduledTaskResponse {
341            triggered: true,
342            message: "Task started".to_string(),
343        };
344        let json = serde_json::to_string(&resp).expect("serialization should succeed");
345        let deserialized: TriggerScheduledTaskResponse =
346            serde_json::from_str(&json).expect("deserialization should succeed");
347        assert!(deserialized.triggered);
348        assert_eq!(deserialized.message, "Task started");
349    }
350
351    #[test]
352    fn trigger_scheduled_task_response_not_triggered() {
353        let resp = TriggerScheduledTaskResponse {
354            triggered: false,
355            message: "Task is already running".to_string(),
356        };
357        let json = serde_json::to_string(&resp).expect("serialization should succeed");
358        let deserialized: TriggerScheduledTaskResponse =
359            serde_json::from_str(&json).expect("deserialization should succeed");
360        assert!(!deserialized.triggered);
361        assert_eq!(deserialized.message, "Task is already running");
362    }
363}