use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{Harness, PublishPatchSeriesOpts};
use tokio::sync::OnceCell;
const COVER_LETTER_TITLE: &str = "exampletitle";
const COVER_LETTER_DESCRIPTION: &str = "exampledescription";
const FIRST_COMMIT_FILE: &str = "t3.md";
const SECOND_COMMIT_FILE: &str = "t4.md";
struct Snapshot {
cover_letter: Event,
first_patch: Event,
second_patch: Event,
first_commit_oid: String,
root_commit_oid: String,
identifier: String,
maintainer_pubkeys: Vec<PublicKey>,
branch_name: String,
}
#[fixture]
async fn snapshot() -> Arc<Snapshot> {
SNAPSHOT
.get_or_init(|| async {
Arc::new(
capture_snapshot()
.await
.expect("send_patch fixture: capture_snapshot failed"),
)
})
.await
.clone()
}
static SNAPSHOT: OnceCell<Arc<Snapshot>> = OnceCell::const_new();
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")
.build()
.await?;
let (_publisher, role_graph) = harness
.publish_repo_with_role_graph("send-patch-repo")
.await?;
let published = &role_graph.published;
let series = harness
.publish_patch_series(
published,
PublishPatchSeriesOpts {
commits: vec![
(FIRST_COMMIT_FILE.into(), "some content\n".into()),
(SECOND_COMMIT_FILE.into(), "some content\n".into()),
],
cover_letter: Some((COVER_LETTER_TITLE.into(), COVER_LETTER_DESCRIPTION.into())),
..Default::default()
},
)
.await?;
let cover_letter = series
.cover_letter_event
.clone()
.context("expected a cover-letter event after --title/--description")?;
let first_commit_oid = series
.commits
.first()
.cloned()
.context("publish_patch_series returned no commits")?;
let second_commit_oid = series
.commits
.get(1)
.cloned()
.context("publish_patch_series returned only one commit")?;
let first_patch = find_patch_for_commit(&series.patch_events, &first_commit_oid)?;
let second_patch = find_patch_for_commit(&series.patch_events, &second_commit_oid)?;
let maintainer_pubkeys = vec![
published.maintainer_keys.public_key(),
role_graph.co_maintainer_keys.public_key(),
];
Ok(Snapshot {
cover_letter,
first_patch,
second_patch,
first_commit_oid,
root_commit_oid: published.initial_oid.clone(),
identifier: published.identifier.clone(),
maintainer_pubkeys,
branch_name: series.branch_name.clone(),
})
}
fn find_patch_for_commit(patches: &[Event], oid: &str) -> Result<Event> {
patches
.iter()
.find(|e| tag_value(e, "commit").as_deref() == Some(oid))
.cloned()
.ok_or_else(|| anyhow!("no patch event found with commit tag {oid}"))
}
fn tag_value(event: &Event, key: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
}
fn tag_values(event: &Event, key: &str) -> Vec<String> {
event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
.collect()
}
fn full_tag(event: &Event, key: &str) -> Result<Vec<String>> {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some(key))
.map(|t| t.as_slice().to_vec())
.ok_or_else(|| anyhow!("no `{key}` tag on event {}", event.id))
}
fn expected_a_coord(maintainer: PublicKey, identifier: &str) -> String {
format!(
"{}:{}:{identifier}",
Kind::GitRepoAnnouncement.as_u16(),
maintainer.to_hex()
)
}
#[derive(Debug, Clone, Copy)]
enum CoverLetterCase {
RootCommitAsR,
TTagCoverLetter,
TTagRoot,
BranchName,
Alt,
}
#[rstest]
#[case::root_commit_as_r(CoverLetterCase::RootCommitAsR)]
#[case::t_tag_cover_letter(CoverLetterCase::TTagCoverLetter)]
#[case::t_tag_root(CoverLetterCase::TTagRoot)]
#[case::branch_name(CoverLetterCase::BranchName)]
#[case::alt(CoverLetterCase::Alt)]
#[tokio::test]
async fn cover_letter_tags(
#[future] snapshot: Arc<Snapshot>,
#[case] case: CoverLetterCase,
) -> Result<()> {
let s = snapshot.await;
match case {
CoverLetterCase::RootCommitAsR => {
assert_eq!(
tag_value(&s.cover_letter, "r").as_deref(),
Some(s.root_commit_oid.as_str()),
"cover letter should carry an `r` tag for the repo's root commit",
);
}
CoverLetterCase::TTagCoverLetter => {
assert!(
tag_values(&s.cover_letter, "t")
.iter()
.any(|v| v == "cover-letter"),
"cover letter should carry `t cover-letter`",
);
}
CoverLetterCase::TTagRoot => {
assert!(
tag_values(&s.cover_letter, "t").iter().any(|v| v == "root"),
"cover letter should carry `t root` (new proposal, not a revision)",
);
}
CoverLetterCase::BranchName => {
assert_eq!(
tag_value(&s.cover_letter, "branch-name").as_deref(),
Some(s.branch_name.as_str()),
"cover letter should carry `branch-name <feature-branch>`",
);
}
CoverLetterCase::Alt => {
assert_eq!(
tag_value(&s.cover_letter, "alt").as_deref(),
Some(format!("git patch cover letter: {COVER_LETTER_TITLE}").as_str()),
"cover letter `alt` text format changed",
);
}
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn cover_letter_a_tag_for_each_maintainer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let coords = tag_values(&s.cover_letter, "a");
for m in &s.maintainer_pubkeys {
let expected = expected_a_coord(*m, &s.identifier);
assert!(
coords.iter().any(|c| c == &expected),
"cover letter missing `a` tag for maintainer {m} (expected {expected:?}, \
got {coords:?})",
);
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn cover_letter_p_tag_for_each_maintainer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let p_tags = tag_values(&s.cover_letter, "p");
for m in &s.maintainer_pubkeys {
let hex = m.to_hex();
assert!(
p_tags.iter().any(|p| p == &hex),
"cover letter missing `p` tag for maintainer {m} (got {p_tags:?})",
);
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum PatchCase {
CommitAndCommitR,
ParentCommit,
RootCommitAsR,
Description,
Alt,
CoverLetterAsRoot,
SecondTagsFirstWithReply,
NoTRootTag,
}
#[rstest]
#[case::commit_and_commit_r(PatchCase::CommitAndCommitR)]
#[case::parent_commit(PatchCase::ParentCommit)]
#[case::root_commit_as_r(PatchCase::RootCommitAsR)]
#[case::description_with_commit_message(PatchCase::Description)]
#[case::alt(PatchCase::Alt)]
#[case::cover_letter_event_as_root(PatchCase::CoverLetterAsRoot)]
#[case::second_patch_tags_first_with_reply(PatchCase::SecondTagsFirstWithReply)]
#[case::no_t_root_tag(PatchCase::NoTRootTag)]
#[tokio::test]
async fn patch_tags(#[future] snapshot: Arc<Snapshot>, #[case] case: PatchCase) -> Result<()> {
let s = snapshot.await;
match case {
PatchCase::CommitAndCommitR => {
let r_tags = tag_values(&s.first_patch, "r");
assert!(
r_tags.iter().any(|v| v == &s.first_commit_oid),
"first patch missing `r <first-commit>`; r tags were {r_tags:?}",
);
assert_eq!(
tag_value(&s.first_patch, "commit").as_deref(),
Some(s.first_commit_oid.as_str()),
"first patch should carry `commit <first-commit>`",
);
}
PatchCase::ParentCommit => {
assert_eq!(
tag_value(&s.first_patch, "parent-commit").as_deref(),
Some(s.root_commit_oid.as_str()),
"first patch's `parent-commit` should be the main tip before branching",
);
}
PatchCase::RootCommitAsR => {
let r_tags = tag_values(&s.first_patch, "r");
assert!(
r_tags.iter().any(|v| v == &s.root_commit_oid),
"first patch missing `r <root-commit>`; r tags were {r_tags:?}",
);
}
PatchCase::Description => {
assert_eq!(
tag_value(&s.first_patch, "description").as_deref(),
Some(format!("add {FIRST_COMMIT_FILE}\n").as_str()),
"first patch's `description` should mirror the commit message",
);
}
PatchCase::Alt => {
assert_eq!(
tag_value(&s.first_patch, "alt").as_deref(),
Some(format!("git patch: add {FIRST_COMMIT_FILE}").as_str()),
"first patch `alt` text format changed",
);
}
PatchCase::CoverLetterAsRoot => {
let root_e = s
.first_patch
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("root")
})
.ok_or_else(|| {
anyhow!(
"first patch missing root-marker `e` tag (tags: {:?})",
s.first_patch.tags
)
})?;
assert_eq!(
root_e.as_slice().get(1).map(String::as_str),
Some(s.cover_letter.id.to_hex().as_str()),
"first patch's `e <root>` should point at the cover letter",
);
}
PatchCase::SecondTagsFirstWithReply => {
let reply_e = s
.second_patch
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("reply")
})
.ok_or_else(|| {
anyhow!(
"second patch missing reply-marker `e` tag (tags: {:?})",
s.second_patch.tags
)
})?;
assert_eq!(
reply_e.as_slice().get(1).map(String::as_str),
Some(s.first_patch.id.to_hex().as_str()),
"second patch's `e <reply>` should point at the first patch",
);
}
PatchCase::NoTRootTag => {
assert!(
!tag_values(&s.first_patch, "t").iter().any(|v| v == "root"),
"per-commit patches must not carry `t root`; that tag belongs to the cover letter",
);
}
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn patch_a_tag_for_each_maintainer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let coords = tag_values(&s.first_patch, "a");
for m in &s.maintainer_pubkeys {
let expected = expected_a_coord(*m, &s.identifier);
assert!(
coords.iter().any(|c| c == &expected),
"patch missing `a` tag for maintainer {m} (expected {expected:?}, got {coords:?})",
);
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn patch_p_tag_for_each_maintainer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let p_tags = tag_values(&s.first_patch, "p");
for m in &s.maintainer_pubkeys {
let hex = m.to_hex();
assert!(
p_tags.iter().any(|p| p == &hex),
"patch missing `p` tag for maintainer {m} (got {p_tags:?})",
);
}
Ok(())
}
#[rstest]
#[tokio::test]
async fn patch_commit_author(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let author = full_tag(&s.first_patch, "author")?;
assert_eq!(
&author[..3],
&["author", "ngit test", "ngit-test@example.invalid"],
"patch `author` tag should carry the harness's default identity",
);
if author.len() != 5 {
bail!("patch `author` tag should be 5 slots [author, name, email, ts, tz]; got {author:?}",);
}
author[3]
.parse::<i64>()
.with_context(|| format!("author timestamp slot is not an integer: {:?}", author[3]))?;
Ok(())
}
#[rstest]
#[tokio::test]
async fn patch_commit_committer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let committer = full_tag(&s.first_patch, "committer")?;
assert_eq!(
&committer[..3],
&["committer", "ngit test", "ngit-test@example.invalid"],
"patch `committer` tag should carry the harness's default identity",
);
if committer.len() != 5 {
bail!(
"patch `committer` tag should be 5 slots [committer, name, email, ts, tz]; got \
{committer:?}",
);
}
committer[3].parse::<i64>().with_context(|| {
format!(
"committer timestamp slot is not an integer: {:?}",
committer[3]
)
})?;
Ok(())
}