use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uptrakit_shared_types::UpdateCategory;
use uuid::Uuid;
use crate::software_items::TriggerUpdateStatus;
use crate::validation::{Validate, ValidationError};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostBatchUpdateRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category_filter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_item_ids: Option<Vec<Uuid>>,
}
impl Validate for HostBatchUpdateRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self
.category_filter
.as_deref()
.is_some_and(|cat| cat.parse::<UpdateCategory>().is_err())
{
return Err(ValidationError {
field: "category_filter",
message: "must be a valid update category".to_string(),
});
}
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ItemBatchUpdateRequest {
pub to_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_ids: Option<Vec<Uuid>>,
}
impl Validate for ItemBatchUpdateRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self.to_version.trim().is_empty() {
return Err(ValidationError {
field: "to_version",
message: "must not be empty".to_string(),
});
}
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchUpdateResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_id: Option<Uuid>,
pub total_created: usize,
pub updates: Vec<BatchUpdateItem>,
pub skipped: Vec<BatchSkippedItem>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchUpdateItem {
pub update_history_id: Uuid,
pub software_item_id: Uuid,
pub software_item_name: String,
pub host_id: Uuid,
pub host_name: String,
pub to_version: String,
pub trigger_status: TriggerUpdateStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchSkippedItem {
pub software_item_id: Uuid,
pub software_item_name: String,
pub host_id: Uuid,
pub host_name: String,
pub reason: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateBatchSummaryResponse {
pub id: Uuid,
pub batch_type: String,
pub status: String,
pub total_count: i32,
pub completed_count: i64,
pub failed_count: i64,
pub pending_count: i64,
pub actor_type: String,
pub actor_id: String,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, format = DateTime)
)]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(
feature = "openapi",
schema(value_type = Option<String>, format = DateTime)
)]
pub completed_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateBatchDetailResponse {
pub id: Uuid,
pub batch_type: String,
pub status: String,
pub total_count: i32,
pub completed_count: i64,
pub failed_count: i64,
pub pending_count: i64,
pub actor_type: String,
pub actor_id: String,
pub updates: Vec<UpdateBatchItemSummary>,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, format = DateTime)
)]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(
feature = "openapi",
schema(value_type = Option<String>, format = DateTime)
)]
pub completed_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateBatchItemSummary {
pub update_history_id: Uuid,
pub host_id: Uuid,
pub host_name: String,
pub software_item_id: Uuid,
pub software_item_name: String,
pub to_version: String,
pub status: String,
pub update_category: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema, utoipa::IntoParams))]
pub struct UpdateBatchListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub page: Option<u64>,
pub per_page: Option<u64>,
}
impl UpdateBatchListQuery {
pub fn pagination(&self) -> crate::pagination::PaginationParams {
crate::pagination::PaginationParams {
page: self.page,
per_page: self.per_page,
}
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
#[test]
fn host_batch_request_validate_valid_category() {
for category in ["security", "bugfix", "feature", "unknown"] {
let req = HostBatchUpdateRequest {
category_filter: Some(category.to_string()),
exclude_item_ids: None,
};
assert!(req.validate().is_ok(), "expected {category:?} to be valid");
}
}
#[test]
fn host_batch_request_validate_no_filter() {
let req = HostBatchUpdateRequest {
category_filter: None,
exclude_item_ids: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn host_batch_request_validate_invalid_category() {
let req = HostBatchUpdateRequest {
category_filter: Some("invalid".to_string()),
exclude_item_ids: None,
};
let err = req.validate().unwrap_err();
assert_eq!(err.field, "category_filter");
}
#[test]
fn item_batch_request_validate_valid() {
let req = ItemBatchUpdateRequest {
to_version: "2.0.0".to_string(),
host_ids: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn item_batch_request_validate_empty_version() {
let req = ItemBatchUpdateRequest {
to_version: " ".to_string(),
host_ids: None,
};
let err = req.validate().unwrap_err();
assert_eq!(err.field, "to_version");
}
#[test]
fn batch_update_response_serializes() {
let resp = BatchUpdateResponse {
batch_id: Some(Uuid::nil()),
total_created: 2,
updates: vec![],
skipped: vec![],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"total_created\":2"));
}
#[test]
fn batch_update_response_no_batch_id_skips() {
let resp = BatchUpdateResponse {
batch_id: None,
total_created: 0,
updates: vec![],
skipped: vec![],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(!json.contains("batch_id"));
}
}