// Exact Rust translations of LibreMetaverse.Tests/BatchLinkTests.cs. // Source SHA-256: 092ccb2686769d27f6a8072114654e91308f88edd94223a77af92f56426ee1ee use super::*; use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::compat::{HttpMessageHandler, HttpRequest, HttpResponse, Uri}; use libremetaverse_types::{AssetType, InventoryType, UUID, WearableType}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::fmt::Write as _; use std::sync::{Arc, Mutex}; const CAP: &str = "http://fake-ais3.test/ais3"; const LIBRARY_CAP: &str = "http://fake-lib.test/lib/"; const SEED: &str = "http://fake-seed.test/caps"; fn block_on(future: F) -> F::Output { use std::task::{Context, Poll, Wake, Waker}; struct ThreadWake(std::thread::Thread); impl Wake for ThreadWake { fn wake(self: Arc) { self.0.unpark(); } } let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); let mut context = Context::from_waker(&waker); let mut future = std::pin::pin!(future); loop { match future.as_mut().poll(&mut context) { Poll::Ready(output) => return output, Poll::Pending => std::thread::park(), } } } #[derive(Clone)] struct RecordingHttp(Arc>>); impl RecordingHttp { fn requests(&self) -> Vec { self.0.lock().unwrap().clone() } } fn client_with_cap(responses: Vec<(String, u16, String)>) -> (GridClient, RecordingHttp) { let requests = Arc::new(Mutex::new(Vec::new())); let recorded = Arc::clone(&requests); let mut queues = HashMap::>::new(); for (uri, status, body) in responses { queues.entry(uri).or_default().push_back(HttpResponse { status_code: status, headers: BTreeMap::new(), content_type: Some("application/llsd+xml".into()), body: body.into_bytes(), }); } let queues = Mutex::new(queues); let seed = format!( "InventoryAPIv3{CAP}LibraryAPIv3{LIBRARY_CAP}" ); let handler = HttpMessageHandler::new(move |request, _| { let response = if request.uri.0 == SEED { HttpResponse { status_code: 200, headers: BTreeMap::new(), content_type: Some("application/llsd+xml".into()), body: seed.as_bytes().to_vec(), } } else { let uri = request.uri.0.clone(); recorded.lock().unwrap().push(request); queues .lock() .unwrap() .get_mut(&uri) .and_then(VecDeque::pop_front) .unwrap_or(HttpResponse { status_code: 404, headers: BTreeMap::new(), content_type: None, body: Vec::new(), }) }; async move { response } }); let mut client = GridClient::new().unwrap(); client.set_http_caps_client(HttpCapsClient::new(handler.clone()).unwrap()); let mut simulator_client = GridClient::new().unwrap(); simulator_client.set_http_caps_client(HttpCapsClient::new(handler).unwrap()); let simulator = Simulator::new( simulator_client, "127.0.0.1:0".parse().unwrap(), 0, None, None, ) .unwrap(); simulator .set_seed_caps(Some(Uri(SEED.into())), Some(true)) .unwrap(); let mut network = client.network(); network.set_current_sim(Some(simulator)); client.set_network(network); (client, RecordingHttp(requests)) } fn link_xml(item: UUID, parent: UUID, linked: UUID, name: &str) -> String { let item = item.to_string(); let parent = parent.to_string(); let linked = linked.to_string(); let agent = UUID::random().unwrap().to_string(); let zero = UUID::zero().to_string(); format!( "item_id{item}parent_id{parent}linked_id{linked}name{name}descagent_id{agent}inv_type{}type{}created_at0permissionscreator_id{zero}last_owner_id{zero}base_mask0everyone_mask0group_mask0next_owner_mask0owner_mask0is_owner_groupfalsegroup_id{zero}sale_infosale_price0sale_type0", InventoryType::WEARABLE.0, AssetType::Link as i32, ) } fn batch_response(links: &[(UUID, UUID, UUID, &str)]) -> String { let mut entries = String::new(); for (index, (item, parent, linked, name)) in links.iter().enumerate() { write!( entries, "{index}{}", link_xml(*item, *parent, *linked, name) ) .unwrap(); } format!( "_embeddedlinks{entries}" ) } fn wearable(name: &str, wearable_type: WearableType, asset_type: AssetType) -> InventoryWearable { let mut item = InventoryWearable::new(UUID::random().unwrap()).unwrap(); item.set_wearable_type(wearable_type); item.base.set_asset_type(asset_type); item.base.set_inventory_type(InventoryType::WEARABLE); item.base.base.set_name(name.into()); item } fn object(name: &str) -> InventoryObject { let mut item = InventoryObject::new(UUID::random().unwrap()).unwrap(); item.base.set_asset_type(AssetType::Object); item.base.set_inventory_type(InventoryType::OBJECT); item.base.base.set_name(name.into()); item } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCofLinkDescription_ClothingWearable_ReturnsWearableTypeAndZeroLayer::test 898a14ff28d953a58a01c3a0e5d9d9fcbbe61376a33fdc5caf20268aaac76242 translated #[test] fn get_cof_link_description_clothing_wearable_returns_wearable_type_and_zero_layer() { let cof = appearance::CurrentOutfitFolder::new(Some(Arc::new(GridClient::new().unwrap()))).unwrap(); let shirt = wearable("Test Shirt", WearableType::Shirt, AssetType::Clothing); assert_eq!(cof.get_cof_link_description(&shirt).unwrap(), "400"); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCofLinkDescription_BodyPartShape_ReturnsEmpty::test 4f009136d265da91afb9eafd060faf459f6c510ca0502a33ce9e128d241f9bd1 translated #[test] fn get_cof_link_description_body_part_shape_returns_empty() { let cof = appearance::CurrentOutfitFolder::new(Some(Arc::new(GridClient::new().unwrap()))).unwrap(); let shape = wearable("Test Shape", WearableType::Shape, AssetType::Bodypart); assert_eq!(cof.get_cof_link_description(&shape).unwrap(), ""); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCofLinkDescription_AttachmentObject_ReturnsEmpty::test a838329a7a23081a4022671bc794e7e706c9ab7da51598280a8befbb824d336d translated #[test] fn get_cof_link_description_attachment_object_returns_empty() { let cof = appearance::CurrentOutfitFolder::new(Some(Arc::new(GridClient::new().unwrap()))).unwrap(); assert_eq!( cof.get_cof_link_description(&object("Test Attachment")) .unwrap(), "" ); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCofLinkDescription_AllBodyPartTypes_ReturnEmpty::test aa057d55a3b34846567efe382b6e090b5d91935a4ffa6166093b34870ee5da23 translated #[test] fn get_cof_link_description_all_body_part_types_return_empty() { let cof = appearance::CurrentOutfitFolder::new(Some(Arc::new(GridClient::new().unwrap()))).unwrap(); for wearable_type in [ WearableType::Shape, WearableType::Skin, WearableType::Eyes, WearableType::Hair, ] { let item = wearable("Test body part", wearable_type, AssetType::Bodypart); assert_eq!( cof.get_cof_link_description(&item).unwrap(), "", "{wearable_type:?}" ); } } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateInventoryLinksAsync_SuccessResponse_ReturnsAllParsedLinks::test 416a1f023a2ae3b883c4c49f0a774c84a2f1a3562abb3ec848f86fe53d929f63 translated #[test] fn create_inventory_links_success_response_returns_all_parsed_links() { let folder = UUID::random().unwrap(); let ids = [ ( UUID::random().unwrap(), UUID::random().unwrap(), UUID::random().unwrap(), "Link1", ), ( UUID::random().unwrap(), UUID::random().unwrap(), UUID::random().unwrap(), "Link2", ), ]; let uri = format!("{CAP}/category/{}", folder.to_string()); let (client, _) = client_with_cap(vec![(uri, 200, batch_response(&ids))]); let payload = OSD::Map(HashMap::from([("links".into(), OSD::Array(Vec::new()))])); let result = block_on( client .ais_client() .create_inventory_links(folder, payload, None), ) .unwrap(); assert_eq!(result.len(), 2); let names: Vec = result.iter().map(|item| item.base.name()).collect(); assert!(names.contains(&"Link1".into())); assert!(names.contains(&"Link2".into())); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateInventoryLinksAsync_ServerError_ThrowsHttpRequestException::test 53e3956676e546ab8d69bb63a071028b29151013b6362c39f37516a36cad6641 translated #[test] fn create_inventory_links_server_error_throws_http_request_exception() { let folder = UUID::random().unwrap(); let (client, _) = client_with_cap(vec![( format!("{CAP}/category/{}", folder.to_string()), 500, String::new(), )]); let Err(error) = block_on(client.ais_client().create_inventory_links( folder, OSD::Map(HashMap::new()), None, )) else { panic!("expected HttpRequest error"); }; assert_eq!(error, Error::HttpRequest); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateInventoryLinksAsync_NoCapability_ReturnsEmptyList::test d9cd50e620f69e40daab52d175886f3052d3741c5d39ef42549283ff6b2b7b44 translated #[test] fn create_inventory_links_no_capability_returns_empty_list() { let client = GridClient::new().unwrap(); let result = block_on(client.ais_client().create_inventory_links( UUID::random().unwrap(), OSD::Map(HashMap::new()), None, )) .unwrap(); assert!(result.is_empty()); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateLinksAsync_EmptyList_InvokesCallbackWithTrueWithoutHttpCall::test e7ba88352761ca9fd3e956233e3743689b41f47bba09bfbb463c66f132273c70 translated #[test] fn create_links_empty_list_invokes_callback_with_true_without_http_call() { let (client, recording) = client_with_cap(Vec::new()); let callback = Arc::new(Mutex::new(None)); let recorded_result = Arc::clone(&callback); block_on(client.inventory().create_links( UUID::random().unwrap(), Box::new(Vec::<(InventoryItem, String)>::new().into_iter()), Some(Box::new(move |value| { *recorded_result.lock().unwrap() = Some(value); })), None, )) .unwrap(); assert_eq!(*callback.lock().unwrap(), Some(true)); assert!(recording.requests().is_empty()); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateLinksAsync_AisAvailable_SendsSingleBatchPost::test 369d99d37dca818991aca94d6ff9d7881874d74797c7a8b705c5925723e383db translated #[test] fn create_links_ais_available_sends_single_batch_post() { let folder = UUID::random().unwrap(); let first = wearable("Shirt", WearableType::Shirt, AssetType::Clothing); let second = object("Object"); let response = batch_response(&[ ( UUID::random().unwrap(), folder, first.base.base.uuid(), "Shirt", ), ( UUID::random().unwrap(), folder, second.base.base.uuid(), "Object", ), ]); let (client, recording) = client_with_cap(vec![( format!("{CAP}/category/{}", folder.to_string()), 200, response, )]); block_on(client.inventory().create_links( folder, Box::new(vec![(first.base, "400".into()), (second.base, String::new())].into_iter()), None, None, )) .unwrap(); assert_eq!( recording .requests() .iter() .filter(|request| request.method == "POST") .count(), 1 ); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateLinksAsync_AisAvailable_PayloadContainsCorrectFieldsForEachLink::test b7629c3d061fe297e1c1c4f41ca4f8ce9cdeb2764525421be7bdc553a5f83063 translated #[test] fn create_links_ais_available_payload_contains_correct_fields_for_each_link() { let folder = UUID::random().unwrap(); let linked = wearable("Gloves", WearableType::Gloves, AssetType::Clothing); let linked_id = linked.base.base.uuid(); let response = batch_response(&[(UUID::random().unwrap(), folder, linked_id, "Gloves")]); let (client, recording) = client_with_cap(vec![( format!("{CAP}/category/{}", folder.to_string()), 200, response, )]); block_on(client.inventory().create_links( folder, Box::new(vec![(linked.base, "900".into())].into_iter()), None, None, )) .unwrap(); let request = recording .requests() .into_iter() .find(|request| request.method == "POST") .unwrap(); let payload = OSDParser::deserialize_llsd_xml_with_string(String::from_utf8(request.body).unwrap()) .unwrap(); let OSD::Map(payload) = payload else { panic!("payload must be a map") }; let OSD::Array(links) = &payload["links"] else { panic!("links must be an array") }; assert_eq!(links.len(), 1); let OSD::Map(link) = &links[0] else { panic!("link must be a map") }; assert!(matches!(&link["linked_id"], OSD::UUID(value) if *value == linked_id)); assert!(matches!(&link["name"], OSD::String(value) if value == "Gloves")); assert!(matches!(&link["desc"], OSD::String(value) if value == "900")); assert!(matches!(&link["type"], OSD::Integer(value) if *value == AssetType::Link as i32)); assert!( matches!(&link["inv_type"], OSD::Integer(value) if *value == i32::from(InventoryType::WEARABLE.0)) ); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateLinksAsync_AisAvailable_CallbackTrueOnSuccess::test 22dc685f896ec0415e56c1c01846ad90a9757cf5d9a0ed0d3706a76ee325d92d translated #[test] fn create_links_ais_available_callback_true_on_success() { let folder = UUID::random().unwrap(); let linked = object("Hat"); let response = batch_response(&[( UUID::random().unwrap(), folder, linked.base.base.uuid(), "Hat", )]); let (client, _) = client_with_cap(vec![( format!("{CAP}/category/{}", folder.to_string()), 200, response, )]); let callback = Arc::new(Mutex::new(None)); let result = Arc::clone(&callback); block_on(client.inventory().create_links( folder, Box::new(vec![(linked.base, String::new())].into_iter()), Some(Box::new(move |value| *result.lock().unwrap() = Some(value))), None, )) .unwrap(); assert_eq!(*callback.lock().unwrap(), Some(true)); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.CreateLinksAsync_ServerError_CallbackFalse::test 5fd9b2967bc61933cea80e01c55ee809f6ade246f10fb4048ec2668e0cc71dbf translated #[test] fn create_links_server_error_callback_false() { let folder = UUID::random().unwrap(); let linked = object("Hat"); let (client, _) = client_with_cap(vec![( format!("{CAP}/category/{}", folder.to_string()), 500, String::new(), )]); let callback = Arc::new(Mutex::new(None)); let result = Arc::clone(&callback); block_on(client.inventory().create_links( folder, Box::new(vec![(linked.base, String::new())].into_iter()), Some(Box::new(move |value| *result.lock().unwrap() = Some(value))), None, )) .unwrap(); assert_eq!(*callback.lock().unwrap(), Some(false)); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCategoryLinks_UsesGetWithNoTidParameter::test 3079c03ab4b14d4ac10a410b3685f1593ea267c52de7b6323f9e8e8387314f29 translated #[test] fn get_category_links_uses_get_with_no_tid_parameter() { let folder = UUID::random().unwrap(); let uri = format!("{CAP}/category/{}/links", folder.to_string()); let response = "_embedded".to_owned(); let (client, recording) = client_with_cap(vec![(uri.clone(), 200, response)]); let (success, _, _, _) = block_on( client .ais_client() .get_category_links(folder.to_string(), None), ) .unwrap(); assert!(success); let requests = recording.requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, "GET"); assert_eq!(requests[0].uri.0, uri); assert!(!requests[0].uri.0.contains("?tid=")); } // parity-case: LibreMetaverse.Tests/BatchLinkTests.cs::BatchLinkTests.GetCategory_UsesGetWithNoTidParameter::test 98cd0aa58ce9640263d07aecd45337113b07acfc76640d17c5812abe05d35c73 translated #[test] fn get_category_uses_get_with_no_tid_parameter() { let folder = UUID::random().unwrap(); let uri = format!("{CAP}/category/{}", folder.to_string()); let response = "_embedded".to_owned(); let (client, recording) = client_with_cap(vec![(uri.clone(), 200, response)]); let (success, _, _, _) = block_on(client.ais_client().get_category(folder.to_string(), None)).unwrap(); assert!(success); let requests = recording.requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, "GET"); assert_eq!(requests[0].uri.0, uri); assert!(!requests[0].uri.0.contains("?tid=")); }