uptrakit-web-api-types 0.0.4

Shared HTTP request/response types for the Uptrakit web API
Documentation
use crate::host_tags::HostTagSummary;
use crate::services::ServiceStatus;
use crate::validation::{Validate, ValidationError};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;

pub use super::agents::MessageResponse as HostMessageResponse;

#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostResponse {
    pub id: Uuid,
    pub machine_id: String,
    pub hostname: String,
    pub friendly_name: String,
    pub os_type: Option<String>,
    pub os_version: Option<String>,
    pub architecture: Option<String>,
    pub ip_address: Option<String>,
    #[serde(with = "time::serde::rfc3339::option")]
    #[cfg_attr(
        feature = "openapi",
        schema(value_type = Option<String>, format = DateTime)
    )]
    pub last_seen_at: Option<OffsetDateTime>,
    #[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,
    pub agents: Vec<HostAgentSummary>,
    /// Tags assigned to this host.
    #[serde(default)]
    pub tags: Vec<HostTagSummary>,
    /// Agent-reported host features. Empty if not reported (legacy agent).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub features: Vec<String>,
    /// Aggregate software status for host-list rendering.
    #[serde(default)]
    pub software_status: HostSoftwareStatusSummary,
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostSoftwareStatusSummary {
    pub known: bool,
    pub update_count: u32,
    pub error_count: u32,
}

#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HostAgentSummary {
    pub id: Uuid,
    pub friendly_name: String,
    pub status: ServiceStatus,
}

#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateHostRequest {
    pub friendly_name: Option<String>,
}

impl Validate for UpdateHostRequest {
    fn validate(&self) -> Result<(), ValidationError> {
        if let Some(name) = &self.friendly_name
            && name.trim().is_empty()
        {
            return Err(ValidationError {
                field: "friendly_name",
                message: "friendly_name must not be empty".to_string(),
            });
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use time::macros::datetime;

    fn sample_uuid() -> Uuid {
        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
            .expect("hard-coded UUID should be valid")
    }

    fn sample_agent_uuid() -> Uuid {
        Uuid::parse_str("b1b2b3b4-c1c2-d1d2-e1e2-f1f2f3f4f5f6")
            .expect("hard-coded UUID should be valid")
    }

    fn sample_software_status() -> HostSoftwareStatusSummary {
        HostSoftwareStatusSummary {
            known: true,
            update_count: 2,
            error_count: 1,
        }
    }

    // ── HostAgentSummary ─────────────────────────────────────────────

    #[test]
    fn host_agent_summary_round_trip() {
        let summary = HostAgentSummary {
            id: sample_agent_uuid(),
            friendly_name: "agent-1".to_string(),
            status: ServiceStatus::Approved,
        };
        let json = serde_json::to_string(&summary).expect("serialization should succeed");
        let deserialized: HostAgentSummary =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert_eq!(deserialized.id, sample_agent_uuid());
        assert_eq!(deserialized.friendly_name, "agent-1");
        assert_eq!(deserialized.status, ServiceStatus::Approved);
    }

    #[test]
    fn host_agent_summary_pending_status() {
        let summary = HostAgentSummary {
            id: sample_agent_uuid(),
            friendly_name: "new-agent".to_string(),
            status: ServiceStatus::Pending,
        };
        let json_value =
            serde_json::to_value(&summary).expect("serialization to Value should succeed");
        assert_eq!(
            json_value.get("status").and_then(|v| v.as_str()),
            Some("pending")
        );
    }

    // ── HostResponse ─────────────────────────────────────────────────

    #[test]
    fn host_response_round_trip_all_fields() {
        let resp = HostResponse {
            id: sample_uuid(),
            machine_id: "machine-001".to_string(),
            hostname: "server-1.local".to_string(),
            friendly_name: "Production Server".to_string(),
            os_type: Some("linux".to_string()),
            os_version: Some("Ubuntu 22.04".to_string()),
            architecture: Some("x86_64".to_string()),
            ip_address: Some("192.168.1.100".to_string()),
            last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
            created_at: datetime!(2025-01-01 0:00:00 UTC),
            updated_at: datetime!(2025-06-01 12:00:00 UTC),
            agents: vec![HostAgentSummary {
                id: sample_agent_uuid(),
                friendly_name: "agent-1".to_string(),
                status: ServiceStatus::Approved,
            }],
            tags: vec![],
            features: vec![],
            software_status: sample_software_status(),
        };
        let json = serde_json::to_string(&resp).expect("serialization should succeed");
        let deserialized: HostResponse =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert_eq!(deserialized.id, sample_uuid());
        assert_eq!(deserialized.machine_id, "machine-001");
        assert_eq!(deserialized.hostname, "server-1.local");
        assert_eq!(deserialized.friendly_name, "Production Server");
        assert_eq!(deserialized.os_type.as_deref(), Some("linux"));
        assert_eq!(deserialized.os_version.as_deref(), Some("Ubuntu 22.04"));
        assert_eq!(deserialized.architecture.as_deref(), Some("x86_64"));
        assert_eq!(deserialized.ip_address.as_deref(), Some("192.168.1.100"));
        assert!(deserialized.last_seen_at.is_some());
        assert_eq!(deserialized.agents.len(), 1);
        assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
        assert_eq!(deserialized.software_status, sample_software_status());
    }

    #[test]
    fn host_response_round_trip_none_fields() {
        let resp = HostResponse {
            id: sample_uuid(),
            machine_id: "machine-002".to_string(),
            hostname: "unknown-host".to_string(),
            friendly_name: "New Host".to_string(),
            os_type: None,
            os_version: None,
            architecture: None,
            ip_address: None,
            last_seen_at: None,
            created_at: datetime!(2025-01-01 0:00:00 UTC),
            updated_at: datetime!(2025-01-01 0:00:00 UTC),
            agents: vec![],
            tags: vec![],
            features: vec![],
            software_status: sample_software_status(),
        };
        let json = serde_json::to_string(&resp).expect("serialization should succeed");
        let deserialized: HostResponse =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert!(deserialized.os_type.is_none());
        assert!(deserialized.os_version.is_none());
        assert!(deserialized.architecture.is_none());
        assert!(deserialized.ip_address.is_none());
        assert!(deserialized.last_seen_at.is_none());
        assert!(deserialized.agents.is_empty());
        assert_eq!(deserialized.software_status, sample_software_status());
    }

    #[test]
    fn host_response_none_fields_serialize_as_null() {
        let resp = HostResponse {
            id: sample_uuid(),
            machine_id: "m".to_string(),
            hostname: "h".to_string(),
            friendly_name: "f".to_string(),
            os_type: None,
            os_version: None,
            architecture: None,
            ip_address: None,
            last_seen_at: None,
            created_at: datetime!(2025-01-01 0:00:00 UTC),
            updated_at: datetime!(2025-01-01 0:00:00 UTC),
            agents: vec![],
            tags: vec![],
            features: vec![],
            software_status: sample_software_status(),
        };
        let json_value =
            serde_json::to_value(&resp).expect("serialization to Value should succeed");
        let obj = json_value
            .as_object()
            .expect("top-level value should be an object");
        for field in [
            "os_type",
            "os_version",
            "architecture",
            "ip_address",
            "last_seen_at",
        ] {
            assert!(
                obj.get(field).expect("field should be present").is_null(),
                "{field} should serialize as null when None"
            );
        }
    }

    #[test]
    fn host_response_multiple_agents_different_statuses() {
        let resp = HostResponse {
            id: sample_uuid(),
            machine_id: "m".to_string(),
            hostname: "h".to_string(),
            friendly_name: "f".to_string(),
            os_type: None,
            os_version: None,
            architecture: None,
            ip_address: None,
            last_seen_at: None,
            created_at: datetime!(2025-01-01 0:00:00 UTC),
            updated_at: datetime!(2025-01-01 0:00:00 UTC),
            agents: vec![
                HostAgentSummary {
                    id: sample_agent_uuid(),
                    friendly_name: "approved-agent".to_string(),
                    status: ServiceStatus::Approved,
                },
                HostAgentSummary {
                    id: sample_uuid(),
                    friendly_name: "pending-agent".to_string(),
                    status: ServiceStatus::Pending,
                },
            ],
            tags: vec![],
            features: vec![],
            software_status: sample_software_status(),
        };
        let json = serde_json::to_string(&resp).expect("serialization should succeed");
        let deserialized: HostResponse =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert_eq!(deserialized.agents.len(), 2);
        assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
        assert_eq!(deserialized.agents[1].status, ServiceStatus::Pending);
    }

    #[test]
    fn host_response_deserializes_missing_software_status_to_default() {
        let json = serde_json::json!({
            "id": sample_uuid(),
            "machine_id": "machine-001",
            "hostname": "server-1.local",
            "friendly_name": "Production Server",
            "os_type": null,
            "os_version": null,
            "architecture": null,
            "ip_address": null,
            "last_seen_at": null,
            "created_at": "2025-01-01T00:00:00Z",
            "updated_at": "2025-01-01T00:00:00Z",
            "agents": [],
            "tags": [],
            "features": []
        });

        let deserialized: HostResponse =
            serde_json::from_value(json).expect("deserialization should succeed");
        assert_eq!(
            deserialized.software_status,
            HostSoftwareStatusSummary::default()
        );
    }

    // ── UpdateHostRequest ────────────────────────────────────────────

    #[test]
    fn update_host_request_round_trip_with_name() {
        let req = UpdateHostRequest {
            friendly_name: Some("New Name".to_string()),
        };
        let json = serde_json::to_string(&req).expect("serialization should succeed");
        let deserialized: UpdateHostRequest =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert_eq!(deserialized.friendly_name.as_deref(), Some("New Name"));
    }

    #[test]
    fn update_host_request_round_trip_none() {
        let req = UpdateHostRequest {
            friendly_name: None,
        };
        let json = serde_json::to_string(&req).expect("serialization should succeed");
        let deserialized: UpdateHostRequest =
            serde_json::from_str(&json).expect("deserialization should succeed");
        assert!(deserialized.friendly_name.is_none());
    }

    #[test]
    fn update_host_request_from_empty_json_object() {
        let json = r#"{}"#;
        let req: UpdateHostRequest =
            serde_json::from_str(json).expect("deserialization should succeed");
        assert!(req.friendly_name.is_none());
    }

    #[test]
    fn update_host_request_validate_rejects_empty_friendly_name() {
        let req = UpdateHostRequest {
            friendly_name: Some(String::new()),
        };
        assert_eq!(req.validate().err().map(|e| e.field), Some("friendly_name"));
    }

    #[test]
    fn update_host_request_validate_passes_when_friendly_name_omitted() {
        let req = UpdateHostRequest {
            friendly_name: None,
        };
        req.validate().expect("None friendly_name should validate");
    }
}