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:
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¤cy=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()