use std::time::Duration;
use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use test_harness::{Harness, UnavailableTcpEndpoint};
const DISPLAY_NAME: &str = "Republish Project";
const EXPECTED_IDENTIFIER: &str = "Republish-Project";
fn ref_map(event: &Event) -> std::collections::BTreeMap<String, String> {
event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
let name = s.first()?;
if name.starts_with("refs/") || name == "HEAD" {
Some((name.clone(), s.get(1).cloned().unwrap_or_default()))
} else {
None
}
})
.collect()
}
async fn state_event_on(
relay: &test_harness::VanillaRelay,
author: PublicKey,
) -> Result<Option<Event>> {
let events = relay
.events(Filter::new().author(author).kind(Kind::Custom(30618)))
.await?;
let mut matching: Vec<Event> = events
.into_iter()
.filter(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.first().map(String::as_str) == Some("d")
&& s.get(1).map(String::as_str) == Some(EXPECTED_IDENTIFIER)
})
})
.collect();
if matching.len() > 1 {
bail!(
"expected at most one kind-30618 per coordinate on a relay, got {}",
matching.len()
);
}
Ok(matching.pop())
}
async fn run_init(repo: &test_harness::Repo, args: &[&str]) -> Result<std::process::Output> {
let mut full = vec!["init", "--name", DISPLAY_NAME];
full.extend_from_slice(args);
let mut command = repo.ngit(full);
command.kill_on_drop(true);
command.output().await.context("failed to spawn ngit init")
}
async fn run_edit(repo: &test_harness::Repo, args: &[&str]) -> Result<std::process::Output> {
let mut full = vec!["repo", "edit", "--name", DISPLAY_NAME];
full.extend_from_slice(args);
let mut command = repo.ngit(full);
command.kill_on_drop(true);
command
.output()
.await
.context("failed to spawn ngit repo edit")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn repository_edit_republishes_fresh_state_to_newly_added_relay() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_relay("extra")
.with_vanilla_git_server("host")
.build()
.await?;
let (repo, state) = harness.arrange_init_state_a_fresh().await?;
let pubkey = state.keys.public_key();
let vanilla_url = harness.vanilla_git_server("host").url().to_string();
let default_relay_url = harness.relay("default").url().to_string();
let extra_relay_url = harness.relay("extra").url().to_string();
let first = run_init(
&repo,
&[
"--additional-clone",
&vanilla_url,
"--additional-relay",
&default_relay_url,
],
)
.await?;
if !first.status.success() {
bail!(
"first ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
first.status,
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr),
);
}
let first_event = state_event_on(harness.relay("default"), pubkey)
.await?
.context("no kind-30618 on the default relay after the first init")?;
assert!(
state_event_on(harness.relay("extra"), pubkey)
.await?
.is_none(),
"the extra relay must not hold the state before it is announced",
);
let second = run_edit(&repo, &["--add-additional-relay", &extra_relay_url]).await?;
if !second.status.success() {
bail!(
"ngit repo edit exited non-zero ({:?})\nstdout: {}\nstderr: {}",
second.status,
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr),
);
}
let republished = state_event_on(harness.relay("extra"), pubkey)
.await?
.context(
"no kind-30618 on the newly announced relay after the repeat \
edit — the cached-state republish should have fanned the fresh \
event out to every announced relay",
)?;
assert_ne!(
republished.id, first_event.id,
"the repository edit must publish a fresh state event, not re-send the \
cached one",
);
assert_eq!(
ref_map(&republished),
ref_map(&first_event),
"the republished state must carry the same ref values as the cached \
state it re-signs",
);
let on_default = state_event_on(harness.relay("default"), pubkey)
.await?
.context("kind-30618 disappeared from the default relay")?;
assert_eq!(
on_default.id, republished.id,
"the previously announced relay should hold the fresh state event \
(NIP-01 replacement of the older one)",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_republish_leaves_previous_state_authoritative() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_vanilla_git_server("host")
.build()
.await?;
let (repo, state) = harness.arrange_init_state_a_fresh().await?;
let pubkey = state.keys.public_key();
let vanilla_url = harness.vanilla_git_server("host").url().to_string();
let default_relay_url = harness.relay("default").url().to_string();
let first = run_init(
&repo,
&[
"--additional-clone",
&vanilla_url,
"--additional-relay",
&default_relay_url,
],
)
.await?;
if !first.status.success() {
bail!(
"first ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
first.status,
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr),
);
}
let first_event = state_event_on(harness.relay("default"), pubkey)
.await?
.context("no kind-30618 on the default relay after the first init")?;
let unavailable = UnavailableTcpEndpoint::start().await?;
let dead_url = format!("http://{}/repo.git", unavailable.addr());
let failed = tokio::time::timeout(
Duration::from_secs(5),
run_edit(
&repo,
&[
"--remove-additional-clone",
&vanilla_url,
"--add-additional-clone",
&dead_url,
],
),
)
.await
.context("repo edit did not fail promptly for an unavailable git server")??;
assert!(
!failed.status.success(),
"repo edit with no listable git server must fail instead of \
broadcasting a state event no git server holds\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&failed.stdout),
String::from_utf8_lossy(&failed.stderr),
);
let stderr = String::from_utf8_lossy(&failed.stderr).to_lowercase();
assert!(
stderr.contains("ngit sync"),
"failed republish should point at `ngit sync` as the follow-up; \
stderr: {stderr}",
);
let on_default = state_event_on(harness.relay("default"), pubkey)
.await?
.context("kind-30618 disappeared from the default relay")?;
assert_eq!(
on_default.id, first_event.id,
"a failed republish must leave the previously published state event \
authoritative on the relays",
);
Ok(())
}