tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit b0318dafece6648b8986afefe9a91ed629a93c44
parent 5d5e401c2a75dbc9e5540e66b68fb59b09f826d7
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 12:13:31 -0700

http: complete seller endpoint

Diffstat:
Mcrates/tangle/tests/run_integration.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/lib.rs | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 272 insertions(+), 7 deletions(-)

diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs @@ -64,6 +64,7 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens."); let label = listing_label(&listing, 1_714_124_440, "reviewed"); let report = listing_report(&listing, 1_714_124_441, "spam"); + let profile = seller_profile(1_714_124_442); let auth = build_fixture_event(&auth_event_spec()).expect("auth"); let seller = FixtureKey::Seller.public_key(); @@ -125,6 +126,29 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { publisher .send(Message::Text( + serde_json::json!(["EVENT", event_to_value(&profile)]) + .to_string() + .into(), + )) + .await + .expect("profile send"); + assert_ok(&next_json(&mut publisher).await, true); + publisher + .send(Message::Text( + serde_json::json!(["REQ", "sub-profile", { "ids": [profile.id().as_str()] }]) + .to_string() + .into(), + )) + .await + .expect("profile fetch send"); + let fetched_profile = next_json(&mut publisher).await; + assert_eq!(fetched_profile[0], "EVENT"); + assert_eq!(fetched_profile[1], "sub-profile"); + assert_eq!(fetched_profile[2]["id"], profile.id().as_str()); + assert_eq!(next_label(&mut publisher).await, "EOSE"); + + publisher + .send(Message::Text( serde_json::json!(["EVENT", event_to_value(&listing)]) .to_string() .into(), @@ -376,6 +400,9 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { let seller_detail = http_get(port, &format!("/api/sellers/{}", seller.as_str())); assert!(seller_detail.contains("200 OK")); assert!(seller_detail.contains(seller.as_str())); + assert!(seller_detail.contains(profile.id().as_str())); + assert!(seller_detail.contains("\"display_name\":\"Radroots Market\"")); + assert!(seller_detail.contains("\"regions\":[\"cascadia\",\"pnw\"]")); stop_relay(relay); @@ -400,6 +427,30 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { ); assert!( store + .raw_event_row(profile.id()) + .await + .expect("profile raw row") + .is_some() + ); + let profile_row = store + .seller_profile_row(seller.as_str()) + .await + .expect("profile row") + .expect("profile row exists"); + assert_eq!(profile_row["event_id"], profile.id().as_str()); + assert_eq!(profile_row["name"], "radroots-market"); + assert_eq!(profile_row["display_name"], "Radroots Market"); + assert_eq!( + profile_row["regions"], + serde_json::json!(["cascadia", "pnw"]) + ); + assert_eq!(profile_row["categories"], serde_json::json!(["produce"])); + assert_eq!( + profile_row["trust_markers"], + serde_json::json!(["csa", "regenerative"]) + ); + assert!( + store .listing_current_row(&listing_key) .await .expect("listing row") @@ -1046,6 +1097,32 @@ fn assert_ok(message: &Value, accepted: bool) { assert_eq!(message[2], accepted, "relay OK frame: {message}"); } +fn seller_profile(created_at: u64) -> tangle_protocol::Event { + let content = serde_json::json!({ + "name": "radroots-market", + "display_name": "Radroots Market", + "about": "Local food seller profile", + "picture": "https://fixtures.radroots.test/seller.png", + "website": "https://seller.radroots.test", + "nip05": "seller@radroots.test", + "lud16": "seller@pay.radroots.test" + }); + build_fixture_event_from_parts( + FixtureKey::Seller, + created_at, + 0, + vec![ + vec!["region".to_owned(), "PNW".to_owned()], + vec!["region".to_owned(), "Cascadia".to_owned()], + vec!["category".to_owned(), "Produce".to_owned()], + vec!["trust".to_owned(), "CSA".to_owned()], + vec!["trust".to_owned(), "regenerative".to_owned()], + ], + &content.to_string(), + ) + .expect("seller profile") +} + fn listing_comment( listing: &tangle_protocol::Event, created_at: u64, diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -39,7 +39,8 @@ use tangle_store_surreal::{ ForumThreadProjectionOutcome, ForumThreadProjectionQuery, LabelProjectionOutcome, LabelProjectionQuery, ListingProjectionQuery, LongFormProjectionOutcome, MigrationApplyOutcome, ReactionProjectionOutcome, ReportProjectionOutcome, ReportProjectionQuery, SearchDocumentQuery, - SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan, + SellerProfileProjectionOutcome, SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, + base_migration_plan, }; use tokio::net::TcpListener; use tokio::sync::broadcast; @@ -626,6 +627,13 @@ async fn project_stored_event( Ok(ReportProjectionOutcome::NotReport | ReportProjectionOutcome::Ineligible) => false, Err(_) => return Err(RuntimeCommandError::store("event projection failed")), }; + let seller_profile_projected = match store.project_seller_profile(event, now).await { + Ok(SellerProfileProjectionOutcome::Projected) => true, + Ok( + SellerProfileProjectionOutcome::NotProfile | SellerProfileProjectionOutcome::Ineligible, + ) => false, + Err(_) => return Err(RuntimeCommandError::store("event projection failed")), + }; if effect == AdmissionEffect::StoreRawAndProjectPublicListing { if store.project_current_listing(event, now).await.is_err() || store.project_listing_helpers(event).await.is_err() @@ -640,7 +648,8 @@ async fn project_stored_event( || long_form_projected || forum_thread_projected || label_projected - || report_projected) + || report_projected + || seller_profile_projected) } fn parse_event_import_document(raw: &str) -> Result<Vec<Event>, RuntimeCommandError> { @@ -2049,6 +2058,11 @@ impl EventMessageHandler { || self.store.project_forum_thread(&event, now).await.is_err() || self.store.project_label(&event, now).await.is_err() || self.store.project_report(&event, now).await.is_err() + || self + .store + .project_seller_profile(&event, now) + .await + .is_err() { return ok_rejected(event_id, "error: projection failed".to_owned()); } @@ -2767,6 +2781,17 @@ pub struct ForumThreadDetailDocument { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SellerDocument { pub pubkey: String, + pub event_id: Option<String>, + pub name: Option<String>, + pub display_name: Option<String>, + pub about: Option<String>, + pub picture: Option<String>, + pub website: Option<String>, + pub nip05: Option<String>, + pub lud16: Option<String>, + pub regions: Vec<String>, + pub categories: Vec<String>, + pub trust_markers: Vec<String>, pub approved: bool, pub blocked: bool, pub active_listing_count: u64, @@ -3324,6 +3349,15 @@ async fn seller_detail( .relay_user_row(pubkey.as_str()) .await .map_err(|_| ApiError::internal())?; + let profile = state + .store + .seller_profile_row(pubkey.as_str()) + .await + .map_err(|_| ApiError::internal())?; + let visible_profile = match profile.as_ref() { + Some(row) if !bool_field(row, "hidden")? && !bool_field(row, "deleted")? => Some(row), + _ => None, + }; let listings = state .store .query_current_listings( @@ -3335,15 +3369,68 @@ async fn seller_detail( .map_err(|_| ApiError::internal())?; Ok(Json(SellerDocument { pubkey: pubkey.as_str().to_owned(), + event_id: visible_profile + .map(|row| string_field(row, "event_id")) + .transpose()?, + name: visible_profile + .map(|row| optional_string_field(row, "name")) + .transpose()? + .flatten(), + display_name: visible_profile + .map(|row| optional_string_field(row, "display_name")) + .transpose()? + .flatten(), + about: visible_profile + .map(|row| optional_string_field(row, "about")) + .transpose()? + .flatten(), + picture: visible_profile + .map(|row| optional_string_field(row, "picture")) + .transpose()? + .flatten(), + website: visible_profile + .map(|row| optional_string_field(row, "website")) + .transpose()? + .flatten(), + nip05: visible_profile + .map(|row| optional_string_field(row, "nip05")) + .transpose()? + .flatten(), + lud16: visible_profile + .map(|row| optional_string_field(row, "lud16")) + .transpose()? + .flatten(), + regions: visible_profile + .map(|row| string_array_field(row, "regions")) + .transpose()? + .unwrap_or_default(), + categories: visible_profile + .map(|row| string_array_field(row, "categories")) + .transpose()? + .unwrap_or_default(), + trust_markers: visible_profile + .map(|row| string_array_field(row, "trust_markers")) + .transpose()? + .unwrap_or_default(), approved: seller .as_ref() .and_then(|row| row.get("seller_approved")) .and_then(serde_json::Value::as_bool) + .or_else(|| { + visible_profile + .and_then(|row| row.get("seller_approved")) + .and_then(serde_json::Value::as_bool) + }) .unwrap_or(false), blocked: seller .as_ref() .and_then(|row| row.get("blocked")) .and_then(serde_json::Value::as_bool) + .or_else(|| { + visible_profile + .and_then(|row| row.get("blocked")) + .and_then(serde_json::Value::as_bool) + }) .unwrap_or(false), active_listing_count: listings.len() as u64, })) @@ -4126,7 +4213,9 @@ mod tests { AdmissionPolicy, EventValidator, MarketplaceListingStatus, MarketplaceSort, NostrFilterCompiler, RateLimitConfig, RuntimeLimits, }; - use tangle_nips::{FulfillmentMethod, ListingUnit, parse_relay_auth_event}; + use tangle_nips::{ + FulfillmentMethod, ListingUnit, NIP01_METADATA_KIND, parse_relay_auth_event, + }; use tangle_protocol::{ ClientMessage, EventId, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value, filter_from_value, @@ -6494,9 +6583,21 @@ mod tests { async fn seller_endpoint_returns_policy_state_and_active_listing_count() { let store = runtime_memory_store().await; let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let profile = seller_profile(1_714_125_300, "radroots-market", Some("Radroots Market")); let seller = listing.unsigned().pubkey().as_str().to_owned(); let listing_key = format!("30402:{seller}:listing-a"); store + .store_raw_event(&StoredEvent::new( + profile.clone(), + UnixTimestamp::new(1_714_125_301), + )) + .await + .expect("store profile"); + store + .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_302)) + .await + .expect("project profile"); + store .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) .await .expect("project listing"); @@ -6525,6 +6626,17 @@ mod tests { serde_json::from_slice::<serde_json::Value>(&body).expect("json"), serde_json::json!({ "pubkey": seller, + "event_id": profile.id().as_str(), + "name": "radroots-market", + "display_name": "Radroots Market", + "about": "Local food seller profile", + "picture": "https://fixtures.radroots.test/seller.png", + "website": "https://seller.radroots.test", + "nip05": "seller@radroots.test", + "lud16": "seller@pay.radroots.test", + "regions": ["cascadia", "pnw"], + "categories": ["produce"], + "trust_markers": ["csa", "regenerative"], "approved": true, "blocked": false, "active_listing_count": 1 @@ -6539,6 +6651,38 @@ mod tests { .expect("hide listing") .check() .expect("hide check"); + let response = listings_router(ListingsHttpState::new( + store.clone(), + RuntimeLimits::default(), + )) + .oneshot( + Request::builder() + .uri(format!("/api/sellers/{seller}")) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).expect("json")["active_listing_count"], + 0 + ); + + let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); + store + .hide_event( + profile.id(), + "profile moderation", + "admin_api", + admin_pubkey.as_str(), + UnixTimestamp::new(1_714_125_600), + ) + .await + .expect("hide profile"); let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) .oneshot( Request::builder() @@ -6552,10 +6696,11 @@ mod tests { let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json")["active_listing_count"], - 0 - ); + let document = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); + assert!(document["event_id"].is_null()); + assert!(document["name"].is_null()); + assert_eq!(document["regions"], serde_json::json!([])); + assert_eq!(document["approved"], true); } #[tokio::test] @@ -6582,6 +6727,17 @@ mod tests { serde_json::from_slice::<serde_json::Value>(&body).expect("json"), serde_json::json!({ "pubkey": missing, + "event_id": null, + "name": null, + "display_name": null, + "about": null, + "picture": null, + "website": null, + "nip05": null, + "lud16": null, + "regions": [], + "categories": [], + "trust_markers": [], "approved": false, "blocked": false, "active_listing_count": 0 @@ -6613,6 +6769,38 @@ mod tests { store } + fn seller_profile( + created_at: u64, + name: &str, + display_name: Option<&str>, + ) -> tangle_protocol::Event { + let mut content = serde_json::json!({ + "name": name, + "about": "Local food seller profile", + "picture": "https://fixtures.radroots.test/seller.png", + "website": "https://seller.radroots.test", + "nip05": "seller@radroots.test", + "lud16": "seller@pay.radroots.test" + }); + if let Some(display_name) = display_name { + content["display_name"] = serde_json::Value::String(display_name.to_owned()); + } + build_fixture_event_from_parts( + FixtureKey::Seller, + created_at, + u64::from(NIP01_METADATA_KIND), + vec![ + vec!["region".to_owned(), "PNW".to_owned()], + vec!["region".to_owned(), "Cascadia".to_owned()], + vec!["category".to_owned(), "Produce".to_owned()], + vec!["trust".to_owned(), "CSA".to_owned()], + vec!["trust".to_owned(), "regenerative".to_owned()], + ], + &content.to_string(), + ) + .expect("seller profile") + } + fn listing_comment( listing: &tangle_protocol::Event, created_at: u64,