Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,10 @@ async fn handle_join_request(
.await?
{
info!(channel = %channel_id, "kind:9021 join — already a member, skipping");
// Re-emit discovery events so the client can reconcile its membership state.
if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await {
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed on redundant join");
}
return Ok(());
}

Expand Down
95 changes: 95 additions & 0 deletions crates/buzz-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2476,6 +2476,101 @@ async fn test_reply_ingest_pushes_live_thread_summary() {
client.disconnect().await.expect("disconnect");
}

#[tokio::test]
#[ignore]
async fn test_redundant_join_re_emits_group_discovery_events() {
let url = relay_url();
let owner = Keys::generate();
let joiner = Keys::generate();
seed_relay_owner(&owner).await;
let channel_id = create_test_channel(&owner).await;

let mut client = BuzzTestClient::connect(&url, &joiner)
.await
.expect("connect and authenticate");

// Subscribe to kind:39002 (group members) for this channel.
let members_sid = sub_id("members-redundant");
let members_filter = Filter::new()
.kind(Kind::Custom(39002))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), channel_id.as_str());
client
.subscribe(&members_sid, vec![members_filter])
.await
.expect("subscribe to members");
let historical = client
.collect_until_eose(&members_sid, Duration::from_secs(5))
.await
.expect("collect historical members");
let first_members_id = historical
.iter()
.find(|e| {
e.tags
.iter()
.any(|t| t.as_slice().first().map(String::as_str) == Some("d")
&& t.as_slice().get(1).map(String::as_str) == Some(channel_id.as_str()))
})
.map(|e| e.id.to_hex());

// First join — adds the joiner as a member.
let join_event = EventBuilder::new(Kind::Custom(9021), "")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&joiner)
.expect("sign join");
let ok = client
.send_event(join_event)
.await
.expect("send first join");
assert!(ok.accepted, "first join rejected: {}", ok.message);

// Wait for the 39002 emitted after the first join.
let first_live = client
.recv_event(Duration::from_secs(5))
.await
.expect("receive 39002 after first join");
let first_live_id = match first_live {
RelayMessage::Event { event, .. } => {
assert_eq!(event.kind, Kind::Custom(39002));
event.id.to_hex()
}
other => panic!("expected 39002 EVENT after first join, got: {other:?}"),
};
assert_ne!(
Some(first_live_id.as_str()),
first_members_id.as_deref(),
"39002 after first join should be a fresh event"
);

// Redundant join — already a member, should still re-emit 39002.
let redundant_join = EventBuilder::new(Kind::Custom(9021), "")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&joiner)
.expect("sign redundant join");
let ok = client
.send_event(redundant_join)
.await
.expect("send redundant join");
assert!(ok.accepted, "redundant join rejected: {}", ok.message);

let second_live = client
.recv_event(Duration::from_secs(5))
.await
.expect("receive 39002 after redundant join");
let second_live_id = match second_live {
RelayMessage::Event { event, .. } => {
assert_eq!(event.kind, Kind::Custom(39002));
event.id.to_hex()
}
other => panic!("expected 39002 EVENT after redundant join, got: {other:?}"),
};
assert_ne!(
second_live_id, first_live_id,
"redundant join should emit a fresh 39002 event, not reuse the first"
);

client.disconnect().await.expect("disconnect");
}

/// Read a member's authoritative role from the relay-signed kind:39002 member
/// list. The relay's own view of membership, not the client's — a kind:9000 can
/// be `accepted` (stored) while its membership side effect fails, so asserting
Expand Down