Implement estate and marketplace services (#73)
All checks were successful
Native code generation / deterministic (push) Successful in 15m57s
Imaging and meshing gate / native (push) Successful in 5m16s
Native Rust workspace compile / compile (push) Successful in 5m11s

This commit is contained in:
2026-08-10 19:25:31 +00:00
parent c22eef5d0e
commit 763e9e5044
14 changed files with 3132 additions and 1202 deletions

View File

@@ -0,0 +1,242 @@
use libremetaverse::{GridClient, HttpCapsClient, Simulator, UserReport, UserReportType};
use libremetaverse_compat_tests::block_on;
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{
CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, Uri,
};
use libremetaverse_types::{Error, UUID, Vector3};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
const SEED: &str = "http://test.invalid/seed";
const CATEGORIES: &str = "http://test.invalid/abuse-categories";
const SEND: &str = "http://test.invalid/send-report";
const SEND_SCREENSHOT: &str = "http://test.invalid/send-report-with-screenshot";
fn response(status_code: u16, body: Vec<u8>) -> HttpResponse {
HttpResponse {
status_code,
headers: BTreeMap::new(),
content_type: Some("application/llsd+xml".into()),
body,
}
}
fn fixture(
capabilities: &[(&str, &str)],
categories: OSD,
) -> (GridClient, Arc<Mutex<Vec<HttpRequest>>>) {
let capabilities = capabilities
.iter()
.map(|(name, uri)| ((*name).to_owned(), OSD::Uri(Uri((*uri).to_owned()))))
.collect::<HashMap<_, _>>();
let seed_body =
OSDParser::serialize_llsd_xml_bytes(OSD::Map(capabilities)).expect("seed capability LLSD");
let categories_body =
OSDParser::serialize_llsd_xml_bytes(categories).expect("category response LLSD");
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let handler = HttpMessageHandler::new(move |request, _token| {
let response = if request.uri.0 == SEED {
response(200, seed_body.clone())
} else if request.uri.0.starts_with(CATEGORIES) {
response(200, categories_body.clone())
} else if request.uri.0 == SEND || request.uri.0 == SEND_SCREENSHOT {
response(200, Vec::new())
} else {
response(404, Vec::new())
};
captured.lock().expect("request recording").push(request);
async move { response }
});
let http = HttpCapsClient::new(handler).expect("HTTP fixture");
let mut client = GridClient::new().expect("GridClient constructor");
client.set_http_caps_client(http.clone());
let mut simulator_client = GridClient::new().expect("simulator GridClient constructor");
simulator_client.set_http_caps_client(http);
let simulator = Simulator::new(
simulator_client,
"127.0.0.1:0".parse().expect("loopback endpoint"),
0,
None,
None,
)
.expect("fixture simulator");
simulator
.set_seed_caps(Some(Uri(SEED.into())), Some(true))
.expect("fixture seed capability");
let mut network = client.network();
network.set_current_sim(Some(simulator));
client.set_network(network);
(client, requests)
}
fn report(screenshot_id: UUID) -> UserReport {
UserReport {
report_type: UserReportType::Complaint,
category: 7,
screenshot_id,
object_id: UUID::random().expect("object UUID"),
abuser_id: UUID::random().expect("abuser UUID"),
abuse_region_name: "Recorded Region".into(),
abuse_region_id: UUID::random().expect("region UUID"),
position: Vector3 {
x: 12.5,
y: 64.0,
z: 21.25,
},
summary: "summary".into(),
details: "details".into(),
}
}
#[test]
fn categories_preserve_localized_descriptions_and_language_query() {
let categories = OSD::Map(HashMap::from([(
"categories".into(),
OSD::Array(vec![
OSD::Map(HashMap::from([
(
"description_localized".into(),
OSD::String("Harassment".into()),
),
("category".into(), OSD::String("7".into())),
])),
OSD::Map(HashMap::from([
("description_localized".into(), OSD::String("Fraud".into())),
("category".into(), OSD::String("11".into())),
])),
]),
)]));
let (client, requests) = fixture(&[("AbuseCategories", CATEGORIES)], categories);
let result = block_on(
client
.user_reports()
.fetch_abuse_report_categories(Some("en-US"), None),
)
.expect("category request");
assert_eq!(result.get("Harassment").map(String::as_str), Some("7"));
assert_eq!(result.get("Fraud").map(String::as_str), Some("11"));
assert!(
requests
.lock()
.expect("request recording")
.iter()
.any(|request| request.uri.0 == format!("{CATEGORIES}?lc=en-US"))
);
}
#[test]
fn screenshot_report_selects_capability_and_preserves_exact_payload() {
let screenshot_id = UUID::random().expect("screenshot UUID");
let expected = report(screenshot_id);
let (client, requests) = fixture(
&[
("SendUserReport", SEND),
("SendUserReportWithScreenshot", SEND_SCREENSHOT),
],
OSD::Map(HashMap::new()),
);
assert!(
block_on(
client
.user_reports()
.send_user_report(expected.clone(), None)
)
.expect("report submission")
);
let requests = requests.lock().expect("request recording");
let request = requests
.iter()
.find(|request| request.uri.0 == SEND_SCREENSHOT)
.expect("screenshot capability POST");
assert_eq!(request.method, "POST");
let OSD::Map(payload) =
OSDParser::deserialize_with_bytes(request.body.clone()).expect("report payload LLSD")
else {
panic!("report payload must be a map");
};
assert_eq!(payload.get("report-type").unwrap().as_integer().unwrap(), 3);
assert_eq!(payload.get("category").unwrap().as_integer().unwrap(), 7);
assert_eq!(payload.get("check-flags").unwrap().as_integer().unwrap(), 0);
assert_eq!(
payload.get("screenshot-id").unwrap().as_uuid().unwrap(),
screenshot_id
);
assert_eq!(
payload.get("object-id").unwrap().as_uuid().unwrap(),
expected.object_id
);
assert_eq!(
payload.get("abuser-id").unwrap().as_uuid().unwrap(),
expected.abuser_id
);
assert_eq!(
payload
.get("abuse-region-name")
.unwrap()
.as_string()
.unwrap(),
""
);
assert_eq!(
payload.get("abuse-region-id").unwrap().as_uuid().unwrap(),
UUID::zero()
);
assert_eq!(
payload.get("position").unwrap().as_vector3().unwrap(),
expected.position
);
assert_eq!(
payload.get("summary").unwrap().as_string().unwrap(),
"summary"
);
assert_eq!(
payload.get("version-string").unwrap().as_string().unwrap(),
"<3 LibreMetaverse"
);
assert_eq!(
payload.get("details").unwrap().as_string().unwrap(),
"details"
);
assert!(!requests.iter().any(|request| request.uri.0 == SEND));
}
#[test]
fn live_report_submission_requires_explicit_opt_in() {
let live_cap = "https://example.com/send-report";
let (client, requests) = fixture(&[("SendUserReport", live_cap)], OSD::Map(HashMap::new()));
let error = block_on(
client
.user_reports()
.send_user_report(report(UUID::zero()), None),
)
.expect_err("live report must be gated");
assert_eq!(error, Error::InvalidOperation);
assert!(
!requests
.lock()
.expect("request recording")
.iter()
.any(|request| request.uri.0 == live_cap)
);
}
#[test]
fn category_request_propagates_preexisting_cancellation() {
let (client, _) = fixture(&[("AbuseCategories", CATEGORIES)], OSD::Map(HashMap::new()));
let cancellation = CancellationTokenSource::new();
cancellation.cancel();
let error = block_on(
client
.user_reports()
.fetch_abuse_report_categories(None, Some(cancellation.token())),
)
.expect_err("cancellation must be propagated");
assert_eq!(error, Error::Cancelled);
}

View File

@@ -1,4 +1,6 @@
use libremetaverse::marketplace::{MarketplaceListing, MarketplaceManager};
use libremetaverse::marketplace::{
MarketplaceListing, MarketplaceListingStatus, MarketplaceManager,
};
use libremetaverse::{
DirectoryManager, DirectoryManagerAgentSearchData, EstateTask, EstateTools, FriendsManager,
GroupManager, GroupProfileEventArgs, ObjectManager, ParcelManager, ParcelSettings, Simulator,
@@ -9,14 +11,6 @@ use libremetaverse_types::compat::{
CancellationToken, EventHandler, IAsyncEnumerable, Subscription,
};
fn member_id<T>(result: Result<T, libremetaverse::Error>) -> &'static str {
result
.err()
.expect("failure-only shim unexpectedly returned a value")
.csharp_member()
.expect("failure-only shim error")
}
fn compile_search_call(directory: &DirectoryManager, token: CancellationToken) {
let _: Result<IAsyncEnumerable<DirectoryManagerAgentSearchData>, libremetaverse::Error> =
directory.search_people(String::new(), Some(token));
@@ -67,15 +61,17 @@ fn world_and_social_flows_have_typed_callable_signatures() {
}
#[test]
fn world_facing_constructors_preserve_implemented_and_pending_behavior() {
assert_eq!(
member_id(EstateTask::new()),
"M:LibreMetaverse.EstateTask.#ctor"
);
assert_eq!(
member_id(MarketplaceListing::new()),
"M:LibreMetaverse.Marketplace.MarketplaceListing.#ctor"
);
fn world_facing_constructors_preserve_implemented_defaults() {
let task = EstateTask::new().expect("EstateTask constructor");
assert_eq!(task.task_id, UUID::zero());
assert_eq!(task.task_local_id, 0);
assert_eq!((task.score, task.mono_score), (0.0, 0.0));
assert!(task.task_name.is_empty());
assert!(task.owner_name.is_empty());
let listing = MarketplaceListing::new().expect("MarketplaceListing constructor");
assert_eq!(listing.status(), MarketplaceListingStatus::Unknown);
assert_eq!(listing.listing_id(), 0);
assert!(listing.edit_url().is_none());
let parcel = ParcelSettings::new().expect("ParcelSettings constructor");
assert!(parcel.track_parcels);
assert!(parcel.always_request_acl);