1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7pub const TASK_TYPE_FETCH_RELEASES: &str = "fetch_releases";
18
19pub const TASK_TYPE_DETECT_VERSION: &str = "detect_version";
25
26#[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#[derive(Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57pub struct UpdateScheduledTaskRequest {
58 pub interval_seconds: Option<i32>,
60 pub jitter_seconds: Option<i32>,
62 pub enabled: Option<bool>,
64 pub task_config: Option<serde_json::Value>,
66}
67
68#[derive(Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct TriggerScheduledTaskResponse {
72 pub triggered: bool,
74 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 #[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 #[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 #[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 #[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}