tangle


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

commit d55008cb87e41f46e01b5624dd86116b26fbbb32
parent c862449bc1a18f7586b12f36430e69466c9478d4
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 13:35:02 -0700

tests: add nip99 conformance suite

- add a relay-backed NIP-99 marketplace listing conformance test
- hydrate current-event queries from canonical raw event rows
- apply addressable tag filters to current-event relay results
- verify listing API and SurrealDB projection state after relay shutdown

Diffstat:
Acrates/tangle/tests/nip99_conformance.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_store_surreal/src/lib.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 149 insertions(+), 8 deletions(-)

diff --git a/crates/tangle/tests/nip99_conformance.rs b/crates/tangle/tests/nip99_conformance.rs @@ -0,0 +1,97 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::fs; +use support::{ + RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store, send_auth, + send_event, send_req, +}; +use tangle_test_support::{ + FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec, +}; + +#[tokio::test] +async fn nip99_conformance_projects_and_serves_public_listings() { + let seller = FixtureKey::Seller.public_key(); + let harness = RelayHarness::start( + "nip99_conformance", + serde_json::json!({ + "approved_sellers": [seller.as_str()] + }), + ); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let auth = build_fixture_event(&auth_event_spec()).expect("auth"); + + let mut client = connect_client(harness.port).await; + assert_ok(&send_auth(&mut client, &auth).await, true); + assert_ok(&send_event(&mut client, &listing).await, true); + let by_address = send_req( + &mut client, + "nip99-address", + serde_json::json!({ + "kinds": [30402], + "authors": [seller.as_str()], + "#d": ["listing-a"], + "limit": 5 + }), + ) + .await; + assert_eq!(by_address[0], "EVENT"); + assert_eq!(by_address[1], "nip99-address"); + assert_eq!(by_address[2]["id"], listing.id().as_str()); + assert_eq!(by_address[2]["kind"], 30402); + assert_eq!(next_label(&mut client).await, "EOSE"); + + let list_response = http_get( + harness.port, + &format!( + "/api/listings?status=active&seller={}&unit=lb&currency=usd&limit=5", + seller.as_str() + ), + ); + assert!(list_response.contains("200 OK")); + assert!(list_response.contains(listing.id().as_str())); + assert!(list_response.contains("\"title\":\"Carrot bunches\"")); + assert!(list_response.contains("\"unit\":\"lb\"")); + let detail_response = http_get( + harness.port, + &format!("/api/listings/{}/listing-a", seller.as_str()), + ); + assert!(detail_response.contains("200 OK")); + assert!(detail_response.contains(listing.id().as_str())); + assert!(detail_response.contains("\"content\":\"Sweet storage carrots.\"")); + + let store_config = harness.store_config(); + let root = harness.root.clone(); + drop(client); + harness.stop(); + let store = reopen_store(&store_config).await; + let listing_key = format!("30402:{}:listing-a", seller.as_str()); + assert!( + store + .raw_event_row(listing.id()) + .await + .expect("raw row") + .is_some() + ); + let row = store + .listing_current_row(&listing_key) + .await + .expect("listing row") + .expect("listing row exists"); + assert_eq!(row["listing_key"], listing_key); + assert_eq!(row["event_id"], listing.id().as_str()); + assert_eq!(row["seller_pubkey"], seller.as_str()); + assert_eq!(row["d"], "listing-a"); + assert_eq!(row["title"], "Carrot bunches"); + assert_eq!(row["content"], "Sweet storage carrots."); + assert_eq!(row["price_decimal"], "12.50"); + assert_eq!(row["price_minor"], 1_250_u64); + assert_eq!(row["currency_norm"], "USD"); + assert_eq!(row["unit"], "lb"); + assert_eq!(row["effective_status"], "active"); + assert_eq!(row["hidden"], false); + drop(store); + fs::remove_dir_all(root).expect("remove runtime root"); +} diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -14,7 +14,10 @@ use tangle_nips::{ parse_forum_thread_event, parse_label_event, parse_long_form_event, parse_reaction_event, parse_report_event, parse_seller_profile_event, }; -use tangle_protocol::{AddressCoordinate, Event, EventId, Filter, UnixTimestamp, event_to_value}; +use tangle_protocol::{ + AddressCoordinate, Event, EventId, Filter, RawEventJson, UnixTimestamp, event_to_value, + parse_event_json, +}; use tangle_store::{StoreEventOutcome, StoredEvent}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -2037,9 +2040,6 @@ UPSERT type::record('event_current', $address_key) CONTENT { statement.push_str(" AND created_at <= $until"); } statement.push_str(" ORDER BY created_at DESC, event_id ASC"); - if filter.limit().is_some() { - statement.push_str(" LIMIT $limit"); - } statement.push(';'); let mut query = self.db.query(statement); if !filter.ids().is_empty() { @@ -2078,15 +2078,35 @@ UPSERT type::record('event_current', $address_key) CONTENT { if let Some(until) = filter.until() { query = query.bind(("until", until.as_u64())); } - if let Some(limit) = filter.limit() { - query = query.bind(("limit", limit)); - } let mut response = query .await .map_err(SurrealStoreError::from)? .check() .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) + let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?; + let mut events = Vec::new(); + for row in rows { + let event_id = EventId::new(&string_row_field(&row, "event_id")?) + .map_err(|source| SurrealStoreError::new(&source))?; + let Some(raw_row) = self.raw_event_row(&event_id).await? else { + continue; + }; + let raw_json = string_row_field(&raw_row, "raw_json")?; + let raw = RawEventJson::new(&raw_json) + .map_err(|source| SurrealStoreError::new(&source.to_string()))?; + let event = parse_event_json(&raw) + .map_err(|source| SurrealStoreError::new(&source.to_string()))?; + if filter.matches(&event) { + events.push(raw_row); + } + if filter + .limit() + .is_some_and(|limit| events.len() >= limit as usize) + { + break; + } + } + Ok(events) } pub async fn apply_deletion_markers( @@ -6768,6 +6788,10 @@ mod tests { "listing", ); for event in [&older, &newer, &other, &addressable] { + store + .store_raw_event(&StoredEvent::new(event.clone(), UnixTimestamp::new(200))) + .await + .expect("raw event"); store.maintain_current_event(event).await.expect("current"); } @@ -6797,6 +6821,26 @@ mod tests { assert_eq!(rows.len(), 2); assert_eq!(rows[0]["event_id"], other.id().as_str()); assert_eq!(rows[1]["event_id"], newer.id().as_str()); + assert!( + rows[0]["raw_json"] + .as_str() + .expect("raw json") + .contains("other") + ); + + let address_filter = filter_from_value(&serde_json::json!({ + "kinds": [30402], + "authors": [pubkey_a], + "#d": ["listing-current"], + "limit": 1 + })) + .expect("address filter"); + let rows = store + .query_current_events(&address_filter) + .await + .expect("address rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["event_id"], addressable.id().as_str()); store .database()