test(relay,db): remove three timing races from Postgres and E2E tests (#9098)

## Summary

Three tests asserted on outcomes that depended on how fast something
unordered happened. The first depended on dial latency inside a 150 ms
acquire budget, the second on whether two aux-page replies landed in one
poll, and the third on whether NIP-42 admission auto-joined the joiner
before the invite claim did. Each test now sets up the state it asserts
before the racing actor can run: it fills the reader warm floor first,
pauses the writer's proxy after the first aux page, or claims the invite
before the joiner connects. Only tests change. No assertions are
loosened, and no sleeps, retries or timeouts are added.

### test(db): fill the reader warm floor outside the acquire budget
first

#### Problem

`reader_warm_at_production_budget_ends_partial_when_dials_stall` failed
on main at reader_pool_postgres_tests.rs:177, "one partial wave, then
one wave that opens nothing": left (15, 0, 5), right (15, 3, 7).
Postgres lanes run with no retries.
- main (d3a4909cc):
-
https://github.com/block/buzz/actions/runs/41693681008/job/126127000284

The test is unchanged on current main.

#### Cause

The test builds the reader pool with the production constructor, so
every warm acquire, dial included, gets the 150 ms
`READER_ACQUIRE_TIMEOUT`. It relayed the proxy's first 3 sessions and
stalled the rest, so it needed the first wave's 3 relayed dials (TCP
through the proxy, backend fork, SCRAM, `after_connect`) to finish
inside that budget. On a loaded runner (4 vCPUs, nextest test-threads=8)
none did. All 5 first-wave acquires missed, and the pass ended after one
empty wave as the product should, which gives (15, 0, 5). The warm pass
was correct, but the test's outcome depended on dial latency.

The session counts had a second race. The proxy accepts connections on
the test's current-thread runtime. If that thread is descheduled past
the budget, the timed-out acquires and the listener's readiness are
handled in the same tick. So `proxy.sessions() == 10`, checked right
after the pass, could miss a stalled dial still waiting to be accepted.

#### Fix

Fill the warm floor before stalling anything, as boot does.
`Db::read_pool_boot_ping_once` closes its probe connection, and sqlx's
floor maintenance then dials the 3 floor connections under its own 300 s
deadline, outside the acquire budget. The test waits for them with
`await_idle_floor`, then stalls every session from the recorded baseline
(`proxy.stall_from(proxy.sessions())`) instead of a hardcoded 3. Wave 1
now holds the 3 idle connections without dialing, and its 2 dials stall.
Wave 2's 5 dials stall, so it makes no progress and the pass ends. That
gives (15, 3, 7) at any dial latency. The session count is now relative
to the baseline and asserted once, after the existing settle. That
single check covers both "the pass stops dialing" and "nothing
re-dials". The report assertion, the `elapsed < 2s` bound and the
production pool are unchanged. Only the test changes.

The setup still needs one of the boot ping's five attempts (spaced over
about 4 s by `READER_BOOT_PROBE_BACKOFF`) to dial within 150 ms, the
same exposure as `reader_boot_ping_warms_pool_to_min_connections`.
Before, all 3 concurrent first-wave dials had to make it on their first
try.

#### Validation

Local Linux workstation, on a private postgres:17-alpine. The test
processes and Postgres shared 4 pinned CPUs, with 8 parallel workers.
"Before" is the test as on main.
- CPU stress, no busy loops: before, 2/40 failed with (15, 0, 5) at
:177, the CI signature. After, 120/120 passed.
- Plus 4 busy loops on those CPUs: before, 80/80 failed with (15, 0, 5).
After, 80/80 passed. In 80 more instrumented runs (all passed), the boot
ping retried 16 times: 15 runs needed 2 attempts and 1 needed 3.
- Adversarial latency, using a temporary, uncommitted proxy hook that
delays chosen sessions' first server reply by 200 ms (past the budget),
10 runs each:
- every relayed session slow: before, 10/10 (15, 0, 5). After, 10/10
failed at "boot ping must reach the reader", the bound above.
- the first 4 sessions slow: before, 10/10 (15, 0, 5). After, 10/10
passed, with the ping succeeding on its 5th attempt.
- every session but the first slow: before, 10/10 (15, 1, 9). After,
10/10 passed.
- Production mutations, each of which fails the test: dropping the
no-progress break gives (15, 3, 27). A 2 s acquire budget fails the
elapsed bound at 4.0 s. Closing the warmed connections instead of
releasing them leaves the floor at 0 idle.
- All 7 `reader_pool_postgres_tests` pass. `cargo fmt --check`, `cargo
clippy -p buzz-db --all-targets -- -D warnings` and `git diff --check`
are clean.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** Linux
workstation, private `postgres:16` container with the desired-state
schema applied as in the PostgreSQL lane (pgschema plus the reconcile
script).

- `cargo test -p buzz-db`: 191 lib, 21 `observability_source` and 5 doc
tests passed.
- `cargo test -p buzz-db --lib reader_pool_postgres_tests -- --ignored
--test-threads=1`: 7/7 passed.
`reader_warm_at_production_budget_ends_partial_when_dials_stall` alone:
20/20 passed.
- `cargo clippy -p buzz-db -p buzz-relay -p buzz-test-client
--all-targets -- -D warnings`, `cargo fmt -p buzz-db -p buzz-relay -p
buzz-test-client -- --check` and `git diff --check` are clean.

### test(relay): hold the writer's second aux page too

#### Problem

`thread_window_batch_discards_earlier_windows_after_revocation` failed
on main at failure_postgres_tests.rs:55, "barrier must precede closure
completion": left 2, right 1. Postgres lanes run with no retries.
- main (d3a4909cc):
-
https://github.com/block/buzz/actions/runs/41693681008/job/126127000284

The test and the code it covers are unchanged on current main.

#### Cause

This is the race #8740 (322fdbf08) fixed for the replica test only.
`pause_after_aux_page` polls the request until a `thread_window_aux`
span closes, then expects exactly one closed. `query` runs the closure's
two aux hops (reactions and edits, then their deletions) back to back,
one span each. On the writer, each hop is a pool acquire with its idle
ping, BEGIN, two SET LOCALs, the SELECT and ROLLBACK. #8740 left the
writer callers on `None`, so only the request returning Pending on a
reply that had not arrived yet held hop 2. sqlx tries each read before
it waits for readiness, and tokio leaves the socket marked readable
after the previous reply, so a reply Postgres has already sent is
consumed in the same poll. If each of hop 2's replies lands before the
test thread reads it, for example when a loaded runner deschedules that
thread after its writes, hop 2 closes in the poll that closed hop 1 and
the barrier counts 2. `assert_aux_access_change`, behind the grant and
revocation tests, uses the same `None` path.

#### Fix

`proxied_state` gives the paused request a copy of the fixture's state
whose production writer connects through a `PausingProxy`.
`production_db_at` is `production_db` with a URL override. `AuxPages`
pauses the proxy when hop 1's span closes. That runs inside the
request's poll, before hop 2's first bytes are written, so hop 2 stays
pending however fast Postgres answers. Each test resumes the proxy after
its access change, just before `request.await`. Only the request uses
the proxied state: `assert_aux_access_change` reads through the
fixture's direct `state.db` while the request is held, and through the
proxy that read would deadlock. No caller passes `None` any more, so the
`Option` goes. Only tests change. No assertions are loosened, and no
sleeps, retries or timeouts are added.

Draft #8991 (BUZZ-512) also edits `AuxPages` and `pause_after_aux_page`,
adding a waker for its detached HTTP handler. It also moves these two
callers onto a proxy, through its own `proxy_request_pool`, which
replaces `f.state.db` with a raw pool. Rebasing it onto this keeps its
waker on the non-`Option` proxy field and can use `proxied_state` for
those two callers.

#### Validation

Local Linux workstation, Postgres 17 in docker, one test per process as
nextest runs them. "Before" is the test as on main.
- Adversarial ordering, using a temporary, uncommitted change as in
#8740: after the barrier, ten more polls of the request over 500 ms,
then the barrier's count assertion. Before, the batch, grant and
revocation tests failed 30/30 at that assertion with left 2, right 1,
the CI signature, and the replica test passed 10/10. After, 40/40
passed.
- Natural contention: the three tests and a private postgres:17-alpine
shared 2 pinned CPUs with 4 busy loops, 3 processes at a time. Before,
450/450 passed, so the same-poll burst did not occur locally. After,
450/450 passed.
- Production mutation: skipping `query_batch`'s final implicit
access-set check fails all three tests with 200 instead of 503.
- All 19 `api::bridge::thread_window` Postgres tests pass, run serially
against one database. `cargo fmt --check`, `cargo clippy -p buzz-relay
--all-targets -- -D warnings` (1.98.1), `git diff --check` and
`check-postgres-test-discovery.py` are clean.

Running several of these tests in one libtest process can fail them at
:41 with no aux span seen, on main too. tracing-core builds a new
callsite's interest from the registering thread's default while only one
scoped dispatcher exists. nextest runs one test per process, so CI is
not exposed. The runs above use one process per test.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** same database
setup as above.

- Each of the 19 `api::bridge::thread_window` Postgres tests ran in its
own process and its own database cloned from the desired-state template,
as nextest runs them: 19/19 passed.
- The batch, grant, revocation and replica tests, 5 more runs each in
the same way: 20/20 passed.

### test(relay): claim the invite before the joiner connects

#### Problem

`test_invite_claim_auto_joins_default_channel`
(crates/buzz-test-client/tests/e2e_relay.rs) failed in "Relay and
PostgreSQL / Relay E2E" at :363 with "recv kind:44100 notification for
the default channel: Timeout". Two of the three reruns passed.

- run 41568808045 attempt 1 (rerun passed):
-
https://github.com/block/buzz/actions/runs/41568808045/job/125701098947
- run 41657074260:
-
https://github.com/block/buzz/actions/runs/41657074260/job/125997465078
- run 41716543775 attempt 1 (rerun passed):
-
https://github.com/block/buzz/actions/runs/41716543775/job/126206390776

#### Cause

The e2e relay runs without `BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so every
human NIP-42 admission spawns `auto_join_never_joined_channels`
(handlers/auth.rs). The test connected the joiner and sent its
kind:44100 REQ before minting and claiming the invite. The admission
therefore joined the joiner to the flagged channel before the claim did.
The claim's `auto_join_default_channels` then got `AlreadyMember` and
emitted nothing, so the admission notice was the only kind:44100.

All three relay logs show this. The joiner's "auto-joined default
channel on admission" appears 39, 52 and 106 ms after its NIP-42 auth,
before "relay member added via v2 invite". The claim logs no join for
the joiner.

When the admission notice reached the client before EOSE,
`collect_until_eose` consumed it, and the test discarded that result.
The test then waited 5 s for a live event that never came. When the
notice arrived after EOSE, the test passed, but on the admission path.
Either way, the test never covered the claim-time auto-join it is named
for.

#### Fix

Only the test changes. The joiner opens no WebSocket until the claim
returns. Over NIP-98, the claim awaits the join, the kind:39002 roster
and the kind:44100 before its 200. The test now:

- checks the roster before the joiner connects, so only the claim can
have joined them;
- then connects and REQs kind:44100 `#p=joiner` with `since` taken
before the claim (minus the file's usual 5 s margin);
- asserts that the events `collect_until_eose` returns hold exactly one
notice for the channel, signed by the relay's NIP-11 `self` key.

Nothing depends on EOSE-vs-live ordering any more. The admission that
follows skips the channel, because the claim already added a member row.
No timeouts change, no retries are added, and no assertions are
loosened. The repeat-claim and cleanup steps are unchanged.

No open PR touches this test. Draft #9045 (default-channel recovery)
changes invites.rs, but not this test or this ordering.

#### Validation

Local Linux workstation, fresh database, relay started by
scripts/start-relay-for-tests.sh. Redis was the host's own, and
`BUZZ_METRICS_PORT` was moved off 9102, which bloxlet owns. Each
iteration ran CI's `e2e_relay invite` selection (8 tests in parallel).

- Unmodified test: 12/60 failed at :363 with the CI message.
- Adversarial ordering, using temporary, uncommitted changes: a 500 ms
sleep between connect and REQ, plus logging of what `collect_until_eose`
returned. 20/20 failed with the CI signature, and each time
`collect_until_eose` had returned the channel's notice. In the relay
log, the admission join came 28 ms after auth, the REQ queued one event,
and the claim emitted nothing.
- Fixed test: 100/100 passed. With the same 500 ms sleep: 30/30 passed,
each with exactly one notice, from the claim. Four concurrent loops of
25: 100/100 passed.
- Falsifiability, using a temporary relay build without the claim-time
`auto_join_default_channels` call: the fixed test failed 10/10 at the
roster assertion (`None`). The old test passed 18/20 against that build;
its two failures were its own :363 flake.
- The full CI "Relay E2E tests" step list passed once.
- `cargo fmt --check`, `cargo clippy -p buzz-test-client --tests -D
warnings`, `just file-size-check` and `git diff --check` are clean.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** the relay was
built from this branch with `--profile ci` (its relay code is identical
to main) and run with CI's environment. It used a fresh desired-state
database with the deployment community seeded, the host's Redis, and a
private MinIO-compatible container (the image docker-compose pins) for
the startup git probe. `BUZZ_METRICS_PORT` was moved off 9102.

- CI's `e2e_relay invite` selection: 30/30 iterations passed, 8 tests
each.
- CI's full "Relay E2E tests" step list, once: 10 of 11 steps passed.
`test_nip29_announce_channel_wire` failed at e2e_relay.rs:3862: the
kind:39000 metadata still carried `auto_join` right after the relay
owner cleared it. This branch doesn't touch that test or any relay code.
Run alone against the same relay, that test failed 8 of 20 times.
Re-running the whole step against one database isn't meaningful, because
`test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`
reuses a fixed channel name.

## Coordination

- #8991 ("BUZZ-512: Track session application and drain") edits
`AuxPages` and `pause_after_aux_page` in
`crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs`,
and `git merge-tree` shows a conflict there. #8991 already conflicts
with main in 15 files, so it needs a rebase anyway. The second commit's
message describes that rebase: keep #8991's waker on the non-`Option`
proxy field, and use `proxied_state` for the two callers it moves onto a
proxy.
- #6642 and #7569 touch `crates/buzz-test-client/tests/e2e_relay.rs` and
merge cleanly with this branch.
- #7852 touches `thread_window/postgres_tests.rs` and `e2e_relay.rs`. It
already conflicts with main, and this branch adds no new conflicted file
to it. Older open PRs that touch `e2e_relay.rs` also already conflict
with main, and none gains a new conflict from this branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-11-06 09:28:38 +11:00
committed by GitHub
parent 47be2e2d3f
commit a22ff133b4
4 changed files with 124 additions and 65 deletions

View File

@@ -144,10 +144,13 @@ async fn reader_boot_ping_warms_pool_to_max_in_background() {
/// At the production acquire budget, a reader that admits only some dials
/// ends the warm pass bounded and partial, with no re-dialing. The pool is
/// built by the production constructor, so each warm acquire, dial
/// included, gets only [`Db::READER_ACQUIRE_TIMEOUT`]. The proxy relays the
/// first 3 sessions and stalls every later dial mid-handshake: the first
/// wave of 5 holds 3, the second wave's 5 dials all miss, and that wave's
/// lack of progress ends the pass after exactly 10 dials.
/// included, gets only [`Db::READER_ACQUIRE_TIMEOUT`]. The boot ping first
/// fills the warm floor of 3, dialed outside that budget, and the proxy then
/// stalls every later dial mid-handshake: the first wave of 5 holds the 3
/// idle connections and its 2 dials miss, the second wave's 5 dials all
/// miss, and that wave's lack of progress ends the pass after exactly 7
/// dials. The pass needs no dial to beat the budget: on a loaded host every
/// dial can miss it, which would end the pass after one empty wave.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn reader_warm_at_production_budget_ends_partial_when_dials_stall() {
@@ -156,7 +159,6 @@ async fn reader_warm_at_production_budget_ends_partial_when_dials_stall() {
let writer_options = writer.connect_options();
let (host, port) = (writer_options.get_host(), writer_options.get_port());
let proxy = crate::test_support::PausingProxy::spawn(host.to_owned(), port).await;
proxy.stall_from(3);
let scratch_url = scratch.url();
let proxied_url = scratch_url.replacen(
&format!("{host}:{port}"),
@@ -169,6 +171,13 @@ async fn reader_warm_at_production_budget_ends_partial_when_dials_stall() {
);
let read_pool = Db::connect_read_pool(&DbConfig::default(), &proxied_url, max)
.expect("production reader pool");
assert!(
Db::read_pool_boot_ping_once(read_pool.clone(), Default::default()).await,
"boot ping must reach the reader"
);
await_idle_floor(&read_pool, 3).await;
let relayed = proxy.sessions();
proxy.stall_from(relayed);
let started = Instant::now();
let report = Db::warm_read_pool(&read_pool).await;
@@ -179,14 +188,19 @@ async fn reader_warm_at_production_budget_ends_partial_when_dials_stall() {
(max, 3, 7),
"one partial wave, then one wave that opens nothing"
);
assert_eq!(proxy.sessions(), 10, "the pass must stop dialing");
assert!(
elapsed < Duration::from_secs(2),
"two waves at the production budget must end promptly: {elapsed:?}"
);
await_idle_floor(&read_pool, 3).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(proxy.sessions(), 10, "nothing re-dials after the pass");
// Counted after the settle: the proxy accepts on this runtime, so a dial
// that missed its budget can still be queued for accept when the pass ends.
assert_eq!(
proxy.sessions() - relayed,
7,
"the pass must stop dialing, and nothing re-dials after it"
);
read_pool.close().await;
}

View File

@@ -180,8 +180,13 @@ impl Fixture {
}
}
async fn production_db(mut config: buzz_db::DbConfig) -> buzz_db::Db {
config.database_url = crate::test_support::database_url();
async fn production_db(config: buzz_db::DbConfig) -> buzz_db::Db {
production_db_at(crate::test_support::database_url(), config).await
}
/// [`production_db`] at another URL, such as a proxy in front of the test database.
async fn production_db_at(database_url: String, mut config: buzz_db::DbConfig) -> buzz_db::Db {
config.database_url = database_url;
config.max_connections = 5;
buzz_db::Db::new(&config).await.unwrap()
}

View File

@@ -10,10 +10,11 @@ use tracing_subscriber::{layer::Context, prelude::*, registry::LookupSpan, Layer
// Pause the HTTP future after the first real auxiliary page completes. This
// observes a production span rather than replacing the database or closure.
// With a proxy, the first page's span close also pauses it. That runs inside
// the request's poll, before the second page's query is written, so the second
// page stays pending however fast the database answers.
struct AuxPages(Arc<AtomicUsize>, Option<Arc<PausingProxy>>);
// The first page's span close also pauses the proxy the request's database
// connects through. That runs inside the request's poll, before the second
// page's query is written, so the second page stays pending however fast the
// database answers.
struct AuxPages(Arc<AtomicUsize>, Arc<PausingProxy>);
impl<S> Layer<S> for AuxPages
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
@@ -22,16 +23,14 @@ where
if ctx.span(&id).unwrap().metadata().name() == "thread_window_aux"
&& self.0.fetch_add(1, Ordering::SeqCst) == 0
{
if let Some(proxy) = &self.1 {
proxy.set_paused(true);
}
self.1.set_paused(true);
}
}
}
async fn pause_after_aux_page<F: Future>(
request: F,
proxy: Option<Arc<PausingProxy>>,
proxy: Arc<PausingProxy>,
) -> std::pin::Pin<Box<impl Future<Output = F::Output>>> {
let pages = Arc::new(AtomicUsize::new(0));
let subscriber = tracing_subscriber::registry().with(AuxPages(pages.clone(), proxy));
@@ -60,6 +59,23 @@ async fn pause_after_aux_page<F: Future>(
request
}
// State for the paused request alone, whose production writer connects through
// a proxy so `AuxPages` can hold its second writer page. The test still reads
// through the fixture's direct `state.db` while the request is held; through
// the proxy, that read would deadlock.
async fn proxied_state(f: &Fixture) -> (Arc<AppState>, Arc<PausingProxy>) {
let database_url = crate::test_support::database_url();
let options: sqlx::postgres::PgConnectOptions = database_url.parse().unwrap();
let proxy =
Arc::new(PausingProxy::spawn(options.get_host().to_owned(), options.get_port()).await);
let mut url = url::Url::parse(&database_url).unwrap();
url.set_host(Some("127.0.0.1")).unwrap();
url.set_port(Some(proxy.port)).unwrap();
let mut state = (*f.state).clone();
state.db = production_db_at(url.into(), buzz_db::DbConfig::default()).await;
(Arc::new(state), proxy)
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_window_bridge_restarts_both_aux_hops_after_replica_failure() {
@@ -92,11 +108,8 @@ async fn thread_window_bridge_restarts_both_aux_hops_after_replica_failure() {
let mut filter = f.filter();
filter["until"] = json!(f.root.created_at.as_secs() + 1);
filter["before_id"] = json!("00".repeat(32));
let request = pause_after_aux_page(
f.post(&f.keys, "/query", json!([filter])),
Some(proxy.clone()),
)
.await;
let request =
pause_after_aux_page(f.post(&f.keys, "/query", json!([filter])), proxy.clone()).await;
let held: i64 = sqlx::query_scalar("SELECT count(*) FROM pg_stat_activity WHERE application_name=$1 AND xact_start IS NOT NULL")
.bind(&reader_name).fetch_one(&f.pool).await.unwrap();
assert_eq!(held, 1, "must actually hold the proved replica transaction");
@@ -218,7 +231,12 @@ async fn assert_aux_access_change(grant: bool) {
"pre-transition visibility control"
);
let request = pause_after_aux_page(f.post(&f.keys, "/query", json!([filter])), None).await;
let (state, proxy) = proxied_state(&f).await;
let request = pause_after_aux_page(
post(state, &f.host, &f.keys, "/query", json!([filter])),
proxy.clone(),
)
.await;
set_access(&f, aux_channel, grant).await;
let current = f
.state
@@ -231,6 +249,7 @@ async fn assert_aux_access_change(grant: bool) {
"requested channel stays authorized"
);
assert_eq!(current.contains(&aux_channel), grant);
proxy.set_paused(false);
let (status, interrupted) = request.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{interrupted}");
assert_eq!(
@@ -355,8 +374,14 @@ async fn thread_window_batch_discards_earlier_windows_after_revocation() {
let (status, before) = f.post(&f.keys, "/query", batch.clone()).await;
assert_eq!(status, StatusCode::OK, "{before}");
assert_eq!(ids(&before, Some(39007)).len(), 2);
let request = pause_after_aux_page(f.post(&f.keys, "/query", batch.clone()), None).await;
let (state, proxy) = proxied_state(&f).await;
let request = pause_after_aux_page(
post(state, &f.host, &f.keys, "/query", batch.clone()),
proxy.clone(),
)
.await;
set_access(&f, f.channel, false).await;
proxy.set_paused(false);
let (status, body) = request.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}");
assert_eq!(

View File

@@ -303,8 +303,14 @@ async fn test_invite_mint_and_claim_admits_new_pubkey() {
}
/// Claiming an invite joins the new member to the community's default
/// channels: the relay owner flags one, the claimer receives its kind:44100
/// and is on its kind:39002 roster, and a repeat claim is a no-op.
/// channels: the relay owner flags one, the claim puts the claimer on its
/// kind:39002 roster and stores one relay-signed kind:44100 for them, and a
/// repeat claim is a no-op.
///
/// The claimer opens no WebSocket until the claim has answered. The e2e
/// relay runs without `BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so a NIP-42 admission
/// would join them to the default channels itself and leave the claim
/// nothing to join.
#[tokio::test]
#[ignore]
async fn test_invite_claim_auto_joins_default_channel() {
@@ -319,23 +325,6 @@ async fn test_invite_claim_auto_joins_default_channel() {
(true, String::new())
);
let mut joiner_client = BuzzTestClient::connect(&url, &joiner)
.await
.expect("connect joiner");
let sid = sub_id("auto-join-notif");
let filter = Filter::new()
.kind(Kind::Custom(44100))
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), joiner_hex.as_str())
.since(nostr::Timestamp::now() - 5u64);
joiner_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
joiner_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
let mint_response = invite_post(&relay_owner, "/api/invites", "{}").await;
assert_eq!(mint_response.status(), reqwest::StatusCode::OK);
let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON");
@@ -344,6 +333,7 @@ async fn test_invite_claim_auto_joins_default_channel() {
.and_then(serde_json::Value::as_str)
.expect("mint response includes code");
let claim_body = serde_json::json!({ "code": code }).to_string();
let claimed_since = nostr::Timestamp::now() - 5u64;
let claim_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
assert_eq!(claim_response.status(), reqwest::StatusCode::OK);
let claim_json: serde_json::Value = claim_response.json().await.expect("claim JSON");
@@ -351,28 +341,6 @@ async fn test_invite_claim_auto_joins_default_channel() {
claim_json.get("status").and_then(serde_json::Value::as_str),
Some("joined")
);
// Other tests may leave their own default channels flagged on this
// shared relay; wait for the notification about ours.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let msg = joiner_client
.recv_event(remaining)
.await
.expect("recv kind:44100 notification for the default channel");
if let RelayMessage::Event { event, .. } = msg {
let channel = event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|tag| tag.as_slice().get(1).cloned());
if channel.as_deref() == Some(channel_id.as_str()) {
break;
}
}
}
joiner_client.disconnect().await.ok();
assert_eq!(
member_role(&url, &relay_owner, &channel_id, &joiner_hex)
.await
@@ -381,6 +349,53 @@ async fn test_invite_claim_auto_joins_default_channel() {
"the relay-signed kind:39002 roster lists the claimer"
);
// The claim stores its notice before it answers, so the notice comes
// back as history, before EOSE.
let mut joiner_client = BuzzTestClient::connect(&url, &joiner)
.await
.expect("connect joiner");
let sid = sub_id("auto-join-notif");
let filter = Filter::new()
.kind(Kind::Custom(44100))
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), joiner_hex.as_str())
.since(claimed_since);
joiner_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
let notices = joiner_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
joiner_client.disconnect().await.ok();
// Other tests may leave their own default channels flagged on this
// shared relay; count only the notices about ours.
let ours: Vec<_> = notices
.iter()
.filter(|event| {
event.tags.iter().any(|tag| {
let tag = tag.as_slice();
tag.len() >= 2 && tag[0] == "h" && tag[1] == channel_id
})
})
.collect();
assert_eq!(
ours.len(),
1,
"one notice for the default channel: {notices:?}"
);
let info: serde_json::Value = reqwest::get(format!("{}/info", relay_http_url()))
.await
.expect("GET /info")
.json()
.await
.expect("NIP-11 JSON");
assert_eq!(
info.get("self").and_then(serde_json::Value::as_str),
Some(ours[0].pubkey.to_hex().as_str()),
"the relay signs the notice"
);
let repeat_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
assert_eq!(repeat_response.status(), reqwest::StatusCode::OK);
let repeat_json: serde_json::Value = repeat_response.json().await.expect("repeat claim JSON");