use std::sync::Arc;
use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{
CloneLogin, Harness, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, PublishRepoOpts,
event_branch_name_tag, tag_value, tag_values,
};
use tokio::sync::OnceCell;
const IDENTIFIER: &str = "pr-grasp06-git-server-test-repo";
const BRANCH: &str = "feature";
struct Snapshot {
pr_event: Event,
pr_count_primary: usize,
pr_update_count: usize,
maintainer_pubkey: PublicKey,
identifier: String,
merge_base_oid: String,
main_tip_at_send_time: String,
pr_tip_oid: String,
branch_name: String,
extra_grasp_prs_clone_url: String,
extra_grasp_http_base: String,
announcement_clone_urls: Vec<String>,
grasp_primary_pr_ref_oid: String,
grasp_secondary_pr_ref_oid: String,
extra_grasp_pr_ref_oid: String,
}
static SNAPSHOT: OnceCell<Arc<Snapshot>> = OnceCell::const_new();
#[fixture]
async fn snapshot() -> Arc<Snapshot> {
SNAPSHOT
.get_or_init(|| async {
Arc::new(
capture_snapshot()
.await
.expect("send_pr_grasp06_git_server fixture: capture_snapshot failed"),
)
})
.await
.clone()
}
async fn capture_snapshot() -> Result<Snapshot> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.with_grasp_server("repo_secondary")
.with_grasp_server_grasp06("extra_grasp")
.build()
.await?;
let extra_grasp_ws_url = harness.grasp("extra_grasp").relay_url(); let extra_grasp_http_base = harness.grasp("extra_grasp").url().to_string();
let (publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("pr-grasp06-test maintainer".into()),
identifier: Some(IDENTIFIER.into()),
additional_grasp_roles: vec!["repo_secondary".into()],
..Default::default()
})
.await?;
let maintainer_pubkey = published.maintainer_keys.public_key();
let maintainer_npub = maintainer_pubkey
.to_bech32()
.context("failed to bech32-encode maintainer pubkey")?;
let announcements = harness
.relay("default")
.events(
Filter::new()
.author(maintainer_pubkey)
.kind(Kind::GitRepoAnnouncement),
)
.await?;
let announcement = announcements
.iter()
.find(|e| tag_value(e, "d").as_deref() == Some(IDENTIFIER))
.context("no kind-30617 with expected identifier on default relay after publish_repo")?;
let announcement_clone_urls = tag_values(announcement, "clone");
let contributor = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "pr-grasp06-test contributor".into(),
},
)
.await?;
let contributor_nsec = contributor
.config("nostr.nsec")
.await?
.context("nostr.nsec missing after AsContributor login")?;
let contributor_keys =
Keys::parse(&contributor_nsec).context("contributor nostr.nsec is not a valid key")?;
let contributor_pubkey = contributor_keys.public_key();
let contributor_npub = contributor_pubkey
.to_bech32()
.context("failed to bech32-encode contributor pubkey")?;
let contributor_pubkey_hex = contributor_pubkey.to_hex();
contributor
.git_ok(
["checkout", "-b", BRANCH],
&format!("git checkout -b {BRANCH}"),
)
.await?;
std::fs::write(contributor.dir().join("t3.md"), "some content\n")
.context("failed to write t3.md in contributor clone")?;
contributor
.git_ok(["add", "t3.md"], "git add t3.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t3.md", "--no-gpg-sign"],
"git commit -m add t3.md --no-gpg-sign",
)
.await?;
let merge_base_oid = published.initial_oid.clone();
std::fs::write(publisher.dir().join("t-on-main.md"), "content\n")
.context("failed to write t-on-main.md on publisher side")?;
publisher
.git_ok(["add", "t-on-main.md"], "git add t-on-main.md")
.await?;
publisher
.git_ok(
["commit", "-m", "advance main", "--no-gpg-sign"],
"git commit -m advance main --no-gpg-sign",
)
.await?;
publisher
.nostr_push(["-u", "origin", "main"])
.await
.context("maintainer nostr_push to advance main failed")?;
let main_tip_at_send_time = publisher.rev_parse("HEAD").await?;
std::fs::write(contributor.dir().join("t4.md"), "some content\n")
.context("failed to write t4.md in contributor clone")?;
contributor
.git_ok(["add", "t4.md"], "git add t4.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t4.md", "--no-gpg-sign"],
"git commit -m add t4.md --no-gpg-sign",
)
.await?;
let pr_tip_oid = contributor.rev_parse("HEAD").await?;
if merge_base_oid == main_tip_at_send_time {
bail!(
"arrange bug: merge_base_oid == main_tip_at_send_time ({merge_base_oid}); \
the maintainer's 'advance main' commit must produce a distinct oid"
);
}
if merge_base_oid == pr_tip_oid {
bail!(
"arrange bug: merge_base_oid == pr_tip_oid ({merge_base_oid}); \
the contributor's feature-branch tip must differ from the fork point"
);
}
if main_tip_at_send_time == pr_tip_oid {
bail!(
"arrange bug: main_tip_at_send_time == pr_tip_oid ({main_tip_at_send_time}); \
the two trees must diverge after step 5"
);
}
let extra_grasp_prs_clone_url = format!(
"{}/prs/{}/{}.git",
extra_grasp_http_base.trim_end_matches('/'),
contributor_npub,
IDENTIFIER,
);
let send_out = contributor
.ngit([
"send",
"HEAD~2",
"--force-pr",
"--title",
"add feature",
"--description",
"this adds the feature",
"--git-server",
&extra_grasp_ws_url,
])
.output()
.await
.context("failed to spawn ngit send --force-pr --git-server (GRASP-06)")?;
if !send_out.status.success() {
bail!(
"ngit send --force-pr --git-server (GRASP-06) exited non-zero ({:?})\nstdout: {}\nstderr: {}",
send_out.status,
String::from_utf8_lossy(&send_out.stdout),
String::from_utf8_lossy(&send_out.stderr),
);
}
let pr_events_primary = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?;
let pr_count_primary = pr_events_primary.len();
let pr_event = pr_events_primary
.into_iter()
.find(|e| event_branch_name_tag(e).as_deref() == Some(BRANCH))
.context(
"no KIND_PULL_REQUEST with branch-name=\"feature\" authored by contributor \
found on primary grasp after `ngit send --force-pr --git-server` (GRASP-06)",
)?;
let pr_updates_primary = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?;
let pr_updates_secondary = harness
.grasp("repo_secondary")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?;
let pr_updates_relay = harness
.relay("default")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?;
let pr_update_count =
pr_updates_primary.len() + pr_updates_secondary.len() + pr_updates_relay.len();
let pr_event_id_hex = pr_event.id.to_hex();
let grasp_primary_pr_ref_oid = harness
.grasp("repo")
.read_nostr_ref(&maintainer_npub, IDENTIFIER, &pr_event_id_hex)
.await?;
let grasp_secondary_pr_ref_oid = harness
.grasp("repo_secondary")
.read_nostr_ref(&maintainer_npub, IDENTIFIER, &pr_event_id_hex)
.await?;
let extra_grasp_pr_ref_oid = harness
.grasp("extra_grasp")
.read_nostr_ref_prs(&contributor_pubkey_hex, IDENTIFIER, &pr_event_id_hex)
.await?;
Ok(Snapshot {
pr_event,
pr_count_primary,
pr_update_count,
maintainer_pubkey,
identifier: IDENTIFIER.to_string(),
merge_base_oid,
main_tip_at_send_time,
pr_tip_oid,
branch_name: BRANCH.to_string(),
extra_grasp_prs_clone_url,
extra_grasp_http_base,
announcement_clone_urls,
grasp_primary_pr_ref_oid,
grasp_secondary_pr_ref_oid,
extra_grasp_pr_ref_oid,
})
}
#[rstest]
#[tokio::test]
async fn pr_event_exactly_one(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_count_primary, 1,
"expected exactly one KIND_PULL_REQUEST event on primary grasp authored by \
contributor; got {}",
s.pr_count_primary,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn a_tag_is_repo_coordinate(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let expected = format!("30617:{}:{}", s.maintainer_pubkey, s.identifier);
let a_tags: Vec<&Tag> = s
.pr_event
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("a"))
.collect();
assert!(
a_tags
.iter()
.any(|t| t.as_slice().get(1).map(String::as_str) == Some(expected.as_str())),
"expected an `a` tag with value {expected:?}; got a tags: {:?}",
a_tags
.iter()
.filter_map(|t| t.as_slice().get(1).cloned())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn c_tag_is_pr_tip(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "c").as_deref(),
Some(s.pr_tip_oid.as_str()),
"PR event `c` tag should equal contributor's feature-branch tip OID; \
got {:?}, want {:?}",
tag_value(&s.pr_event, "c"),
s.pr_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn merge_base_tag_is_fork_point(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "merge-base").as_deref(),
Some(s.merge_base_oid.as_str()),
"PR event `merge-base` tag should equal the contributor's fork-point OID \
(parent of the first PR commit, not the current main tip);\n\
got {:?}\nwant: {:?}\nmain_tip_at_send_time was: {}",
tag_value(&s.pr_event, "merge-base"),
s.merge_base_oid,
s.main_tip_at_send_time,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn branch_name_tag_matches(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "branch-name").as_deref(),
Some(s.branch_name.as_str()),
"PR event `branch-name` tag should equal the contributor's feature branch name; \
got {:?}, want {:?}",
tag_value(&s.pr_event, "branch-name"),
s.branch_name,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn clone_tag_is_grasp06_prs_url(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let clone_tag_urls = tag_values(&s.pr_event, "clone");
assert_eq!(
clone_tag_urls.len(),
1,
"expected exactly one URL in PR event's `clone` tag; got {}: {:?}",
clone_tag_urls.len(),
clone_tag_urls,
);
assert_eq!(
clone_tag_urls[0], s.extra_grasp_prs_clone_url,
"PR event's `clone` URL should be the GRASP-06 /prs/ endpoint on the \
user-specified server (not a standard grasp clone URL);\n\
got: {:?}\nwant: {:?}",
clone_tag_urls[0], s.extra_grasp_prs_clone_url,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn all_three_servers_have_pr_ref(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.grasp_primary_pr_ref_oid, s.pr_tip_oid,
"primary grasp: refs/nostr/<pr_event_id> should resolve to pr_tip_oid; \
got {:?}, want {:?}",
s.grasp_primary_pr_ref_oid, s.pr_tip_oid,
);
assert_eq!(
s.grasp_secondary_pr_ref_oid, s.pr_tip_oid,
"secondary grasp: refs/nostr/<pr_event_id> should resolve to pr_tip_oid; \
got {:?}, want {:?}",
s.grasp_secondary_pr_ref_oid, s.pr_tip_oid,
);
assert_eq!(
s.extra_grasp_pr_ref_oid, s.pr_tip_oid,
"GRASP-06 extra grasp: refs/nostr/<pr_event_id> in prs/<hex>/<id>.git should \
resolve to pr_tip_oid; got {:?}, want {:?}",
s.extra_grasp_pr_ref_oid, s.pr_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn extra_grasp_not_in_announcement(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
for url in &s.announcement_clone_urls {
assert!(
!url.contains(&s.extra_grasp_http_base),
"GRASP-06 extra server base URL ({:?}) should not appear in the repo \
announcement's clone tag, but found it in {url:?}",
s.extra_grasp_http_base,
);
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn no_pr_update_event(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_update_count, 0,
"expected zero KIND_PULL_REQUEST_UPDATE events across all relay surfaces; \
got {} — was `--force-pr` accidentally routed through the update path?",
s.pr_update_count,
);
Ok(())
}