use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::validation::{Validate, ValidationError};
pub const TASK_TYPE_FETCH_RELEASES: &str = "fetch_releases";
pub const TASK_TYPE_DETECT_VERSION: &str = "detect_version";
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ScheduledTaskResponse {
pub id: Uuid,
pub task_type: String,
pub label: String,
pub interval_seconds: i32,
pub jitter_seconds: i32,
pub enabled: bool,
pub task_config: Option<serde_json::Value>,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
pub last_run_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub next_run_at: OffsetDateTime,
pub is_running: bool,
pub last_error: Option<String>,
pub run_count: i64,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub updated_at: OffsetDateTime,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateScheduledTaskRequest {
pub interval_seconds: Option<i32>,
pub jitter_seconds: Option<i32>,
pub enabled: Option<bool>,
pub task_config: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TriggerScheduledTaskResponse {
pub triggered: bool,
pub message: String,
}
impl Validate for UpdateScheduledTaskRequest {
fn validate(&self) -> Result<(), ValidationError> {
if let Some(interval) = self.interval_seconds
&& interval <= 0
{
return Err(ValidationError {
field: "interval_seconds",
message: "must be greater than 0".to_string(),
});
}
if let Some(jitter) = self.jitter_seconds
&& jitter < 0
{
return Err(ValidationError {
field: "jitter_seconds",
message: "must be >= 0".to_string(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
fn sample_uuid() -> Uuid {
Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
.expect("hard-coded UUID should be valid")
}
#[test]
fn scheduled_task_response_round_trip_all_fields() {
use time::macros::datetime;
let resp = ScheduledTaskResponse {
id: sample_uuid(),
task_type: "fetch_releases".to_string(),
label: "Fetch Latest Releases".to_string(),
interval_seconds: 21600,
jitter_seconds: 300,
enabled: true,
task_config: Some(serde_json::json!({"timeout": 30})),
last_run_at: Some(datetime!(2025-06-01 00:00:00 UTC)),
next_run_at: datetime!(2025-06-02 00:00:00 UTC),
is_running: false,
last_error: Some("timeout exceeded".to_string()),
run_count: 42,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-06-01 00:00:00 UTC),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: ScheduledTaskResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.id, sample_uuid());
assert_eq!(deserialized.task_type, "fetch_releases");
assert_eq!(deserialized.label, "Fetch Latest Releases");
assert_eq!(deserialized.interval_seconds, 21600);
assert_eq!(deserialized.jitter_seconds, 300);
assert!(deserialized.enabled);
assert!(deserialized.task_config.is_some());
assert_eq!(
deserialized.last_run_at,
Some(datetime!(2025-06-01 00:00:00 UTC))
);
assert_eq!(deserialized.next_run_at, datetime!(2025-06-02 00:00:00 UTC));
assert!(!deserialized.is_running);
assert_eq!(deserialized.last_error.as_deref(), Some("timeout exceeded"));
assert_eq!(deserialized.run_count, 42);
}
#[test]
fn scheduled_task_response_round_trip_none_fields() {
use time::macros::datetime;
let resp = ScheduledTaskResponse {
id: sample_uuid(),
task_type: "cleanup".to_string(),
label: "Weekly cleanup".to_string(),
interval_seconds: 86400,
jitter_seconds: 300,
enabled: false,
task_config: None,
last_run_at: None,
next_run_at: datetime!(2025-06-08 00:00:00 UTC),
is_running: false,
last_error: None,
run_count: 0,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-01-01 00:00:00 UTC),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: ScheduledTaskResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.task_config.is_none());
assert!(deserialized.last_run_at.is_none());
assert!(deserialized.last_error.is_none());
assert!(!deserialized.enabled);
assert_eq!(deserialized.run_count, 0);
}
#[test]
fn scheduled_task_response_is_running_true() {
use time::macros::datetime;
let resp = ScheduledTaskResponse {
id: sample_uuid(),
task_type: "sync".to_string(),
label: "Sync".to_string(),
interval_seconds: 300,
jitter_seconds: 30,
enabled: true,
task_config: None,
last_run_at: None,
next_run_at: datetime!(2025-06-01 00:05:00 UTC),
is_running: true,
last_error: None,
run_count: 10,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-01-01 00:00:00 UTC),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: ScheduledTaskResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.is_running);
}
#[test]
fn update_scheduled_task_request_round_trip_all_fields() {
let req = UpdateScheduledTaskRequest {
interval_seconds: Some(7200),
jitter_seconds: Some(60),
enabled: Some(false),
task_config: Some(serde_json::json!({"retries": 3})),
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: UpdateScheduledTaskRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.interval_seconds, Some(7200));
assert_eq!(deserialized.jitter_seconds, Some(60));
assert_eq!(deserialized.enabled, Some(false));
assert!(deserialized.task_config.is_some());
}
#[test]
fn update_scheduled_task_request_round_trip_none_fields() {
let req = UpdateScheduledTaskRequest {
interval_seconds: None,
jitter_seconds: None,
enabled: None,
task_config: None,
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: UpdateScheduledTaskRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.interval_seconds.is_none());
assert!(deserialized.jitter_seconds.is_none());
assert!(deserialized.enabled.is_none());
assert!(deserialized.task_config.is_none());
}
#[test]
fn update_scheduled_task_request_from_empty_json() {
let json = r#"{}"#;
let req: UpdateScheduledTaskRequest =
serde_json::from_str(json).expect("deserialization should succeed");
assert!(req.interval_seconds.is_none());
assert!(req.jitter_seconds.is_none());
assert!(req.enabled.is_none());
assert!(req.task_config.is_none());
}
#[test]
fn validate_valid_interval() {
let req = UpdateScheduledTaskRequest {
interval_seconds: Some(300),
jitter_seconds: Some(30),
enabled: None,
task_config: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_none_interval_passes() {
let req = UpdateScheduledTaskRequest {
interval_seconds: None,
jitter_seconds: None,
enabled: Some(true),
task_config: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_zero_interval_fails() {
let req = UpdateScheduledTaskRequest {
interval_seconds: Some(0),
jitter_seconds: None,
enabled: None,
task_config: None,
};
let err = req
.validate()
.expect_err("zero interval should fail validation");
assert_eq!(err.field, "interval_seconds");
assert!(err.message.contains("greater than 0"));
}
#[test]
fn validate_negative_interval_fails() {
let req = UpdateScheduledTaskRequest {
interval_seconds: Some(-1),
jitter_seconds: None,
enabled: None,
task_config: None,
};
let err = req
.validate()
.expect_err("negative interval should fail validation");
assert_eq!(err.field, "interval_seconds");
}
#[test]
fn validate_negative_jitter_fails() {
let req = UpdateScheduledTaskRequest {
interval_seconds: None,
jitter_seconds: Some(-1),
enabled: None,
task_config: None,
};
let err = req
.validate()
.expect_err("negative jitter should fail validation");
assert_eq!(err.field, "jitter_seconds");
assert!(err.message.contains(">= 0"));
}
#[test]
fn validate_zero_jitter_passes() {
let req = UpdateScheduledTaskRequest {
interval_seconds: Some(300),
jitter_seconds: Some(0),
enabled: None,
task_config: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn trigger_scheduled_task_response_round_trip() {
let resp = TriggerScheduledTaskResponse {
triggered: true,
message: "Task started".to_string(),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerScheduledTaskResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.triggered);
assert_eq!(deserialized.message, "Task started");
}
#[test]
fn trigger_scheduled_task_response_not_triggered() {
let resp = TriggerScheduledTaskResponse {
triggered: false,
message: "Task is already running".to_string(),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerScheduledTaskResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(!deserialized.triggered);
assert_eq!(deserialized.message, "Task is already running");
}
}