use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uptrakit_shared_types::{PluginRole, PluginTypeId};
use uuid::Uuid;
use crate::pagination::PaginationParams;
use crate::plugin_configs::CreatePluginConfigRequest;
use crate::validation::{Validate, ValidationError};
fn default_execution_site() -> String {
"auto".to_string()
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(try_from = "serde_json::Value", into = "serde_json::Value")]
pub struct JsonObjectMap(serde_json::Map<String, serde_json::Value>);
impl TryFrom<serde_json::Value> for JsonObjectMap {
type Error = ValidationError;
fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
crate::json_object::parse_json_object(value, "config_override").map(Self)
}
}
impl JsonObjectMap {
pub fn new(value: serde_json::Map<String, serde_json::Value>) -> Self {
Self(value)
}
pub fn is_object(&self) -> bool {
true
}
pub fn as_object(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.0
}
}
impl From<JsonObjectMap> for serde_json::Value {
fn from(value: JsonObjectMap) -> Self {
serde_json::Value::Object(value.0)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum IconUrlPatch {
#[default]
Keep,
Set(String),
Clear,
}
impl IconUrlPatch {
pub fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
pub fn from_json(value: Option<&serde_json::Value>) -> Result<Self, ValidationError> {
match value {
None => Ok(Self::Keep),
Some(serde_json::Value::Null) => Ok(Self::Clear),
Some(serde_json::Value::String(url)) => Ok(Self::Set(url.clone())),
Some(_) => Err(ValidationError {
field: "icon_url",
message: "icon_url must be null, a string, or omitted".to_string(),
}),
}
}
}
impl Serialize for IconUrlPatch {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Keep | Self::Clear => serializer.serialize_none(),
Self::Set(url) => url.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for IconUrlPatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match Option::<String>::deserialize(deserializer)? {
Some(url) => Self::Set(url),
None => Self::Clear,
})
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum JsonObjectMapPatch {
#[default]
Keep,
Set(JsonObjectMap),
Clear,
}
impl JsonObjectMapPatch {
pub fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
pub fn as_set(&self) -> Option<&JsonObjectMap> {
match self {
Self::Set(value) => Some(value),
Self::Keep | Self::Clear => None,
}
}
pub fn into_option(self) -> Option<JsonObjectMap> {
match self {
Self::Set(value) => Some(value),
Self::Keep | Self::Clear => None,
}
}
pub fn resolve(self, current: Option<JsonObjectMap>) -> Option<JsonObjectMap> {
match self {
Self::Keep => current,
Self::Set(value) => Some(value),
Self::Clear => None,
}
}
}
impl Serialize for JsonObjectMapPatch {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Keep | Self::Clear => serializer.serialize_none(),
Self::Set(value) => value.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for JsonObjectMapPatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match Option::<JsonObjectMap>::deserialize(deserializer)? {
Some(value) => Self::Set(value),
None => Self::Clear,
})
}
}
fn validate_https_icon_url(url: &str) -> Result<(), ValidationError> {
if url.len() > 2048 {
return Err(ValidationError {
field: "icon_url",
message: "icon_url must not exceed 2048 characters".to_string(),
});
}
if !url.starts_with("https://") {
return Err(ValidationError {
field: "icon_url",
message: "icon_url must start with https://".to_string(),
});
}
Ok(())
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CreateSoftwareItemRequest {
pub name: String,
#[serde(default = "crate::default_featured")]
pub featured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateSoftwareItemRequest {
pub name: Option<String>,
pub featured: Option<bool>,
#[serde(default, skip_serializing_if = "IconUrlPatch::is_keep")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub icon_url: IconUrlPatch,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostSoftwareAssignment {
pub host_id: Uuid,
pub plugins: Vec<HostPluginRoleAssignment>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostPluginRoleAssignment {
pub role: PluginRole,
#[serde(default)]
pub ordinal: i32,
pub plugin_config_id: Option<Uuid>,
pub plugin_config: Option<CreatePluginConfigRequest>,
pub package_identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_override: Option<JsonObjectMap>,
#[serde(default = "default_execution_site")]
pub execution_site: String,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AssignHostsRequest {
pub host_assignments: Vec<HostSoftwareAssignment>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateHostAssignmentRequest {
pub role: PluginRole,
#[serde(default)]
pub ordinal: i32,
pub plugin_config_id: Option<Uuid>,
pub plugin_config: Option<CreatePluginConfigRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_type: Option<PluginTypeId>,
pub package_identifier: Option<String>,
#[serde(default, skip_serializing_if = "JsonObjectMapPatch::is_keep")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
pub config_override: JsonObjectMapPatch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_site: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct SoftwareItemResponse {
pub id: Uuid,
pub name: String,
pub plugins: Vec<String>,
pub featured: bool,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
pub last_checked_at: Option<OffsetDateTime>,
pub host_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_display_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_release_metadata: Option<serde_json::Value>,
pub update_available: bool,
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct SoftwareItemDetailResponse {
pub id: Uuid,
pub name: String,
pub plugins: Vec<String>,
pub featured: bool,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
pub last_checked_at: Option<OffsetDateTime>,
pub host_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
pub update_available: bool,
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
pub hosts: Vec<SoftwareItemHostSummary>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct SoftwareItemHostSummary {
pub id: Uuid,
pub host_id: Uuid,
pub hostname: String,
pub friendly_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qualifier: Option<String>,
pub plugins: Vec<HostPluginRoleSummary>,
pub installed_version: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
pub installed_version_detected_at: Option<OffsetDateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_display_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_release_metadata: Option<serde_json::Value>,
pub update_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_update_history_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_update_status: Option<String>,
pub update_category: String,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = DateTime))]
pub last_updated_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(feature = "openapi", schema(value_type = String, format = DateTime))]
pub linked_at: OffsetDateTime,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostPluginRoleSummary {
pub role: PluginRole,
#[serde(default)]
pub ordinal: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_config_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_config_name: Option<String>,
pub plugin_type: String,
pub package_identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_override: Option<JsonObjectMap>,
pub execution_site: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum TriggerUpdateStatus {
Pending,
Queued,
Failed,
}
impl std::fmt::Display for TriggerUpdateStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => f.write_str("pending"),
Self::Queued => f.write_str("queued"),
Self::Failed => f.write_str("failed"),
}
}
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ReleaseAssetInfoRequest {
pub name: String,
pub download_url: String,
pub size: Option<u64>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ReleaseInfoRequest {
pub tag: String,
pub release_url: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<ReleaseAssetInfoRequest>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TriggerUpdateRequest {
pub to_version: String,
pub release_info: Option<ReleaseInfoRequest>,
#[serde(default)]
pub interactive: bool,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TriggerUpdateResponse {
pub update_history_id: Uuid,
pub status: TriggerUpdateStatus,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TriggerVersionCheckResponse {
pub agents_notified: u32,
#[serde(default)]
pub controller_checks_run: u32,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
pub struct ListSoftwareItemsParams {
pub page: Option<u64>,
pub per_page: Option<u64>,
pub featured: Option<bool>,
pub host_id: Option<Uuid>,
pub updatable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
}
impl ListSoftwareItemsParams {
pub fn pagination(&self) -> PaginationParams {
PaginationParams {
page: self.page,
per_page: self.per_page,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemSummary {
pub id: Uuid,
pub name: String,
pub host_count: u64,
pub plugins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemLinkSummary {
pub id: Uuid,
pub host_id: Uuid,
pub hostname: String,
pub friendly_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qualifier: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemsPreviewRequest {
pub candidate_ids: Vec<Uuid>,
pub survivor_id: Uuid,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed_item_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemsPreviewResponse {
pub candidates: Vec<MergeSoftwareItemSummary>,
pub survivor: MergeSoftwareItemSummary,
pub losers: Vec<MergeSoftwareItemSummary>,
pub moved_links: Vec<MergeSoftwareItemLinkSummary>,
pub skipped_duplicate_links: Vec<MergeSoftwareItemLinkSummary>,
pub candidate_count: u64,
pub loser_count: u64,
pub moved_link_count: u64,
pub skipped_duplicate_link_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemsExecuteRequest {
pub candidate_ids: Vec<Uuid>,
pub survivor_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MergeSoftwareItemsExecuteResponse {
pub survivor_id: Uuid,
pub deleted_ids: Vec<Uuid>,
pub moved_link_ids: Vec<Uuid>,
pub skipped_duplicate_link_ids: Vec<Uuid>,
}
impl Validate for CreateSoftwareItemRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self.name.trim().is_empty() {
return Err(ValidationError {
field: "name",
message: "name must not be empty".to_string(),
});
}
if let Some(url) = &self.icon_url {
validate_https_icon_url(url)?;
}
Ok(())
}
}
impl Validate for UpdateSoftwareItemRequest {
fn validate(&self) -> Result<(), ValidationError> {
if let IconUrlPatch::Set(url) = &self.icon_url {
validate_https_icon_url(url)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
use uptrakit_shared_types::plugin_ids;
fn sample_uuid() -> Uuid {
Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
.expect("hard-coded UUID should be valid")
}
fn valid_create_request() -> CreateSoftwareItemRequest {
CreateSoftwareItemRequest {
name: "1Password".to_string(),
featured: true,
icon_url: None,
}
}
#[test]
fn create_software_item_request_round_trip() {
let req = valid_create_request();
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: CreateSoftwareItemRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.name, "1Password");
assert!(deserialized.featured);
}
#[test]
fn create_software_item_request_default_featured_from_json() {
let json = serde_json::json!({ "name": "Test" });
let req: CreateSoftwareItemRequest =
serde_json::from_value(json).expect("deserialization should succeed");
assert!(req.featured, "featured should default to true");
}
#[test]
fn validate_valid_request_passes() {
let req = valid_create_request();
assert!(req.validate().is_ok());
}
#[test]
fn validate_empty_name_fails() {
let req = CreateSoftwareItemRequest {
name: "".to_string(),
featured: true,
icon_url: None,
};
let err = req
.validate()
.expect_err("empty name should fail validation");
assert_eq!(err.field, "name");
}
#[test]
fn validate_whitespace_only_name_fails() {
let req = CreateSoftwareItemRequest {
name: " ".to_string(),
featured: true,
icon_url: None,
};
let err = req
.validate()
.expect_err("whitespace-only name should fail validation");
assert_eq!(err.field, "name");
}
#[test]
fn create_software_item_icon_url_https_passes() {
let req = CreateSoftwareItemRequest {
name: "App".to_string(),
featured: true,
icon_url: Some("https://example.com/icon.png".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_software_item_icon_url_http_rejected() {
let req = CreateSoftwareItemRequest {
name: "App".to_string(),
featured: true,
icon_url: Some("http://example.com/icon.png".to_string()),
};
let err = req.validate().expect_err("http URL should fail validation");
assert_eq!(err.field, "icon_url");
}
#[test]
fn create_software_item_icon_url_none_passes() {
let req = CreateSoftwareItemRequest {
name: "App".to_string(),
featured: true,
icon_url: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn update_software_item_icon_url_https_passes() {
let req = UpdateSoftwareItemRequest {
name: None,
featured: None,
icon_url: IconUrlPatch::Set("https://example.com/icon.png".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn update_software_item_icon_url_null_clears() {
let req: UpdateSoftwareItemRequest = serde_json::from_value(serde_json::json!({
"icon_url": null
}))
.expect("deserialization should succeed");
assert!(req.validate().is_ok());
assert_eq!(req.icon_url, IconUrlPatch::Clear);
}
#[test]
fn update_software_item_icon_url_http_rejected() {
let req = UpdateSoftwareItemRequest {
name: None,
featured: None,
icon_url: IconUrlPatch::Set("http://example.com/icon.png".to_string()),
};
let err = req.validate().expect_err("http URL should fail validation");
assert_eq!(err.field, "icon_url");
}
#[test]
fn update_software_item_icon_url_patch_parses_set_clear_and_keep() {
let keep_req: UpdateSoftwareItemRequest =
serde_json::from_value(serde_json::json!({})).expect("keep request should deserialize");
let clear_req: UpdateSoftwareItemRequest = serde_json::from_value(serde_json::json!({
"icon_url": null
}))
.expect("clear request should deserialize");
let set_req: UpdateSoftwareItemRequest = serde_json::from_value(serde_json::json!({
"icon_url": "https://example.com/icon.png"
}))
.expect("set request should deserialize");
assert_eq!(keep_req.icon_url, IconUrlPatch::Keep);
assert_eq!(clear_req.icon_url, IconUrlPatch::Clear);
assert!(matches!(
set_req.icon_url,
IconUrlPatch::Set(ref url) if url == "https://example.com/icon.png"
));
assert!(matches!(
IconUrlPatch::from_json(None).expect("keep"),
IconUrlPatch::Keep
));
assert!(matches!(
IconUrlPatch::from_json(Some(&serde_json::Value::Null)).expect("clear"),
IconUrlPatch::Clear
));
assert!(matches!(
IconUrlPatch::from_json(Some(&serde_json::json!("https://example.com/icon.png")))
.expect("set"),
IconUrlPatch::Set(url) if url == "https://example.com/icon.png"
));
}
#[test]
fn update_software_item_icon_url_patch_rejects_invalid_shape() {
let err = IconUrlPatch::from_json(Some(&serde_json::json!({"url": "https://example.com"})))
.expect_err("object should be rejected");
assert_eq!(err.field, "icon_url");
}
#[test]
fn assign_hosts_request_round_trip() {
let req = AssignHostsRequest {
host_assignments: vec![
HostSoftwareAssignment {
host_id: sample_uuid(),
plugins: vec![
HostPluginRoleAssignment {
role: PluginRole::DetectVersion,
ordinal: 0,
plugin_config_id: Some(sample_uuid()),
plugin_config: None,
package_identifier: "1password".to_string(),
config_override: None,
execution_site: "auto".to_string(),
},
HostPluginRoleAssignment {
role: PluginRole::FetchReleases,
ordinal: 0,
plugin_config_id: Some(sample_uuid()),
plugin_config: None,
package_identifier: "1password".to_string(),
config_override: None,
execution_site: "auto".to_string(),
},
],
},
HostSoftwareAssignment {
host_id: Uuid::nil(),
plugins: vec![HostPluginRoleAssignment {
role: PluginRole::ExecuteUpdate,
ordinal: 0,
plugin_config_id: None,
plugin_config: Some(crate::plugin_configs::CreatePluginConfigRequest {
name: "Homebrew Casks".to_string(),
plugin_type: plugin_ids::PACKAGE_MANAGER_HOMEBREW.clone(),
config: serde_json::json!({"package_type": "cask"}),
enabled: true,
}),
package_identifier: "1password-cli".to_string(),
config_override: None,
execution_site: "agent".to_string(),
}],
},
],
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: AssignHostsRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.host_assignments.len(), 2);
assert_eq!(deserialized.host_assignments[0].host_id, sample_uuid());
assert_eq!(deserialized.host_assignments[0].plugins.len(), 2);
assert_eq!(
deserialized.host_assignments[0].plugins[0].package_identifier,
"1password"
);
assert_eq!(deserialized.host_assignments[1].plugins.len(), 1);
assert!(
deserialized.host_assignments[1].plugins[0]
.plugin_config
.is_some()
);
}
#[test]
fn host_plugin_role_assignment_defaults_execution_site() {
let json = serde_json::json!({
"role": "detect_version",
"plugin_config_id": sample_uuid(),
"package_identifier": "nginx"
});
let assignment: HostPluginRoleAssignment =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(assignment.execution_site, "auto");
assert_eq!(assignment.role, PluginRole::DetectVersion);
}
#[test]
fn update_host_assignment_request_round_trip() {
let req = UpdateHostAssignmentRequest {
role: PluginRole::FetchReleases,
ordinal: 0,
plugin_config_id: Some(sample_uuid()),
plugin_config: None,
plugin_type: None,
package_identifier: Some("nginx".to_string()),
config_override: JsonObjectMapPatch::Set(
JsonObjectMap::try_from(serde_json::json!({
"asset_patterns": ["nginx.*linux"]
}))
.expect("object config_override"),
),
execution_site: Some("controller".to_string()),
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: UpdateHostAssignmentRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.role, PluginRole::FetchReleases);
assert_eq!(deserialized.execution_site.as_deref(), Some("controller"));
assert_eq!(
deserialized.config_override,
JsonObjectMapPatch::Set(
JsonObjectMap::try_from(serde_json::json!({
"asset_patterns": ["nginx.*linux"]
}))
.expect("object config_override")
)
);
let keep_req: UpdateHostAssignmentRequest = serde_json::from_value(serde_json::json!({
"role": "fetch_releases"
}))
.expect("keep request should deserialize");
assert_eq!(keep_req.config_override, JsonObjectMapPatch::Keep);
let clear_req: UpdateHostAssignmentRequest = serde_json::from_value(serde_json::json!({
"role": "fetch_releases",
"config_override": null
}))
.expect("clear request should deserialize");
assert_eq!(clear_req.config_override, JsonObjectMapPatch::Clear);
}
#[test]
fn software_item_response_round_trip() {
use time::macros::datetime;
let resp = SoftwareItemResponse {
id: sample_uuid(),
name: "1Password".to_string(),
plugins: vec![
"package_manager_homebrew".to_string(),
"releases_github".to_string(),
],
featured: true,
last_checked_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
host_count: 5,
installed_version: Some("8.9.0".to_string()),
installed_display_version: None,
latest_version: Some("8.10.0".to_string()),
latest_release_metadata: None,
update_available: true,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-06-01 12:00:00 UTC),
icon_url: None,
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: SoftwareItemResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.id, sample_uuid());
assert_eq!(deserialized.name, "1Password");
assert_eq!(deserialized.host_count, 5);
assert_eq!(deserialized.plugins.len(), 2);
assert!(deserialized.featured);
assert_eq!(deserialized.installed_version.as_deref(), Some("8.9.0"));
assert_eq!(deserialized.latest_version.as_deref(), Some("8.10.0"));
assert!(deserialized.update_available);
}
#[test]
fn software_item_response_update_available_false_when_no_latest() {
use time::macros::datetime;
let resp = SoftwareItemResponse {
id: sample_uuid(),
name: "MyApp".to_string(),
plugins: vec!["releases_github".to_string()],
featured: true,
last_checked_at: None,
host_count: 1,
installed_version: None,
installed_display_version: None,
latest_version: None,
latest_release_metadata: None,
update_available: false,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-01-01 00:00:00 UTC),
icon_url: None,
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: SoftwareItemResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.installed_version.is_none());
assert!(deserialized.latest_version.is_none());
assert!(!deserialized.update_available);
let json_value =
serde_json::to_value(&resp).expect("serialization to Value should succeed");
assert!(json_value.get("installed_version").is_none());
assert!(json_value.get("latest_version").is_none());
}
#[test]
fn software_item_response_empty_plugins() {
use time::macros::datetime;
let resp = SoftwareItemResponse {
id: sample_uuid(),
name: "Test".to_string(),
plugins: vec![],
featured: false,
last_checked_at: None,
host_count: 0,
installed_version: None,
installed_display_version: None,
latest_version: None,
latest_release_metadata: None,
update_available: false,
created_at: datetime!(2025-01-01 00:00:00 UTC),
updated_at: datetime!(2025-01-01 00:00:00 UTC),
icon_url: None,
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: SoftwareItemResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert!(deserialized.plugins.is_empty());
assert!(deserialized.last_checked_at.is_none());
assert!(!deserialized.featured);
assert!(!deserialized.update_available);
}
#[test]
fn trigger_update_request_round_trip() {
let req = TriggerUpdateRequest {
to_version: "2.0.0".to_string(),
release_info: None,
interactive: false,
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: TriggerUpdateRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.to_version, "2.0.0");
assert!(deserialized.release_info.is_none());
}
#[test]
fn trigger_update_request_with_release_info() {
let req = TriggerUpdateRequest {
to_version: "3.0.0".to_string(),
release_info: Some(ReleaseInfoRequest {
tag: "v3.0.0".to_string(),
release_url: "https://github.com/example/repo/releases/v3.0.0".to_string(),
assets: vec![ReleaseAssetInfoRequest {
name: "binary.tar.gz".to_string(),
download_url: "https://example.com/binary.tar.gz".to_string(),
size: Some(1024),
}],
}),
interactive: false,
};
let json = serde_json::to_string(&req).expect("serialization should succeed");
let deserialized: TriggerUpdateRequest =
serde_json::from_str(&json).expect("deserialization should succeed");
let info = deserialized
.release_info
.expect("release_info should be present");
assert_eq!(info.tag, "v3.0.0");
assert_eq!(info.assets.len(), 1);
assert_eq!(info.assets[0].size, Some(1024));
}
#[test]
fn trigger_update_response_round_trip() {
let resp = TriggerUpdateResponse {
update_history_id: sample_uuid(),
status: TriggerUpdateStatus::Pending,
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerUpdateResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.update_history_id, sample_uuid());
assert_eq!(deserialized.status, TriggerUpdateStatus::Pending);
}
#[test]
fn trigger_update_response_queued_status() {
let resp = TriggerUpdateResponse {
update_history_id: sample_uuid(),
status: TriggerUpdateStatus::Queued,
};
let json_value =
serde_json::to_value(&resp).expect("serialization to Value should succeed");
assert_eq!(
json_value.get("status").and_then(|v| v.as_str()),
Some("queued")
);
}
#[test]
fn trigger_update_response_failed_status() {
let resp = TriggerUpdateResponse {
update_history_id: sample_uuid(),
status: TriggerUpdateStatus::Failed,
};
let json_value =
serde_json::to_value(&resp).expect("serialization to Value should succeed");
assert_eq!(
json_value.get("status").and_then(|v| v.as_str()),
Some("failed")
);
}
#[test]
fn trigger_version_check_response_round_trip() {
let resp = TriggerVersionCheckResponse {
agents_notified: 3,
controller_checks_run: 0,
message: "Version check triggered for 3 agents".to_string(),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerVersionCheckResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.agents_notified, 3);
assert_eq!(deserialized.controller_checks_run, 0);
assert_eq!(deserialized.message, "Version check triggered for 3 agents");
}
#[test]
fn trigger_version_check_response_controller_only() {
let resp = TriggerVersionCheckResponse {
agents_notified: 0,
controller_checks_run: 2,
message: "Version check completed for 2 item(s) on the controller".to_string(),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerVersionCheckResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.agents_notified, 0);
assert_eq!(deserialized.controller_checks_run, 2);
}
#[test]
fn trigger_version_check_response_controller_checks_run_defaults_to_zero() {
let json = r#"{"agents_notified":1,"message":"ok"}"#;
let deserialized: TriggerVersionCheckResponse =
serde_json::from_str(json).expect("deserialization should succeed");
assert_eq!(deserialized.agents_notified, 1);
assert_eq!(deserialized.controller_checks_run, 0);
}
#[test]
fn trigger_version_check_response_zero_agents() {
let resp = TriggerVersionCheckResponse {
agents_notified: 0,
controller_checks_run: 0,
message: "No agents connected".to_string(),
};
let json = serde_json::to_string(&resp).expect("serialization should succeed");
let deserialized: TriggerVersionCheckResponse =
serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(deserialized.agents_notified, 0);
assert_eq!(deserialized.controller_checks_run, 0);
}
#[test]
fn trigger_update_status_display() {
assert_eq!(TriggerUpdateStatus::Pending.to_string(), "pending");
assert_eq!(TriggerUpdateStatus::Queued.to_string(), "queued");
assert_eq!(TriggerUpdateStatus::Failed.to_string(), "failed");
}
#[test]
fn list_software_items_params_featured_filter() {
let json = serde_json::json!({ "featured": true });
let params: ListSoftwareItemsParams =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(params.featured, Some(true));
}
#[test]
fn list_software_items_params_no_filter() {
let params = ListSoftwareItemsParams::default();
assert!(params.featured.is_none());
assert!(params.page.is_none());
assert!(params.per_page.is_none());
}
#[test]
fn list_software_items_params_updatable_filter() {
let json = serde_json::json!({ "updatable": true });
let params: ListSoftwareItemsParams =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(params.updatable, Some(true));
}
#[test]
fn list_software_items_params_plugin_type_filter() {
let json = serde_json::json!({ "plugin_type": "releases_docker" });
let params: ListSoftwareItemsParams =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(params.plugin_type.as_deref(), Some("releases_docker"));
}
#[test]
fn list_software_items_params_query_filter() {
let params: ListSoftwareItemsParams =
serde_json::from_str(r#"{"query":"node","plugin_type":"releases_docker"}"#)
.expect("deserialize");
assert_eq!(params.query.as_deref(), Some("node"));
assert_eq!(params.plugin_type.as_deref(), Some("releases_docker"));
}
#[test]
fn merge_preview_request_round_trip() {
let req = MergeSoftwareItemsPreviewRequest {
candidate_ids: vec![Uuid::nil(), Uuid::new_v4()],
survivor_id: Uuid::nil(),
seed_item_id: Some(Uuid::new_v4()),
};
let json = serde_json::to_string(&req).expect("serialize");
let parsed: MergeSoftwareItemsPreviewRequest =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.candidate_ids.len(), 2);
assert_eq!(parsed.survivor_id, Uuid::nil());
}
#[test]
fn merge_preview_response_round_trip() {
let resp = MergeSoftwareItemsPreviewResponse {
candidates: vec![MergeSoftwareItemSummary {
id: Uuid::nil(),
name: "Node.js".to_string(),
host_count: 2,
plugins: vec!["releases_github".to_string()],
}],
survivor: MergeSoftwareItemSummary {
id: Uuid::new_v4(),
name: "Node.js LTS".to_string(),
host_count: 4,
plugins: vec!["releases_github".to_string()],
},
losers: vec![MergeSoftwareItemSummary {
id: Uuid::new_v4(),
name: "Node".to_string(),
host_count: 1,
plugins: vec![],
}],
moved_links: vec![MergeSoftwareItemLinkSummary {
id: Uuid::new_v4(),
host_id: Uuid::new_v4(),
hostname: "host-a".to_string(),
friendly_name: "Host A".to_string(),
qualifier: None,
}],
skipped_duplicate_links: vec![MergeSoftwareItemLinkSummary {
id: Uuid::new_v4(),
host_id: Uuid::new_v4(),
hostname: "host-b".to_string(),
friendly_name: "Host B".to_string(),
qualifier: Some("docker".to_string()),
}],
candidate_count: 1,
loser_count: 1,
moved_link_count: 1,
skipped_duplicate_link_count: 1,
};
let json = serde_json::to_string(&resp).expect("serialize");
let parsed: MergeSoftwareItemsPreviewResponse =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.candidates.len(), 1);
assert_eq!(parsed.losers.len(), 1);
assert_eq!(parsed.moved_links.len(), 1);
assert_eq!(parsed.candidate_count, 1);
assert_eq!(parsed.loser_count, 1);
assert_eq!(parsed.moved_link_count, 1);
assert_eq!(parsed.skipped_duplicate_link_count, 1);
}
#[test]
fn merge_preview_response_serializes_empty_arrays() {
let resp = MergeSoftwareItemsPreviewResponse {
candidates: vec![],
survivor: MergeSoftwareItemSummary {
id: Uuid::nil(),
name: "Node.js".to_string(),
host_count: 0,
plugins: vec![],
},
losers: vec![],
moved_links: vec![],
skipped_duplicate_links: vec![],
candidate_count: 0,
loser_count: 0,
moved_link_count: 0,
skipped_duplicate_link_count: 0,
};
let json = serde_json::to_value(&resp).expect("serialize");
assert!(json["candidates"].as_array().is_some());
assert!(json["survivor"]["plugins"].as_array().is_some());
assert!(json["losers"].as_array().is_some());
assert!(json["moved_links"].as_array().is_some());
assert!(json["skipped_duplicate_links"].as_array().is_some());
assert_eq!(json["candidate_count"], 0);
}
#[test]
fn merge_execute_response_round_trip() {
let resp = MergeSoftwareItemsExecuteResponse {
survivor_id: Uuid::nil(),
deleted_ids: vec![Uuid::new_v4()],
moved_link_ids: vec![Uuid::new_v4()],
skipped_duplicate_link_ids: vec![Uuid::new_v4()],
};
let json = serde_json::to_string(&resp).expect("serialize");
let parsed: MergeSoftwareItemsExecuteResponse =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.deleted_ids.len(), 1);
}
}