sonos_api/services/group_management/
operations.rs1use crate::operation::{parse_sonos_bool, response_string};
13use crate::{define_upnp_operation, Validate};
14use paste::paste;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct AddMemberOperationRequest {
24 pub member_id: String,
26 pub boot_seq: u32,
28}
29
30impl Validate for AddMemberOperationRequest {}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34pub struct AddMemberResponse {
35 pub current_transport_settings: String,
37 pub current_uri: String,
39 pub group_uuid_joined: String,
41 pub reset_volume_after: bool,
43 pub volume_av_transport_uri: String,
45}
46
47pub struct AddMemberOperation;
49
50impl crate::operation::UPnPOperation for AddMemberOperation {
51 type Request = AddMemberOperationRequest;
52 type Response = AddMemberResponse;
53
54 const SERVICE: crate::service::Service = crate::service::Service::GroupManagement;
55 const ACTION: &'static str = "AddMember";
56
57 fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
58 <Self::Request as Validate>::validate(request, crate::operation::ValidationLevel::Basic)?;
59 Ok(format!(
60 "<InstanceID>0</InstanceID><MemberID>{}</MemberID><BootSeq>{}</BootSeq>",
61 crate::operation::xml_escape(&request.member_id),
62 request.boot_seq
63 ))
64 }
65
66 fn parse_response(xml: &str) -> Result<Self::Response, crate::error::ApiError> {
67 Ok(AddMemberResponse {
68 current_transport_settings: response_string(xml, "CurrentTransportSettings"),
69 current_uri: response_string(xml, "CurrentURI"),
70 group_uuid_joined: response_string(xml, "GroupUUIDJoined"),
71 reset_volume_after: parse_sonos_bool(xml, "ResetVolumeAfter"),
72 volume_av_transport_uri: response_string(xml, "VolumeAVTransportURI"),
73 })
74 }
75}
76
77pub fn add_member_operation(
79 member_id: String,
80 boot_seq: u32,
81) -> crate::operation::OperationBuilder<AddMemberOperation> {
82 let request = AddMemberOperationRequest {
83 member_id,
84 boot_seq,
85 };
86 crate::operation::OperationBuilder::new(request)
87}
88
89define_upnp_operation! {
94 operation: RemoveMemberOperation,
95 action: "RemoveMember",
96 service: GroupManagement,
97 request: {
98 member_id: String,
99 },
100 response: (),
101 payload: |req| {
102 format!("<InstanceID>{}</InstanceID><MemberID>{}</MemberID>",
103 req.instance_id,
104 crate::operation::xml_escape(&req.member_id))
105 },
106 parse: |_xml| Ok(()),
107}
108
109impl Validate for RemoveMemberOperationRequest {}
110
111define_upnp_operation! {
116 operation: ReportTrackBufferingResultOperation,
117 action: "ReportTrackBufferingResult",
118 service: GroupManagement,
119 request: {
120 member_id: String,
121 result_code: i32,
122 },
123 response: (),
124 payload: |req| {
125 format!(
126 "<InstanceID>{}</InstanceID><MemberID>{}</MemberID><ResultCode>{}</ResultCode>",
127 req.instance_id,
128 crate::operation::xml_escape(&req.member_id),
129 req.result_code
130 )
131 },
132 parse: |_xml| Ok(()),
133}
134
135impl Validate for ReportTrackBufferingResultOperationRequest {}
136
137define_upnp_operation! {
142 operation: SetSourceAreaIdsOperation,
143 action: "SetSourceAreaIds",
144 service: GroupManagement,
145 request: {
146 desired_source_area_ids: String,
147 },
148 response: (),
149 payload: |req| {
150 format!(
151 "<InstanceID>{}</InstanceID><DesiredSourceAreaIds>{}</DesiredSourceAreaIds>",
152 req.instance_id,
153 crate::operation::xml_escape(&req.desired_source_area_ids)
154 )
155 },
156 parse: |_xml| Ok(()),
157}
158
159impl Validate for SetSourceAreaIdsOperationRequest {}
160
161pub use add_member_operation as add_member;
166pub use remove_member_operation as remove_member;
167pub use report_track_buffering_result_operation as report_track_buffering_result;
168pub use set_source_area_ids_operation as set_source_area_ids;
169
170#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::operation::UPnPOperation;
178
179 #[test]
182 fn test_add_member_builder() {
183 let op = add_member_operation("RINCON_123".to_string(), 42)
184 .build()
185 .unwrap();
186 assert_eq!(op.request().member_id, "RINCON_123");
187 assert_eq!(op.request().boot_seq, 42);
188 assert_eq!(op.metadata().action, "AddMember");
189 assert_eq!(op.metadata().service, "GroupManagement");
190 }
191
192 #[test]
193 fn test_add_member_payload() {
194 let request = AddMemberOperationRequest {
195 member_id: "RINCON_ABC123".to_string(),
196 boot_seq: 100,
197 };
198 let payload = AddMemberOperation::build_payload(&request).unwrap();
199 assert!(payload.contains("<InstanceID>0</InstanceID>"));
200 assert!(payload.contains("<MemberID>RINCON_ABC123</MemberID>"));
201 assert!(payload.contains("<BootSeq>100</BootSeq>"));
202 }
203
204 #[test]
205 fn test_add_member_payload_escapes_xml_special_chars() {
206 let request = AddMemberOperationRequest {
207 member_id: "RINCON_123</MemberID><BootSeq>999</BootSeq><Foo>bar".to_string(),
208 boot_seq: 42,
209 };
210 let payload = AddMemberOperation::build_payload(&request).unwrap();
211 assert!(!payload.contains("</MemberID><BootSeq>999"));
213 assert!(payload.contains("</MemberID>"));
214 assert!(payload.contains("<BootSeq>42</BootSeq>"));
215 }
216
217 #[test]
218 fn test_add_member_response_parsing_reset_volume_true() {
219 let xml_str = r#"<AddMemberResponse>
220 <CurrentTransportSettings>settings</CurrentTransportSettings>
221 <CurrentURI>x-rincon:RINCON_123</CurrentURI>
222 <GroupUUIDJoined>group-uuid-123</GroupUUIDJoined>
223 <ResetVolumeAfter>1</ResetVolumeAfter>
224 <VolumeAVTransportURI>x-rincon:RINCON_456</VolumeAVTransportURI>
225 </AddMemberResponse>"#;
226 let response = AddMemberOperation::parse_response(xml_str).unwrap();
227
228 assert_eq!(response.current_transport_settings, "settings");
229 assert_eq!(response.current_uri, "x-rincon:RINCON_123");
230 assert_eq!(response.group_uuid_joined, "group-uuid-123");
231 assert!(response.reset_volume_after);
232 assert_eq!(response.volume_av_transport_uri, "x-rincon:RINCON_456");
233 }
234
235 #[test]
236 fn test_add_member_response_parsing_reset_volume_false() {
237 let xml_str = r#"<AddMemberResponse>
238 <CurrentTransportSettings></CurrentTransportSettings>
239 <CurrentURI></CurrentURI>
240 <GroupUUIDJoined></GroupUUIDJoined>
241 <ResetVolumeAfter>0</ResetVolumeAfter>
242 <VolumeAVTransportURI></VolumeAVTransportURI>
243 </AddMemberResponse>"#;
244 let response = AddMemberOperation::parse_response(xml_str).unwrap();
245
246 assert!(!response.reset_volume_after);
247 }
248
249 #[test]
252 fn test_remove_member_builder() {
253 let op = remove_member_operation("RINCON_456".to_string())
254 .build()
255 .unwrap();
256 assert_eq!(op.request().member_id, "RINCON_456");
257 assert_eq!(op.metadata().action, "RemoveMember");
258 assert_eq!(op.metadata().service, "GroupManagement");
259 }
260
261 #[test]
262 fn test_remove_member_payload() {
263 let request = RemoveMemberOperationRequest {
264 member_id: "RINCON_XYZ".to_string(),
265 instance_id: 0,
266 };
267 let payload = RemoveMemberOperation::build_payload(&request).unwrap();
268 assert!(payload.contains("<MemberID>RINCON_XYZ</MemberID>"));
269 assert!(payload.contains("<InstanceID>0</InstanceID>"));
270 }
271
272 #[test]
275 fn test_report_track_buffering_result_builder() {
276 let op = report_track_buffering_result_operation("RINCON_789".to_string(), 0)
277 .build()
278 .unwrap();
279 assert_eq!(op.request().member_id, "RINCON_789");
280 assert_eq!(op.request().result_code, 0);
281 assert_eq!(op.metadata().action, "ReportTrackBufferingResult");
282 assert_eq!(op.metadata().service, "GroupManagement");
283 }
284
285 #[test]
286 fn test_report_track_buffering_result_payload() {
287 let request = ReportTrackBufferingResultOperationRequest {
288 member_id: "RINCON_ABC".to_string(),
289 result_code: -1,
290 instance_id: 0,
291 };
292 let payload = ReportTrackBufferingResultOperation::build_payload(&request).unwrap();
293 assert!(payload.contains("<MemberID>RINCON_ABC</MemberID>"));
294 assert!(payload.contains("<ResultCode>-1</ResultCode>"));
295 }
296
297 #[test]
300 fn test_set_source_area_ids_builder() {
301 let op = set_source_area_ids_operation("area1,area2".to_string())
302 .build()
303 .unwrap();
304 assert_eq!(op.request().desired_source_area_ids, "area1,area2");
305 assert_eq!(op.metadata().action, "SetSourceAreaIds");
306 assert_eq!(op.metadata().service, "GroupManagement");
307 }
308
309 #[test]
310 fn test_set_source_area_ids_payload() {
311 let request = SetSourceAreaIdsOperationRequest {
312 desired_source_area_ids: "source-area-123".to_string(),
313 instance_id: 0,
314 };
315 let payload = SetSourceAreaIdsOperation::build_payload(&request).unwrap();
316 assert!(payload.contains("<DesiredSourceAreaIds>source-area-123</DesiredSourceAreaIds>"));
317 }
318
319 #[test]
322 fn test_service_constant() {
323 assert_eq!(
324 AddMemberOperation::SERVICE,
325 crate::service::Service::GroupManagement
326 );
327 assert_eq!(
328 RemoveMemberOperation::SERVICE,
329 crate::service::Service::GroupManagement
330 );
331 assert_eq!(
332 ReportTrackBufferingResultOperation::SERVICE,
333 crate::service::Service::GroupManagement
334 );
335 assert_eq!(
336 SetSourceAreaIdsOperation::SERVICE,
337 crate::service::Service::GroupManagement
338 );
339 }
340}
341
342#[cfg(test)]
347mod property_tests {
348 use super::*;
349 use crate::operation::{UPnPOperation, ValidationLevel};
350 use proptest::prelude::*;
351
352 proptest! {
362 #![proptest_config(ProptestConfig::with_cases(100))]
363
364 #[test]
366 fn prop_add_member_bool_parsing(reset_vol in proptest::bool::ANY) {
367 let xml_value = if reset_vol { "1" } else { "0" };
368 let xml_str = format!(r#"<AddMemberResponse>
369 <CurrentTransportSettings>test-settings</CurrentTransportSettings>
370 <CurrentURI>x-rincon:RINCON_TEST</CurrentURI>
371 <GroupUUIDJoined>test-group-uuid</GroupUUIDJoined>
372 <ResetVolumeAfter>{xml_value}</ResetVolumeAfter>
373 <VolumeAVTransportURI>x-rincon:RINCON_VOL</VolumeAVTransportURI>
374 </AddMemberResponse>"#);
375
376 let response = AddMemberOperation::parse_response(&xml_str)
377 .expect("Response parsing should succeed");
378
379 prop_assert_eq!(
380 response.reset_volume_after,
381 reset_vol,
382 "ResetVolumeAfter '{}' should parse to {}",
383 xml_value,
384 reset_vol
385 );
386 }
387 }
388
389 fn member_id_strategy() -> impl Strategy<Value = String> {
400 prop::string::string_regex("[A-Za-z0-9_-]{0,50}").unwrap()
401 }
402
403 fn source_area_ids_strategy() -> impl Strategy<Value = String> {
405 prop::string::string_regex("[A-Za-z0-9,_-]{0,100}").unwrap()
406 }
407
408 proptest! {
409 #![proptest_config(ProptestConfig::with_cases(100))]
410
411 #[test]
413 fn prop_remove_member_validation_passes(member_id in member_id_strategy()) {
414 let request = RemoveMemberOperationRequest {
415 member_id,
416 instance_id: 0,
417 };
418 let result = <RemoveMemberOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
419 prop_assert!(
420 result.is_ok(),
421 "RemoveMember validation should always pass, got: {:?}",
422 result
423 );
424 }
425
426 #[test]
428 fn prop_report_track_buffering_result_validation_passes(
429 member_id in member_id_strategy(),
430 result_code in prop::num::i32::ANY,
431 ) {
432 let request = ReportTrackBufferingResultOperationRequest {
433 member_id,
434 result_code,
435 instance_id: 0,
436 };
437 let result = <ReportTrackBufferingResultOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
438 prop_assert!(
439 result.is_ok(),
440 "ReportTrackBufferingResult validation should always pass, got: {:?}",
441 result
442 );
443 }
444
445 #[test]
447 fn prop_set_source_area_ids_validation_passes(
448 desired_source_area_ids in source_area_ids_strategy(),
449 ) {
450 let request = SetSourceAreaIdsOperationRequest {
451 desired_source_area_ids,
452 instance_id: 0,
453 };
454 let result = <SetSourceAreaIdsOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
455 prop_assert!(
456 result.is_ok(),
457 "SetSourceAreaIds validation should always pass, got: {:?}",
458 result
459 );
460 }
461 }
462}