Files
ngit-grasp/tests/sync/proactive_sync_plus.rs
T
DanConwayDev e84be42446 feat(sync): cover roots with missing relay lists
Some accepted root authors do not publish a discoverable NIP-65 kind 10002, leaving their conversations outside Sync+ even after a successful user-index lookup. Use a bounded operator-configured fallback relay set only after such a successful empty lookup.

Feed fallback roots into the existing GRASP-02 target overlay, so historic, live, rotating, subscription-budget, and recovery behavior remain unified. A later accepted relay list removes the author from desired fallback coverage while shared live subscriptions drain naturally.

Keep eligibility restricted to accepted root authors and preserve single-flight identity discovery. This does not expand to response authors, add another scheduler, or infer fallback need from failed queries.

Validated with the end-to-end missing-list fallback and later-list replacement scenario, discovery/config unit tests, Nix module parsing, rustfmt, clippy with warnings denied, and diff checks.
2026-08-12 17:44:00 +00:00

305 lines
11 KiB
Rust

//! Minimal GRASP-03 mailbox discovery scenario.
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,
};
#[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";
let relay_list = EventBuilder::new(Kind::RelayList, "")
.tags([
Tag::custom("r", vec![inbox.url(), "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 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 inbox coverage from NIP-65")
&& line.contains("fallback_authors=1")
}),
"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"
);
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read updated relay log");
assert!(
sync_log.lines().any(|line| {
line.contains("Updated proactive inbox coverage from NIP-65")
&& line.contains("fallback_authors=0")
}),
"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;
}