commit 67c958be4a98c0b200f7ae3a6d9eea65d3cc2292
parent a98d7814e294d2cf5a6e57c5912ae57ec6b4f785
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 03:57:34 -0700
http: add forum endpoints
Diffstat:
2 files changed, 645 insertions(+), 8 deletions(-)
diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs
@@ -8,7 +8,7 @@ use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
-use tangle_protocol::event_to_value;
+use tangle_protocol::{EventId, event_to_value};
use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore};
use tangle_test_support::{
FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
@@ -33,7 +33,11 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
port,
"tangle_it",
serde_json::json!({
- "approved_sellers": [FixtureKey::Seller.public_key().as_str()]
+ "approved_sellers": [FixtureKey::Seller.public_key().as_str()],
+ "write_rate_limit": {
+ "limit": 10,
+ "window_seconds": 60
+ }
}),
);
@@ -54,6 +58,8 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
let comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?");
let reaction = listing_reaction(&listing, 1_714_124_437, "+");
+ let thread = forum_thread(1_714_124_438, Some("Market day thread"), &["market", "csa"]);
+ let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens.");
let auth = build_fixture_event(&auth_event_spec()).expect("auth");
let seller = FixtureKey::Seller.public_key();
@@ -187,6 +193,55 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert_eq!(fetched_reaction[2]["id"], reaction.id().as_str());
assert_eq!(next_label(&mut publisher).await, "EOSE");
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["EVENT", event_to_value(&thread)])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("thread send");
+ assert_ok(&next_json(&mut publisher).await, true);
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["REQ", "sub-thread", { "ids": [thread.id().as_str()] }])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("thread fetch send");
+ let fetched_thread = next_json(&mut publisher).await;
+ assert_eq!(fetched_thread[0], "EVENT");
+ assert_eq!(fetched_thread[1], "sub-thread");
+ assert_eq!(fetched_thread[2]["id"], thread.id().as_str());
+ assert_eq!(next_label(&mut publisher).await, "EOSE");
+
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["EVENT", event_to_value(&thread_comment)])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("thread comment send");
+ assert_ok(&next_json(&mut publisher).await, true);
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["REQ", "sub-thread-comment", { "ids": [thread_comment.id().as_str()] }])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("thread comment fetch send");
+ let fetched_thread_comment = next_json(&mut publisher).await;
+ assert_eq!(fetched_thread_comment[0], "EVENT");
+ assert_eq!(fetched_thread_comment[1], "sub-thread-comment");
+ assert_eq!(
+ fetched_thread_comment[2]["id"],
+ thread_comment.id().as_str()
+ );
+ assert_eq!(next_label(&mut publisher).await, "EOSE");
+
subscriber
.send(Message::Text(
serde_json::json!(["CLOSE", "sub-live"]).to_string().into(),
@@ -222,6 +277,27 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert!(reactions.contains("200 OK"));
assert!(reactions.contains("\"like_count\":1"));
assert!(reactions.contains("\"total_count\":1"));
+ let forum_threads = http_get(port, "/api/forum/threads?topic=market&limit=5");
+ assert!(forum_threads.contains("200 OK"));
+ assert!(forum_threads.contains(thread.id().as_str()));
+ assert!(forum_threads.contains("Market day thread"));
+ let forum_detail = http_get(
+ port,
+ &format!("/api/forum/threads/{}", thread.id().as_str()),
+ );
+ assert!(forum_detail.contains("200 OK"));
+ assert!(forum_detail.contains(thread.id().as_str()));
+ assert!(forum_detail.contains("Market day thread"));
+ let forum_comments = http_get(
+ port,
+ &format!(
+ "/api/forum/threads/{}/comments?limit=5",
+ thread.id().as_str()
+ ),
+ );
+ assert!(forum_comments.contains("200 OK"));
+ assert!(forum_comments.contains(thread_comment.id().as_str()));
+ assert!(forum_comments.contains("I can bring greens."));
let search = http_get(port, "/api/search?q=carrots&limit=5");
assert!(search.contains("200 OK"));
assert!(search.contains(listing.id().as_str()));
@@ -273,6 +349,27 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert_eq!(reaction_count["target_event_id"], listing.id().as_str());
assert_eq!(reaction_count["like_count"], 1_i64);
assert_eq!(reaction_count["total_count"], 1_i64);
+ let thread_row = store
+ .forum_thread_row(thread.id())
+ .await
+ .expect("thread row")
+ .expect("thread row exists");
+ assert_eq!(thread_row["event_id"], thread.id().as_str());
+ assert_eq!(thread_row["title"], "Market day thread");
+ let thread_comment_row = store
+ .comment_projection_row(thread_comment.id())
+ .await
+ .expect("thread comment row")
+ .expect("thread comment row exists");
+ assert_eq!(thread_comment_row["root_ref"], thread.id().as_str());
+ assert_eq!(thread_comment_row["content"], "I can bring greens.");
+ assert!(
+ store
+ .search_document_row(thread.id().as_str())
+ .await
+ .expect("thread search row")
+ .is_some()
+ );
assert!(
store
.search_document_row(&listing_key)
@@ -830,7 +927,7 @@ async fn next_label(
fn assert_ok(message: &Value, accepted: bool) {
assert_eq!(message[0], "OK");
- assert_eq!(message[2], accepted);
+ assert_eq!(message[2], accepted, "relay OK frame: {message}");
}
fn listing_comment(
@@ -891,6 +988,70 @@ fn listing_reaction(
.expect("reaction event")
}
+fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> tangle_protocol::Event {
+ let mut tags = vec![
+ vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
+ vec![
+ "p".to_owned(),
+ FixtureKey::Buyer.public_key().as_str().to_owned(),
+ ],
+ ];
+ if let Some(title) = title {
+ tags.push(vec!["title".to_owned(), title.to_owned()]);
+ }
+ tags.extend(
+ topics
+ .iter()
+ .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
+ );
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ created_at,
+ 11,
+ tags,
+ "What is everyone bringing this weekend?",
+ )
+ .expect("forum thread")
+}
+
+fn forum_thread_comment(
+ thread: &tangle_protocol::Event,
+ created_at: u64,
+ content: &str,
+) -> tangle_protocol::Event {
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ created_at,
+ 1_111,
+ vec![
+ vec![
+ "E".to_owned(),
+ thread.id().as_str().to_owned(),
+ "wss://relay.radroots.test".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["K".to_owned(), "11".to_owned()],
+ vec![
+ "P".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec![
+ "e".to_owned(),
+ thread.id().as_str().to_owned(),
+ "wss://relay.radroots.test".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["k".to_owned(), "11".to_owned()],
+ vec![
+ "p".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ ],
+ content,
+ )
+ .expect("forum comment event")
+}
+
fn stop_relay(mut relay: Child) {
stop_child(&mut relay);
let status = relay.wait().expect("relay exit");
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -36,8 +36,10 @@ use tangle_protocol::{
use tangle_store::{StoreEventOutcome, StoredEvent};
use tangle_store_surreal::{
CommentProjectionOutcome, CommentProjectionQuery, DurableRateLimitDecision,
- ListingProjectionQuery, MigrationApplyOutcome, ReactionProjectionOutcome, SearchDocumentQuery,
- SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan,
+ ForumThreadProjectionOutcome, ForumThreadProjectionQuery, ListingProjectionQuery,
+ LongFormProjectionOutcome, MigrationApplyOutcome, ReactionProjectionOutcome,
+ SearchDocumentQuery, SurrealConnectionConfig, SurrealConnectionMode, SurrealStore,
+ base_migration_plan,
};
use tokio::net::TcpListener;
use tokio::sync::broadcast;
@@ -602,6 +604,18 @@ async fn project_stored_event(
Ok(ReactionProjectionOutcome::NotReaction | ReactionProjectionOutcome::Ineligible) => false,
Err(_) => return Err(RuntimeCommandError::store("event projection failed")),
};
+ let long_form_projected = match store.project_long_form(event, now).await {
+ Ok(LongFormProjectionOutcome::Projected) => true,
+ Ok(LongFormProjectionOutcome::NotLongForm | LongFormProjectionOutcome::Ineligible) => false,
+ Err(_) => return Err(RuntimeCommandError::store("event projection failed")),
+ };
+ let forum_thread_projected = match store.project_forum_thread(event, now).await {
+ Ok(ForumThreadProjectionOutcome::Projected) => true,
+ Ok(
+ ForumThreadProjectionOutcome::NotForumThread | ForumThreadProjectionOutcome::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()
@@ -611,7 +625,7 @@ async fn project_stored_event(
}
return Ok(true);
}
- Ok(comment_projected || reaction_projected)
+ Ok(comment_projected || reaction_projected || long_form_projected || forum_thread_projected)
}
fn parse_event_import_document(raw: &str) -> Result<Vec<Event>, RuntimeCommandError> {
@@ -972,6 +986,15 @@ fn runtime_router(
"/api/listings/{pubkey}/{d}/reactions",
get(runtime_listing_reactions),
)
+ .route("/api/forum/threads", get(runtime_forum_threads))
+ .route(
+ "/api/forum/threads/{event_id}",
+ get(runtime_forum_thread_detail),
+ )
+ .route(
+ "/api/forum/threads/{event_id}/comments",
+ get(runtime_forum_thread_comments),
+ )
.route("/api/search", get(runtime_marketplace_search))
.route("/api/sellers/{pubkey}", get(runtime_seller_detail))
.route(
@@ -1078,6 +1101,50 @@ async fn runtime_listing_reactions(
.await
}
+async fn runtime_forum_threads(
+ State(state): State<RuntimeRelayState>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ForumThreadsDocument>, ApiError> {
+ forum_threads(
+ State(ListingsHttpState::new(
+ state.store.clone(),
+ state.config.limits(),
+ )),
+ RawQuery(query),
+ )
+ .await
+}
+
+async fn runtime_forum_thread_detail(
+ State(state): State<RuntimeRelayState>,
+ Path(event_id): Path<String>,
+) -> Result<Json<ForumThreadDetailDocument>, ApiError> {
+ forum_thread_detail(
+ State(ListingsHttpState::new(
+ state.store.clone(),
+ state.config.limits(),
+ )),
+ Path(event_id),
+ )
+ .await
+}
+
+async fn runtime_forum_thread_comments(
+ State(state): State<RuntimeRelayState>,
+ Path(event_id): Path<String>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ListingCommentsDocument>, ApiError> {
+ forum_thread_comments(
+ State(ListingsHttpState::new(
+ state.store.clone(),
+ state.config.limits(),
+ )),
+ Path(event_id),
+ RawQuery(query),
+ )
+ .await
+}
+
async fn runtime_marketplace_search(
State(state): State<RuntimeRelayState>,
RawQuery(query): RawQuery,
@@ -1911,6 +1978,8 @@ impl EventMessageHandler {
.is_err()
|| self.store.project_comment(&event, now).await.is_err()
|| self.store.project_reaction(&event, now).await.is_err()
+ || self.store.project_long_form(&event, now).await.is_err()
+ || self.store.project_forum_thread(&event, now).await.is_err()
{
return ok_rejected(event_id, "error: projection failed".to_owned());
}
@@ -2604,6 +2673,29 @@ pub struct ReactionCountsDocument {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ForumThreadsDocument {
+ pub items: Vec<ForumThreadItemDocument>,
+ pub next_cursor: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ForumThreadItemDocument {
+ pub event_id: String,
+ pub pubkey: String,
+ pub created_at: u64,
+ pub updated_at: u64,
+ pub title: Option<String>,
+ pub content: String,
+ pub tags: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ForumThreadDetailDocument {
+ pub thread: ForumThreadItemDocument,
+ pub raw_event: serde_json::Value,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SellerDocument {
pub pubkey: String,
pub approved: bool,
@@ -2811,6 +2903,12 @@ pub fn listings_router(state: ListingsHttpState) -> Router {
"/api/listings/{pubkey}/{d}/reactions",
get(listing_reactions),
)
+ .route("/api/forum/threads", get(forum_threads))
+ .route("/api/forum/threads/{event_id}", get(forum_thread_detail))
+ .route(
+ "/api/forum/threads/{event_id}/comments",
+ get(forum_thread_comments),
+ )
.route("/api/search", get(marketplace_search))
.route("/api/sellers/{pubkey}", get(seller_detail))
.with_state(state)
@@ -2982,6 +3080,94 @@ async fn listing_reactions(
)?))
}
+async fn forum_threads(
+ State(state): State<ListingsHttpState>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ForumThreadsDocument>, ApiError> {
+ let query = forum_thread_query(query.as_deref().unwrap_or_default())?;
+ let rows = state
+ .store
+ .query_forum_threads(&query)
+ .await
+ .map_err(|_| ApiError::internal())?;
+ let items = rows
+ .iter()
+ .map(forum_thread_item_document)
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Json(ForumThreadsDocument {
+ items,
+ next_cursor: None,
+ }))
+}
+
+async fn forum_thread_detail(
+ State(state): State<ListingsHttpState>,
+ Path(event_id): Path<String>,
+) -> Result<Json<ForumThreadDetailDocument>, ApiError> {
+ let event_id =
+ EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?;
+ let row = state
+ .store
+ .forum_thread_row(&event_id)
+ .await
+ .map_err(|_| ApiError::internal())?
+ .ok_or_else(|| ApiError::not_found("forum thread not found"))?;
+ if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
+ return Err(ApiError::not_found("forum thread not found"));
+ }
+ let raw_row = state
+ .store
+ .raw_event_row(&event_id)
+ .await
+ .map_err(|_| ApiError::internal())?
+ .ok_or_else(ApiError::internal)?;
+ if bool_field(&raw_row, "hidden")? || bool_field(&raw_row, "deleted")? {
+ return Err(ApiError::not_found("forum thread not found"));
+ }
+ let raw_event = serde_json::from_str(&string_field(&raw_row, "raw_json")?)
+ .map_err(|_| ApiError::internal())?;
+ Ok(Json(ForumThreadDetailDocument {
+ thread: forum_thread_item_document(&row)?,
+ raw_event,
+ }))
+}
+
+async fn forum_thread_comments(
+ State(state): State<ListingsHttpState>,
+ Path(event_id): Path<String>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ListingCommentsDocument>, ApiError> {
+ let event_id =
+ EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?;
+ let limit = parse_comment_query(query.as_deref().unwrap_or_default())?;
+ let row = state
+ .store
+ .forum_thread_row(&event_id)
+ .await
+ .map_err(|_| ApiError::internal())?
+ .ok_or_else(|| ApiError::not_found("forum thread not found"))?;
+ if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? {
+ return Err(ApiError::not_found("forum thread not found"));
+ }
+ let rows = state
+ .store
+ .query_comment_projections(
+ &CommentProjectionQuery::new()
+ .with_root("event", event_id.as_str())
+ .with_limit(limit),
+ )
+ .await
+ .map_err(|_| ApiError::internal())?;
+ let items = rows
+ .iter()
+ .map(comment_item_document)
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Json(ListingCommentsDocument {
+ items,
+ next_cursor: None,
+ }))
+}
+
async fn marketplace_search(
State(state): State<ListingsHttpState>,
RawQuery(query): RawQuery,
@@ -3198,6 +3384,40 @@ fn search_document_query(parsed: &MarketplaceSearchHttpQuery) -> SearchDocumentQ
query
}
+fn forum_thread_query(raw: &str) -> Result<ForumThreadProjectionQuery, ApiError> {
+ let mut pubkey = None;
+ let mut topic = None;
+ let mut limit = None;
+ for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
+ let value = value.into_owned();
+ match key.as_ref() {
+ "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?,
+ "topic" => set_once("topic", &mut topic, required_value("topic", &value)?)?,
+ "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?,
+ "cursor" => {
+ return Err(invalid_parameter(
+ "cursor",
+ "signed cursor decoding is not implemented",
+ ));
+ }
+ "" => {}
+ unsupported => {
+ return Err(ApiError::invalid_request(format!(
+ "query parameter `{unsupported}` is unsupported"
+ )));
+ }
+ }
+ }
+ let mut query = ForumThreadProjectionQuery::new().with_limit(limit.unwrap_or(50));
+ if let Some(pubkey) = pubkey {
+ query = query.with_pubkey(pubkey.as_str());
+ }
+ if let Some(topic) = topic {
+ query = query.with_topic(&topic);
+ }
+ Ok(query)
+}
+
fn parse_comment_query(raw: &str) -> Result<u64, ApiError> {
let mut limit = None;
for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
@@ -3238,6 +3458,20 @@ fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument,
})
}
+fn forum_thread_item_document(
+ row: &serde_json::Value,
+) -> Result<ForumThreadItemDocument, ApiError> {
+ Ok(ForumThreadItemDocument {
+ event_id: string_field(row, "event_id")?,
+ pubkey: string_field(row, "pubkey")?,
+ created_at: u64_field(row, "created_at")?,
+ updated_at: u64_field(row, "updated_at")?,
+ title: optional_string_field(row, "title")?,
+ content: string_field(row, "content")?,
+ tags: string_array_field(row, "tags")?,
+ })
+}
+
fn comment_item_document(row: &serde_json::Value) -> Result<CommentItemDocument, ApiError> {
Ok(CommentItemDocument {
event_id: string_field(row, "event_id")?,
@@ -3354,6 +3588,23 @@ fn optional_string_field(
}
}
+fn string_array_field(
+ row: &serde_json::Value,
+ field: &'static str,
+) -> Result<Vec<String>, ApiError> {
+ row.get(field)
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(ApiError::internal)?
+ .iter()
+ .map(|value| {
+ value
+ .as_str()
+ .map(str::to_owned)
+ .ok_or_else(ApiError::internal)
+ })
+ .collect()
+}
+
fn u64_field(row: &serde_json::Value, field: &'static str) -> Result<u64, ApiError> {
row.get(field)
.and_then(serde_json::Value::as_u64)
@@ -3611,8 +3862,8 @@ mod tests {
};
use tangle_nips::{FulfillmentMethod, ListingUnit, parse_relay_auth_event};
use tangle_protocol::{
- ClientMessage, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value,
- filter_from_value,
+ ClientMessage, EventId, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp,
+ event_to_value, filter_from_value,
};
use tangle_store::StoredEvent;
use tangle_store_surreal::{
@@ -5707,6 +5958,163 @@ mod tests {
}
#[tokio::test]
+ async fn forum_threads_endpoint_returns_visible_projected_threads() {
+ let store = runtime_memory_store().await;
+ let thread = forum_thread(1_714_125_430, Some("Market day thread"), &["Market", "CSA"]);
+ store
+ .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_431))
+ .await
+ .expect("project thread");
+
+ let response = listings_router(ListingsHttpState::new(
+ store.clone(),
+ RuntimeLimits::default(),
+ ))
+ .oneshot(
+ Request::builder()
+ .uri("/api/forum/threads?topic=market&limit=5")
+ .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"),
+ serde_json::json!({
+ "items": [{
+ "event_id": thread.id().as_str(),
+ "pubkey": FixtureKey::Buyer.public_key().as_str(),
+ "created_at": 1714125430_u64,
+ "updated_at": 1714125430_u64,
+ "title": "Market day thread",
+ "content": "What is everyone bringing this weekend?",
+ "tags": ["csa", "market"]
+ }],
+ "next_cursor": null
+ })
+ );
+
+ store
+ .database()
+ .query("UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;")
+ .bind(("event_id", thread.id().as_str()))
+ .await
+ .expect("hide thread")
+ .check()
+ .expect("hide thread check");
+ let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
+ .oneshot(
+ Request::builder()
+ .uri("/api/forum/threads?topic=market&limit=5")
+ .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"),
+ serde_json::json!({
+ "items": [],
+ "next_cursor": null
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn forum_thread_detail_and_comments_endpoints_return_visible_rows() {
+ let store = runtime_memory_store().await;
+ let thread = forum_thread(1_714_125_440, Some("Market day thread"), &["market"]);
+ let comment = forum_thread_comment(&thread, 1_714_125_441, "I can bring greens.");
+ store
+ .store_raw_event(&StoredEvent::new(
+ thread.clone(),
+ UnixTimestamp::new(1_714_125_442),
+ ))
+ .await
+ .expect("raw thread");
+ store
+ .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_443))
+ .await
+ .expect("project thread");
+ store
+ .project_comment(&comment, UnixTimestamp::new(1_714_125_444))
+ .await
+ .expect("project comment");
+
+ let detail_uri = format!("/api/forum/threads/{}", thread.id().as_str());
+ let response = listings_router(ListingsHttpState::new(
+ store.clone(),
+ RuntimeLimits::default(),
+ ))
+ .oneshot(
+ Request::builder()
+ .uri(detail_uri.as_str())
+ .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");
+ let detail = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
+ assert_eq!(detail["thread"]["event_id"], thread.id().as_str());
+ assert_eq!(detail["thread"]["title"], "Market day thread");
+ assert_eq!(detail["raw_event"]["id"], thread.id().as_str());
+
+ let comments_uri = format!(
+ "/api/forum/threads/{}/comments?limit=5",
+ thread.id().as_str()
+ );
+ let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
+ .oneshot(
+ Request::builder()
+ .uri(comments_uri.as_str())
+ .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"),
+ serde_json::json!({
+ "items": [{
+ "event_id": comment.id().as_str(),
+ "pubkey": FixtureKey::Seller.public_key().as_str(),
+ "created_at": 1714125441_u64,
+ "content": "I can bring greens.",
+ "root": {
+ "target_type": "event",
+ "target_ref": thread.id().as_str(),
+ "kind": "11",
+ "author": thread.unsigned().pubkey().as_str()
+ },
+ "parent": {
+ "target_type": "event",
+ "target_ref": thread.id().as_str(),
+ "kind": "11",
+ "author": thread.unsigned().pubkey().as_str()
+ }
+ }],
+ "next_cursor": null
+ })
+ );
+ }
+
+ #[tokio::test]
async fn listing_detail_endpoint_rejects_invalid_or_missing_listing() {
let store = runtime_memory_store().await;
let response = listings_router(ListingsHttpState::new(
@@ -5997,6 +6405,74 @@ mod tests {
.expect("reaction event")
}
+ fn forum_thread(
+ created_at: u64,
+ title: Option<&str>,
+ topics: &[&str],
+ ) -> tangle_protocol::Event {
+ let mut tags = vec![
+ vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)],
+ vec![
+ "p".to_owned(),
+ FixtureKey::Seller.public_key().as_str().to_owned(),
+ ],
+ ];
+ if let Some(title) = title {
+ tags.push(vec!["title".to_owned(), title.to_owned()]);
+ }
+ tags.extend(
+ topics
+ .iter()
+ .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]),
+ );
+ build_fixture_event_from_parts(
+ FixtureKey::Buyer,
+ created_at,
+ 11,
+ tags,
+ "What is everyone bringing this weekend?",
+ )
+ .expect("forum thread")
+ }
+
+ fn forum_thread_comment(
+ thread: &tangle_protocol::Event,
+ created_at: u64,
+ content: &str,
+ ) -> tangle_protocol::Event {
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ created_at,
+ 1_111,
+ vec![
+ vec![
+ "E".to_owned(),
+ thread.id().as_str().to_owned(),
+ "wss://relay.radroots.test".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["K".to_owned(), "11".to_owned()],
+ vec![
+ "P".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec![
+ "e".to_owned(),
+ thread.id().as_str().to_owned(),
+ "wss://relay.radroots.test".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["k".to_owned(), "11".to_owned()],
+ vec![
+ "p".to_owned(),
+ thread.unsigned().pubkey().as_str().to_owned(),
+ ],
+ ],
+ content,
+ )
+ .expect("forum comment event")
+ }
+
fn runtime_client_message_loop() -> ClientMessageLoop {
let mut connection = runtime_connection("client-loop");
connection.set_remote_addr("127.0.0.1:7777");