//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2): //! - `From for ResumableAgentDto` maps every field, surfaces //! `conversationId` only when present (omitted via `skip_serializing_if` when //! `None`), and serialises ids as strings + camelCase keys. //! - `From for ResumableAgentListDto` preserves the //! `resumable` list and its order. //! - Full + minimal `ResumableAgentDto` serde round-trips on the wire shape. use app_tauri_lib::dto::{ResumableAgentDto, ResumableAgentListDto}; use application::{ListResumableAgentsOutput, ResumableAgent}; use domain::ids::{AgentId, NodeId}; use uuid::Uuid; /// Helper: a resumable agent with a conversation id (resume-capable). fn full(agent_uuid: u128, node_uuid: u128) -> ResumableAgent { ResumableAgent { agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)), name: "Architect".to_owned(), node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)), conversation_id: Some("conv-123".to_owned()), was_running: true, resume_supported: true, } } /// Helper: a minimal resumable agent without a conversation id. fn minimal(agent_uuid: u128, node_uuid: u128) -> ResumableAgent { ResumableAgent { agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)), name: "Scratch".to_owned(), node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)), conversation_id: None, was_running: false, resume_supported: false, } } // --------------------------------------------------------------------------- // From for ResumableAgentDto — field mapping // --------------------------------------------------------------------------- #[test] fn from_resumable_agent_maps_every_field() { let r = full(1, 2); let dto = ResumableAgentDto::from(r.clone()); assert_eq!(dto.agent_id, r.agent_id.to_string()); assert_eq!(dto.name, "Architect"); assert_eq!(dto.node_id, r.node_id.to_string()); assert_eq!(dto.conversation_id.as_deref(), Some("conv-123")); assert!(dto.was_running); assert!(dto.resume_supported); } #[test] fn from_resumable_agent_ids_are_strings() { let r = full(7, 8); let dto = ResumableAgentDto::from(r.clone()); // The ids cross the wire as UUID strings (not raw bytes / numbers). assert_eq!(dto.agent_id, Uuid::from_u128(7).to_string()); assert_eq!(dto.node_id, Uuid::from_u128(8).to_string()); } // --------------------------------------------------------------------------- // camelCase wire shape + conversationId omission // --------------------------------------------------------------------------- #[test] fn full_dto_serialises_camelcase_with_conversation() { let dto = ResumableAgentDto::from(full(1, 2)); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["agentId"], Uuid::from_u128(1).to_string()); assert_eq!(v["name"], "Architect"); assert_eq!(v["nodeId"], Uuid::from_u128(2).to_string()); assert_eq!(v["conversationId"], "conv-123"); assert_eq!(v["wasRunning"], true); assert_eq!(v["resumeSupported"], true); // No snake_case leak. assert!(v.get("agent_id").is_none(), "no snake_case leak: {v}"); assert!(v.get("node_id").is_none()); assert!(v.get("conversation_id").is_none()); assert!(v.get("was_running").is_none()); assert!(v.get("resume_supported").is_none()); } #[test] fn minimal_dto_omits_conversation_id_when_none() { let dto = ResumableAgentDto::from(minimal(3, 4)); let v = serde_json::to_value(&dto).unwrap(); // conversationId must be OMITTED (absent, not null) when None. assert!( v.get("conversationId").is_none(), "None conversation ⇒ conversationId omitted, got: {v}" ); // Required flags are still present. assert_eq!(v["wasRunning"], false); assert_eq!(v["resumeSupported"], false); assert_eq!(v["name"], "Scratch"); } // --------------------------------------------------------------------------- // Round-trip serde (Serialize → Deserialize) on the camelCase shape // --------------------------------------------------------------------------- /// A `Deserialize` twin matching the wire shape, used to prove the serialised /// JSON is exactly the camelCase contract the frontend `ResumableAgent` mirror /// consumes (`ResumableAgentDto` is serialise-only on the prod side). #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct WireResumableAgent { agent_id: String, name: String, node_id: String, #[serde(default)] conversation_id: Option, was_running: bool, resume_supported: bool, } #[test] fn full_dto_round_trips_camelcase() { let dto = ResumableAgentDto::from(full(1, 2)); let json = serde_json::to_string(&dto).unwrap(); let back: WireResumableAgent = serde_json::from_str(&json).unwrap(); assert_eq!(back.agent_id, Uuid::from_u128(1).to_string()); assert_eq!(back.name, "Architect"); assert_eq!(back.node_id, Uuid::from_u128(2).to_string()); assert_eq!(back.conversation_id.as_deref(), Some("conv-123")); assert!(back.was_running); assert!(back.resume_supported); } #[test] fn minimal_dto_round_trips_camelcase_without_conversation() { let dto = ResumableAgentDto::from(minimal(3, 4)); let json = serde_json::to_string(&dto).unwrap(); // The key is absent from the JSON entirely. assert!( !json.contains("conversationId"), "minimal ⇒ no conversationId key, got: {json}" ); let back: WireResumableAgent = serde_json::from_str(&json).unwrap(); assert_eq!(back.conversation_id, None); assert!(!back.was_running); assert!(!back.resume_supported); } // --------------------------------------------------------------------------- // From for ResumableAgentListDto — list + order // --------------------------------------------------------------------------- #[test] fn from_output_preserves_list_and_order() { let out = ListResumableAgentsOutput { resumable: vec![full(10, 11), minimal(20, 21), full(30, 31)], }; let dto = ResumableAgentListDto::from(out); assert_eq!(dto.resumable.len(), 3); // Order preserved. assert_eq!(dto.resumable[0].agent_id, Uuid::from_u128(10).to_string()); assert_eq!(dto.resumable[1].agent_id, Uuid::from_u128(20).to_string()); assert_eq!(dto.resumable[2].agent_id, Uuid::from_u128(30).to_string()); // Middle entry is the minimal one (no conversation). assert!(dto.resumable[1].conversation_id.is_none()); } #[test] fn empty_output_serialises_empty_resumable_array() { let dto = ResumableAgentListDto::from(ListResumableAgentsOutput::default()); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["resumable"], serde_json::json!([])); } #[test] fn list_dto_serialises_camelcase_wrapper() { let out = ListResumableAgentsOutput { resumable: vec![full(10, 11)], }; let dto = ResumableAgentListDto::from(out); let v = serde_json::to_value(&dto).unwrap(); // Wrapper key is `resumable`; nested entries are camelCase. let arr = v["resumable"].as_array().expect("resumable is an array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["agentId"], Uuid::from_u128(10).to_string()); assert_eq!(arr[0]["nodeId"], Uuid::from_u128(11).to_string()); }