mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Repository owners and issue authors can complete NIP-65 discovery in the same batch, producing two fallback authors rather than one. Assert that fallback coverage is active without depending on batching order. The preceding exact-event check still proves that the reply was fetched from the fallback relay. All four proactive mailbox integration tests pass; no production behavior or timeout changes are included. Assisted-by: Codex (GPT-6)
529 lines
20 KiB
Rust
529 lines
20 KiB
Rust
//! Minimal GRASP-03 mailbox discovery scenario.
|
|
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use nostr_sdk::prelude::*;
|
|
|
|
use crate::common::{
|
|
build_layer2_issue_event, repo_coord, reserve_port, send_to_relay_url,
|
|
setup_announcement_on_relay, wait_for_event_on_relay, MockRelay, TestClient, TestRelay,
|
|
};
|
|
|
|
async fn wait_for_log_line<F>(path: &Path, timeout: Duration, predicate: F) -> bool
|
|
where
|
|
F: Fn(&str) -> bool,
|
|
{
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let log = std::fs::read_to_string(path).unwrap_or_default();
|
|
if log.lines().any(&predicate) {
|
|
return true;
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn root_author_inbox_reuses_existing_root_sync_pipeline() {
|
|
let index = MockRelay::start().await;
|
|
let outbox = MockRelay::start().await;
|
|
let inbox = MockRelay::start().await;
|
|
let replacement_inbox = MockRelay::start().await;
|
|
let owner = Keys::generate();
|
|
let root_author = Keys::generate();
|
|
let unrelated = Keys::generate();
|
|
let identifier = "minimal-proactive-sync-plus";
|
|
|
|
// The inbox is advertised in the root-trailing-slash form. Canonical
|
|
// selection must fold it onto the same connection key, so coverage works
|
|
// end to end for the variant that previously stalled mailbox probes.
|
|
let slashed_inbox = format!("{}/", inbox.url());
|
|
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
|
.tags([
|
|
Tag::custom("r", vec![slashed_inbox.as_str(), "read"]),
|
|
Tag::custom("r", vec![outbox.url(), "write"]),
|
|
])
|
|
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 10))
|
|
.finalize(&root_author)
|
|
.expect("build NIP-65 relay list");
|
|
let old_profile = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#)
|
|
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 20))
|
|
.finalize(&root_author)
|
|
.expect("build old root-author profile");
|
|
let profile = EventBuilder::new(Kind::Metadata, r#"{"name":"current"}"#)
|
|
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 5))
|
|
.finalize(&root_author)
|
|
.expect("build current root-author profile");
|
|
let unrelated_profile = EventBuilder::new(Kind::Metadata, r#"{"name":"unrelated"}"#)
|
|
.finalize(&unrelated)
|
|
.expect("build unrelated profile");
|
|
send_to_relay_url(index.url(), &old_profile)
|
|
.await
|
|
.expect("seed old profile");
|
|
send_to_relay_url(index.url(), &profile)
|
|
.await
|
|
.expect("seed current profile");
|
|
send_to_relay_url(index.url(), &unrelated_profile)
|
|
.await
|
|
.expect("seed unrelated profile");
|
|
send_to_relay_url(index.url(), &relay_list)
|
|
.await
|
|
.expect("seed discovery index");
|
|
let replacement = EventBuilder::new(Kind::RelayList, "")
|
|
.tags([
|
|
Tag::custom("r", vec![replacement_inbox.url(), "read"]),
|
|
Tag::custom("r", vec![outbox.url(), "write"]),
|
|
])
|
|
.custom_created_at(Timestamp::now())
|
|
.finalize(&root_author)
|
|
.expect("build outbox replacement relay list");
|
|
send_to_relay_url(outbox.url(), &replacement)
|
|
.await
|
|
.expect("seed newer relay list on advertised outbox");
|
|
|
|
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
|
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
|
let syncing = TestRelay::start_on_reservation_persistent_sync(
|
|
reserve_port(),
|
|
Some(index.url().to_string()),
|
|
false,
|
|
syncing_git_dir.path().to_path_buf(),
|
|
syncing_relay_dir.path().to_path_buf(),
|
|
)
|
|
.await;
|
|
let syncing_domain = syncing.domain();
|
|
let (_announcement, _git_dir) =
|
|
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
|
let issue = build_layer2_issue_event(
|
|
&root_author,
|
|
&repo_coord(&owner, identifier),
|
|
"root whose replies live in its inbox",
|
|
)
|
|
.expect("build accepted root");
|
|
let client = TestClient::new(syncing.url(), root_author.clone())
|
|
.await
|
|
.expect("connect to target");
|
|
client.send_event(&issue).await.expect("publish root");
|
|
|
|
let reply = EventBuilder::new(Kind::TextNote, "inbox-only reply")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build reply");
|
|
send_to_relay_url(inbox.url(), &reply)
|
|
.await
|
|
.expect("seed root-author inbox");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(reply.id),
|
|
Duration::from_secs(20),
|
|
)
|
|
.await,
|
|
"NIP-65 inbox roots should flow through ordinary descendant coverage"
|
|
);
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(profile.id),
|
|
Duration::from_secs(15),
|
|
)
|
|
.await,
|
|
"latest accepted root-author metadata should be retained locally"
|
|
);
|
|
assert!(
|
|
!wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(old_profile.id),
|
|
Duration::from_secs(2),
|
|
)
|
|
.await,
|
|
"older root-author metadata should not be selected"
|
|
);
|
|
assert!(
|
|
!wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(unrelated_profile.id),
|
|
Duration::from_secs(2),
|
|
)
|
|
.await,
|
|
"unrelated metadata on the discovery relay must remain absent"
|
|
);
|
|
|
|
let replacement_reply = EventBuilder::new(Kind::TextNote, "replacement inbox reply")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build replacement reply");
|
|
send_to_relay_url(replacement_inbox.url(), &replacement_reply)
|
|
.await
|
|
.expect("seed replacement inbox");
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(replacement_reply.id),
|
|
Duration::from_secs(15),
|
|
)
|
|
.await,
|
|
"replacement NIP-65 inbox should become the desired root source"
|
|
);
|
|
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
|
assert!(
|
|
sync_log.lines().any(|line| {
|
|
line.contains("NIP-65 discovery connection ready without repository sync")
|
|
&& line.contains(outbox.url())
|
|
}),
|
|
"the advertised outbox connection should be identified as discovery-only"
|
|
);
|
|
assert!(
|
|
!sync_log
|
|
.lines()
|
|
.any(|line| { line.contains("Starting fresh_start") && line.contains(outbox.url()) }),
|
|
"querying an advertised outbox must not start ordinary repository sync against it"
|
|
);
|
|
let draining_reply = EventBuilder::new(Kind::TextNote, "reply on naturally draining inbox")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build draining reply");
|
|
send_to_relay_url(inbox.url(), &draining_reply)
|
|
.await
|
|
.expect("seed old inbox while its live subscription drains");
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(draining_reply.id),
|
|
Duration::from_secs(5),
|
|
)
|
|
.await,
|
|
"replacement must not eagerly close existing descendant live coverage"
|
|
);
|
|
|
|
// A restart must reconstruct the accepted kind-10002 overlay locally.
|
|
// Stop the only discovery source first so a remote refresh cannot make the
|
|
// assertion pass accidentally.
|
|
index.stop().await;
|
|
let syncing = syncing.restart().await;
|
|
let restart_reply = EventBuilder::new(Kind::TextNote, "reply after offline-index restart")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build restart reply");
|
|
send_to_relay_url(replacement_inbox.url(), &restart_reply)
|
|
.await
|
|
.expect("seed replacement inbox after restart");
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(restart_reply.id),
|
|
Duration::from_secs(20),
|
|
)
|
|
.await,
|
|
"retained kind-10002 must rebuild inbox coverage while its index source is offline"
|
|
);
|
|
|
|
syncing.stop().await;
|
|
replacement_inbox.stop().await;
|
|
outbox.stop().await;
|
|
inbox.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn participant_write_mailbox_fetches_child_of_direct_reaction() {
|
|
let index = MockRelay::start().await;
|
|
let mailbox = MockRelay::start().await;
|
|
let owner = Keys::generate();
|
|
let root_author = Keys::generate();
|
|
let participant = Keys::generate();
|
|
let child_author = Keys::generate();
|
|
let identifier = "participant-write-mailbox";
|
|
|
|
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
|
.tag(Tag::custom("r", vec![mailbox.url(), "write"]))
|
|
.finalize(&participant)
|
|
.expect("build participant relay list");
|
|
send_to_relay_url(index.url(), &relay_list)
|
|
.await
|
|
.expect("seed participant relay list");
|
|
|
|
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
|
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
|
let syncing = TestRelay::start_on_reservation_persistent_sync(
|
|
reserve_port(),
|
|
Some(index.url().to_string()),
|
|
false,
|
|
syncing_git_dir.path().to_path_buf(),
|
|
syncing_relay_dir.path().to_path_buf(),
|
|
)
|
|
.await;
|
|
let syncing_domain = syncing.domain();
|
|
let (_announcement, _git_dir) =
|
|
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
|
let issue = build_layer2_issue_event(
|
|
&root_author,
|
|
&repo_coord(&owner, identifier),
|
|
"root with a reaction whose child is mailbox-only",
|
|
)
|
|
.expect("build accepted root");
|
|
let root_client = TestClient::new(syncing.url(), root_author)
|
|
.await
|
|
.expect("connect root author");
|
|
root_client.send_event(&issue).await.expect("publish root");
|
|
|
|
let reaction = EventBuilder::new(Kind::Reaction, "+")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&participant)
|
|
.expect("build direct participant reaction");
|
|
let participant_client = TestClient::new(syncing.url(), participant)
|
|
.await
|
|
.expect("connect participant");
|
|
participant_client
|
|
.send_event(&reaction)
|
|
.await
|
|
.expect("publish direct reaction");
|
|
|
|
let child = EventBuilder::new(Kind::TextNote, "reply to a reaction")
|
|
.tag(Tag::custom("e", vec![reaction.id.to_hex()]))
|
|
.finalize(&child_author)
|
|
.expect("build mailbox-only child");
|
|
send_to_relay_url(mailbox.url(), &child)
|
|
.await
|
|
.expect("seed participant write mailbox");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(child.id),
|
|
Duration::from_secs(30),
|
|
)
|
|
.await,
|
|
"history probing should fetch a child that only names the participant's direct reaction"
|
|
);
|
|
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
|
assert!(
|
|
sync_log.lines().any(|line| {
|
|
line.contains("Started bounded participant mailbox fetch")
|
|
&& line.contains(mailbox.url())
|
|
}),
|
|
"participant mailbox transport should remain observable"
|
|
);
|
|
|
|
syncing.stop().await;
|
|
mailbox.stop().await;
|
|
index.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn owner_inbox_history_discovers_unknown_root_without_live_sync() {
|
|
let index = MockRelay::start().await;
|
|
let mailbox = MockRelay::start().await;
|
|
let owner = Keys::generate();
|
|
let root_author = Keys::generate();
|
|
let identifier = "owner-repository-mailbox";
|
|
|
|
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
|
.tag(Tag::custom("r", vec![mailbox.url(), "read"]))
|
|
.finalize(&owner)
|
|
.expect("build owner relay list");
|
|
send_to_relay_url(index.url(), &relay_list)
|
|
.await
|
|
.expect("seed owner relay list");
|
|
|
|
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
|
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
|
let syncing = TestRelay::start_on_reservation_persistent_sync(
|
|
reserve_port(),
|
|
Some(index.url().to_string()),
|
|
false,
|
|
syncing_git_dir.path().to_path_buf(),
|
|
syncing_relay_dir.path().to_path_buf(),
|
|
)
|
|
.await;
|
|
let syncing_domain = syncing.domain();
|
|
let (_announcement, _git_dir) =
|
|
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
|
let issue = build_layer2_issue_event(
|
|
&root_author,
|
|
&repo_coord(&owner, identifier),
|
|
"root stored only on the repository owner's mailbox",
|
|
)
|
|
.expect("build mailbox-only root");
|
|
let closed = EventBuilder::new(Kind::from(1632), "closed")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&owner)
|
|
.expect("build root-only closed status");
|
|
send_to_relay_url(mailbox.url(), &issue)
|
|
.await
|
|
.expect("seed unknown root on owner mailbox");
|
|
send_to_relay_url(mailbox.url(), &closed)
|
|
.await
|
|
.expect("seed root-only status on owner mailbox");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(issue.id),
|
|
Duration::from_secs(30),
|
|
)
|
|
.await,
|
|
"the owner's repository-coordinate scope should discover an unknown root"
|
|
);
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(closed.id),
|
|
Duration::from_secs(30),
|
|
)
|
|
.await,
|
|
"the owner's mailbox should be queried for known root IDs after inventory growth"
|
|
);
|
|
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
|
assert!(
|
|
sync_log.lines().any(|line| {
|
|
line.contains("Reconciled proactive participant mailbox inventory")
|
|
&& line.contains("repository_mailbox_author_count=1")
|
|
}),
|
|
"repository mailbox ownership should remain observable without public-key labels"
|
|
);
|
|
assert!(
|
|
sync_log.lines().any(|line| {
|
|
line.contains("Started bounded participant mailbox fetch")
|
|
&& line.contains(mailbox.url())
|
|
}),
|
|
"the owner inbox should be probed through the repository-scoped history path"
|
|
);
|
|
|
|
// A root published later is still discovered, but only through the
|
|
// recurring historical probe cycle: the owner inbox never becomes an
|
|
// ordinary live repository source.
|
|
let later_issue = build_layer2_issue_event(
|
|
&root_author,
|
|
&repo_coord(&owner, identifier),
|
|
"root published after initial owner-inbox history",
|
|
)
|
|
.expect("build later mailbox root");
|
|
send_to_relay_url(mailbox.url(), &later_issue)
|
|
.await
|
|
.expect("publish root after initial history");
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(later_issue.id),
|
|
Duration::from_secs(30),
|
|
)
|
|
.await,
|
|
"owner inbox repository coverage should continue through historical refresh"
|
|
);
|
|
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
|
assert!(
|
|
!sync_log
|
|
.lines()
|
|
.any(|line| { line.contains("Starting fresh_start") && line.contains(mailbox.url()) }),
|
|
"an owner inbox must not enter ordinary repository live sync"
|
|
);
|
|
|
|
syncing.stop().await;
|
|
mailbox.stop().await;
|
|
index.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
|
let index = MockRelay::start().await;
|
|
let fallback = MockRelay::start().await;
|
|
let discovered_inbox = MockRelay::start().await;
|
|
let owner = Keys::generate();
|
|
let root_author = Keys::generate();
|
|
let identifier = "missing-list-fallback";
|
|
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
|
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
|
let syncing = TestRelay::start_on_reservation_persistent_sync_with_fallback(
|
|
reserve_port(),
|
|
index.url().to_string(),
|
|
fallback.url().to_string(),
|
|
syncing_git_dir.path().to_path_buf(),
|
|
syncing_relay_dir.path().to_path_buf(),
|
|
)
|
|
.await;
|
|
let syncing_domain = syncing.domain();
|
|
let (_announcement, _git_dir) =
|
|
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
|
let issue = build_layer2_issue_event(
|
|
&root_author,
|
|
&repo_coord(&owner, identifier),
|
|
"root with no published relay list",
|
|
)
|
|
.expect("build accepted root");
|
|
let client = TestClient::new(syncing.url(), root_author.clone())
|
|
.await
|
|
.expect("connect to target");
|
|
client.send_event(&issue).await.expect("publish root");
|
|
|
|
let reply = EventBuilder::new(Kind::TextNote, "fallback-only reply")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build reply");
|
|
send_to_relay_url(fallback.url(), &reply)
|
|
.await
|
|
.expect("seed fallback relay");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(reply.id),
|
|
Duration::from_secs(20),
|
|
)
|
|
.await,
|
|
"a successful empty index lookup should activate bounded fallback coverage"
|
|
);
|
|
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
|
assert!(
|
|
sync_log.lines().any(|line| {
|
|
line.contains("Updated proactive participant mailbox coverage from NIP-65")
|
|
&& line.split_whitespace().any(|field| {
|
|
field
|
|
.strip_prefix("fallback_authors=")
|
|
.is_some_and(|count| count.parse::<usize>().is_ok_and(|count| count > 0))
|
|
})
|
|
}),
|
|
"fallback activation should remain observable"
|
|
);
|
|
|
|
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
|
.tag(Tag::custom("r", vec![discovered_inbox.url(), "read"]))
|
|
.finalize(&root_author)
|
|
.expect("build later relay list");
|
|
send_to_relay_url(index.url(), &relay_list)
|
|
.await
|
|
.expect("publish later relay list to index");
|
|
let declared_reply = EventBuilder::new(Kind::TextNote, "declared-inbox reply")
|
|
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
|
.finalize(&root_author)
|
|
.expect("build declared-inbox reply");
|
|
send_to_relay_url(discovered_inbox.url(), &declared_reply)
|
|
.await
|
|
.expect("seed declared inbox");
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(declared_reply.id),
|
|
Duration::from_secs(20),
|
|
)
|
|
.await,
|
|
"a later accepted relay list should replace desired fallback coverage"
|
|
);
|
|
assert!(
|
|
wait_for_log_line(&syncing.log_path(), Duration::from_secs(20), |line| {
|
|
line.contains("proactive participant mailbox") && line.contains("fallback_authors=0")
|
|
})
|
|
.await,
|
|
"accepted NIP-65 ownership should retire the author from desired fallback coverage"
|
|
);
|
|
|
|
syncing.stop().await;
|
|
discovered_inbox.stop().await;
|
|
fallback.stop().await;
|
|
index.stop().await;
|
|
}
|