1use crate::host_tags::HostTagSummary;
2use crate::services::ServiceStatus;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7pub use super::agents::MessageResponse as HostMessageResponse;
8
9#[derive(Debug, Serialize, Deserialize)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub struct HostResponse {
12 pub id: Uuid,
13 pub machine_id: String,
14 pub hostname: String,
15 pub friendly_name: String,
16 pub os_type: Option<String>,
17 pub os_version: Option<String>,
18 pub architecture: Option<String>,
19 pub ip_address: Option<String>,
20 #[serde(with = "time::serde::rfc3339::option")]
21 #[cfg_attr(
22 feature = "openapi",
23 schema(value_type = Option<String>, format = DateTime)
24 )]
25 pub last_seen_at: Option<OffsetDateTime>,
26 #[serde(with = "time::serde::rfc3339")]
27 #[cfg_attr(
28 feature = "openapi",
29 schema(value_type = String, format = DateTime)
30 )]
31 pub created_at: OffsetDateTime,
32 #[serde(with = "time::serde::rfc3339")]
33 #[cfg_attr(
34 feature = "openapi",
35 schema(value_type = String, format = DateTime)
36 )]
37 pub updated_at: OffsetDateTime,
38 pub agents: Vec<HostAgentSummary>,
39 #[serde(default)]
41 pub tags: Vec<HostTagSummary>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub features: Vec<String>,
45 #[serde(default)]
47 pub software_status: HostSoftwareStatusSummary,
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52pub struct HostSoftwareStatusSummary {
53 pub known: bool,
54 pub update_count: u32,
55 pub error_count: u32,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60pub struct HostAgentSummary {
61 pub id: Uuid,
62 pub friendly_name: String,
63 pub status: ServiceStatus,
64}
65
66#[derive(Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct UpdateHostRequest {
69 pub friendly_name: Option<String>,
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use time::macros::datetime;
76
77 fn sample_uuid() -> Uuid {
78 Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
79 .expect("hard-coded UUID should be valid")
80 }
81
82 fn sample_agent_uuid() -> Uuid {
83 Uuid::parse_str("b1b2b3b4-c1c2-d1d2-e1e2-f1f2f3f4f5f6")
84 .expect("hard-coded UUID should be valid")
85 }
86
87 fn sample_software_status() -> HostSoftwareStatusSummary {
88 HostSoftwareStatusSummary {
89 known: true,
90 update_count: 2,
91 error_count: 1,
92 }
93 }
94
95 #[test]
98 fn host_agent_summary_round_trip() {
99 let summary = HostAgentSummary {
100 id: sample_agent_uuid(),
101 friendly_name: "agent-1".to_string(),
102 status: ServiceStatus::Approved,
103 };
104 let json = serde_json::to_string(&summary).expect("serialization should succeed");
105 let deserialized: HostAgentSummary =
106 serde_json::from_str(&json).expect("deserialization should succeed");
107 assert_eq!(deserialized.id, sample_agent_uuid());
108 assert_eq!(deserialized.friendly_name, "agent-1");
109 assert_eq!(deserialized.status, ServiceStatus::Approved);
110 }
111
112 #[test]
113 fn host_agent_summary_pending_status() {
114 let summary = HostAgentSummary {
115 id: sample_agent_uuid(),
116 friendly_name: "new-agent".to_string(),
117 status: ServiceStatus::Pending,
118 };
119 let json_value =
120 serde_json::to_value(&summary).expect("serialization to Value should succeed");
121 assert_eq!(
122 json_value.get("status").and_then(|v| v.as_str()),
123 Some("pending")
124 );
125 }
126
127 #[test]
130 fn host_response_round_trip_all_fields() {
131 let resp = HostResponse {
132 id: sample_uuid(),
133 machine_id: "machine-001".to_string(),
134 hostname: "server-1.local".to_string(),
135 friendly_name: "Production Server".to_string(),
136 os_type: Some("linux".to_string()),
137 os_version: Some("Ubuntu 22.04".to_string()),
138 architecture: Some("x86_64".to_string()),
139 ip_address: Some("192.168.1.100".to_string()),
140 last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
141 created_at: datetime!(2025-01-01 0:00:00 UTC),
142 updated_at: datetime!(2025-06-01 12:00:00 UTC),
143 agents: vec![HostAgentSummary {
144 id: sample_agent_uuid(),
145 friendly_name: "agent-1".to_string(),
146 status: ServiceStatus::Approved,
147 }],
148 tags: vec![],
149 features: vec![],
150 software_status: sample_software_status(),
151 };
152 let json = serde_json::to_string(&resp).expect("serialization should succeed");
153 let deserialized: HostResponse =
154 serde_json::from_str(&json).expect("deserialization should succeed");
155 assert_eq!(deserialized.id, sample_uuid());
156 assert_eq!(deserialized.machine_id, "machine-001");
157 assert_eq!(deserialized.hostname, "server-1.local");
158 assert_eq!(deserialized.friendly_name, "Production Server");
159 assert_eq!(deserialized.os_type.as_deref(), Some("linux"));
160 assert_eq!(deserialized.os_version.as_deref(), Some("Ubuntu 22.04"));
161 assert_eq!(deserialized.architecture.as_deref(), Some("x86_64"));
162 assert_eq!(deserialized.ip_address.as_deref(), Some("192.168.1.100"));
163 assert!(deserialized.last_seen_at.is_some());
164 assert_eq!(deserialized.agents.len(), 1);
165 assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
166 assert_eq!(deserialized.software_status, sample_software_status());
167 }
168
169 #[test]
170 fn host_response_round_trip_none_fields() {
171 let resp = HostResponse {
172 id: sample_uuid(),
173 machine_id: "machine-002".to_string(),
174 hostname: "unknown-host".to_string(),
175 friendly_name: "New Host".to_string(),
176 os_type: None,
177 os_version: None,
178 architecture: None,
179 ip_address: None,
180 last_seen_at: None,
181 created_at: datetime!(2025-01-01 0:00:00 UTC),
182 updated_at: datetime!(2025-01-01 0:00:00 UTC),
183 agents: vec![],
184 tags: vec![],
185 features: vec![],
186 software_status: sample_software_status(),
187 };
188 let json = serde_json::to_string(&resp).expect("serialization should succeed");
189 let deserialized: HostResponse =
190 serde_json::from_str(&json).expect("deserialization should succeed");
191 assert!(deserialized.os_type.is_none());
192 assert!(deserialized.os_version.is_none());
193 assert!(deserialized.architecture.is_none());
194 assert!(deserialized.ip_address.is_none());
195 assert!(deserialized.last_seen_at.is_none());
196 assert!(deserialized.agents.is_empty());
197 assert_eq!(deserialized.software_status, sample_software_status());
198 }
199
200 #[test]
201 fn host_response_none_fields_serialize_as_null() {
202 let resp = HostResponse {
203 id: sample_uuid(),
204 machine_id: "m".to_string(),
205 hostname: "h".to_string(),
206 friendly_name: "f".to_string(),
207 os_type: None,
208 os_version: None,
209 architecture: None,
210 ip_address: None,
211 last_seen_at: None,
212 created_at: datetime!(2025-01-01 0:00:00 UTC),
213 updated_at: datetime!(2025-01-01 0:00:00 UTC),
214 agents: vec![],
215 tags: vec![],
216 features: vec![],
217 software_status: sample_software_status(),
218 };
219 let json_value =
220 serde_json::to_value(&resp).expect("serialization to Value should succeed");
221 let obj = json_value
222 .as_object()
223 .expect("top-level value should be an object");
224 for field in [
225 "os_type",
226 "os_version",
227 "architecture",
228 "ip_address",
229 "last_seen_at",
230 ] {
231 assert!(
232 obj.get(field).expect("field should be present").is_null(),
233 "{field} should serialize as null when None"
234 );
235 }
236 }
237
238 #[test]
239 fn host_response_multiple_agents_different_statuses() {
240 let resp = HostResponse {
241 id: sample_uuid(),
242 machine_id: "m".to_string(),
243 hostname: "h".to_string(),
244 friendly_name: "f".to_string(),
245 os_type: None,
246 os_version: None,
247 architecture: None,
248 ip_address: None,
249 last_seen_at: None,
250 created_at: datetime!(2025-01-01 0:00:00 UTC),
251 updated_at: datetime!(2025-01-01 0:00:00 UTC),
252 agents: vec![
253 HostAgentSummary {
254 id: sample_agent_uuid(),
255 friendly_name: "approved-agent".to_string(),
256 status: ServiceStatus::Approved,
257 },
258 HostAgentSummary {
259 id: sample_uuid(),
260 friendly_name: "pending-agent".to_string(),
261 status: ServiceStatus::Pending,
262 },
263 ],
264 tags: vec![],
265 features: vec![],
266 software_status: sample_software_status(),
267 };
268 let json = serde_json::to_string(&resp).expect("serialization should succeed");
269 let deserialized: HostResponse =
270 serde_json::from_str(&json).expect("deserialization should succeed");
271 assert_eq!(deserialized.agents.len(), 2);
272 assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
273 assert_eq!(deserialized.agents[1].status, ServiceStatus::Pending);
274 }
275
276 #[test]
277 fn host_response_deserializes_missing_software_status_to_default() {
278 let json = serde_json::json!({
279 "id": sample_uuid(),
280 "machine_id": "machine-001",
281 "hostname": "server-1.local",
282 "friendly_name": "Production Server",
283 "os_type": null,
284 "os_version": null,
285 "architecture": null,
286 "ip_address": null,
287 "last_seen_at": null,
288 "created_at": "2025-01-01T00:00:00Z",
289 "updated_at": "2025-01-01T00:00:00Z",
290 "agents": [],
291 "tags": [],
292 "features": []
293 });
294
295 let deserialized: HostResponse =
296 serde_json::from_value(json).expect("deserialization should succeed");
297 assert_eq!(
298 deserialized.software_status,
299 HostSoftwareStatusSummary::default()
300 );
301 }
302
303 #[test]
306 fn update_host_request_round_trip_with_name() {
307 let req = UpdateHostRequest {
308 friendly_name: Some("New Name".to_string()),
309 };
310 let json = serde_json::to_string(&req).expect("serialization should succeed");
311 let deserialized: UpdateHostRequest =
312 serde_json::from_str(&json).expect("deserialization should succeed");
313 assert_eq!(deserialized.friendly_name.as_deref(), Some("New Name"));
314 }
315
316 #[test]
317 fn update_host_request_round_trip_none() {
318 let req = UpdateHostRequest {
319 friendly_name: None,
320 };
321 let json = serde_json::to_string(&req).expect("serialization should succeed");
322 let deserialized: UpdateHostRequest =
323 serde_json::from_str(&json).expect("deserialization should succeed");
324 assert!(deserialized.friendly_name.is_none());
325 }
326
327 #[test]
328 fn update_host_request_from_empty_json_object() {
329 let json = r#"{}"#;
330 let req: UpdateHostRequest =
331 serde_json::from_str(json).expect("deserialization should succeed");
332 assert!(req.friendly_name.is_none());
333 }
334}