From 09bb21462ac5571cace5a7e71103156772a499fe Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:19 +0000 Subject: feat: update ngit init for non-interactive mode Complete rewrite of ngit init to support non-interactive mode by default. Key changes: - Implement hybrid validation (validate all args upfront, fail fast) - Add --grasp-servers flag for specifying git servers - Prefer --name over --identifier for better UX - Add comprehensive validation with helpful error messages - Support both clone and init-from-existing-repo workflows - Add --force flag to bypass safety checks - Update tests for new non-interactive behavior - Add test utilities for non-interactive testing --- tests/ngit_init.rs | 1419 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 1045 insertions(+), 374 deletions(-) (limited to 'tests/ngit_init.rs') diff --git a/tests/ngit_init.rs b/tests/ngit_init.rs index f6b30ef..5483315 100644 --- a/tests/ngit_init.rs +++ b/tests/ngit_init.rs @@ -1,77 +1,123 @@ use anyhow::Result; +use nostr::Event; use nostr_sdk::Kind; use rstest::*; use serial_test::serial; use test_utils::{git::GitTestRepo, *}; -fn expect_msgs_first(p: &mut CliTester) -> Result<()> { - p.expect("searching for profile...\r\n")?; - p.expect("logged in as fred via cli arguments\r\n")?; - // // p.expect("searching for existing claims on repository...\r\n")?; - p.expect("publishing repostory announcement to nostr...\r\n")?; - Ok(()) +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extract the GitRepoAnnouncement event from a relay's collected events. +fn get_announcement(events: &[Event]) -> &Event { + events + .iter() + .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) + .expect("GitRepoAnnouncement event not found") +} + +/// Get the first value of a single-value tag (e.g. "d", "name", "description"). +fn get_tag_value<'a>(event: &'a Event, tag_name: &str) -> &'a str { + event + .tags + .iter() + .find(|t| t.as_slice()[0] == tag_name) + .map(|t| t.as_slice()[1].as_str()) + .unwrap_or_else(|| panic!("tag '{tag_name}' not found")) } -fn get_cli_args() -> Vec<&'static str> { - vec![ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "--disable-cli-spinners", - "init", - "--title", - "example-name", - "--identifier", - "example-identifier", - "--description", - "example-description", - "--web", - "https://exampleproject.xyz", - "https://gitworkshop.dev/123", - "--relays", - "ws://localhost:8055", - "ws://localhost:8056", - "--clone-url", - "https://git.myhosting.com/my-repo.git", - "--earliest-unique-commit", - "9ee507fc4357d7ee16a5d8901bedcd103f23c17d", - "--other-maintainers", - TEST_KEY_1_NPUB, - ] +/// Get all values of a multi-value tag (e.g. "relays", "web", "maintainers", +/// "clone"). Returns slice starting from index 1 (skipping the tag name). +fn get_tag_values(event: &Event, tag_name: &str) -> Vec { + event + .tags + .iter() + .find(|t| t.as_slice()[0] == tag_name) + .map(|t| t.as_slice()[1..].iter().map(|s| s.to_string()).collect()) + .unwrap_or_default() } -mod when_repo_not_previously_claimed { +// --------------------------------------------------------------------------- +// State A: Fresh (no coordinate) +// --------------------------------------------------------------------------- + +mod state_a_fresh { use super::*; - mod when_repo_relays_specified_as_arguments { - use futures::join; - use test_utils::relay::Relay; + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } + mod errors { use super::*; - fn prep_git_repo() -> Result { - let test_repo = GitTestRepo::without_repo_in_git_config(); - test_repo.populate()?; - test_repo.add_remote("origin", "https://localhost:1000")?; - Ok(test_repo) + #[test] + #[serial] + fn bare_no_flags() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing required fields")?; + p.expect_eventually("--name ")?; + p.expect_eventually("--grasp-servers")?; + Ok(()) } - fn cli_tester_init(git_repo: &GitTestRepo) -> CliTester { - CliTester::new_from_dir(&git_repo.dir, get_cli_args()) + #[test] + #[serial] + fn name_only_missing_server_infra() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--name", + "My Project", + ]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing --grasp-servers")?; + Ok(()) } - async fn prep_run_init() -> Result<( - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - )> { + #[test] + #[serial] + fn relays_only_missing_name_and_servers() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--relays", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing required fields")?; + p.expect_eventually("--name ")?; + p.expect_eventually("--grasp-servers")?; + Ok(()) + } + } + + mod success { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + async fn run_init_with_grasp_server( + extra_args: Vec<&str>, + ) -> Result<(nostr::Event, GitTestRepo)> { let git_repo = prep_git_repo()?; - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( + let (mut r51, mut r52, mut r53, mut r55) = ( Relay::new( 8051, None, @@ -90,286 +136,599 @@ mod when_repo_not_previously_claimed { Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), ); - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); - p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = + extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result<()> { + let mut args = + vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = + extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55] { + relay::shutdown_relay(8000 + port)?; + } + Ok(()) } - Ok(()) }); - // launch relay let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; - Ok((r51, r52, r53, r55, r56, r57)) - } - mod sent_to_correct_relays { + let event = get_announcement(&r53.events).clone(); + Ok((event, git_repo)) + } + mod with_name_and_grasp_server { use super::*; - #[derive(Clone)] - pub struct SentToCorrectRelaysScenario { - pub r51_repo_event_count: usize, - pub r52_repo_event_count: usize, - pub r53_repo_event_count: usize, - pub r55_repo_event_count: usize, - pub r56_repo_event_count: usize, - pub r57_repo_event_count: usize, + #[fixture] + async fn scenario() -> (nostr::Event, GitTestRepo) { + run_init_with_grasp_server(vec![ + "--name", + "My Project", + "--grasp-servers", + "ws://localhost:8055", + ]) + .await + .expect("init failed") } - #[fixture] - async fn scenario() -> SentToCorrectRelaysScenario { - let (r51, r52, r53, r55, r56, r57) = - prep_run_init().await.expect("prep_run_init failed"); + #[rstest] + #[tokio::test] + #[serial] + async fn identifier_derived_from_name( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "d"), "My-Project"); + Ok(()) + } - // Extract event counts for verification - let r51_repo_event_count = r51 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r52_repo_event_count = r52 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r53_repo_event_count = r53 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r55_repo_event_count = r55 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r56_repo_event_count = r56 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r57_repo_event_count = r57 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - - SentToCorrectRelaysScenario { - r51_repo_event_count, - r52_repo_event_count, - r53_repo_event_count, - r55_repo_event_count, - r56_repo_event_count, - r57_repo_event_count, - } + #[rstest] + #[tokio::test] + #[serial] + async fn name_tag_matches( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "My Project"); + Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_user_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn description_empty( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r53_repo_event_count, 1); - assert_eq!(s.r55_repo_event_count, 1); + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "description"), ""); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_derived_from_grasp_server( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + let clone_urls = get_tag_values(&event, "clone"); + assert_eq!(clone_urls.len(), 1); + assert!( + clone_urls[0].starts_with("http://localhost:8055/"), + "clone url should start with grasp server: {}", + clone_urls[0] + ); + assert!( + clone_urls[0].ends_with("/My-Project.git"), + "clone url should end with identifier.git: {}", + clone_urls[0] + ); + assert!( + clone_urls[0].contains(TEST_KEY_1_NPUB), + "clone url should contain npub: {}", + clone_urls[0] + ); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_specified_repo_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn relays_include_grasp_derived( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r55_repo_event_count, 1); - assert_eq!(s.r56_repo_event_count, 1); + let (event, _) = scenario.await; + let relays = get_tag_values(&event, "relays"); + assert!( + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include grasp-derived relay: {:?}", + relays + ); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_fallback_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn maintainers_is_just_me( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r51_repo_event_count, 1); - assert_eq!(s.r52_repo_event_count, 1); + let (event, _) = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!(maintainers.len(), 1); + assert_eq!(maintainers[0], TEST_KEY_1_KEYS.public_key().to_string()); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_blaster_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn earliest_unique_commit_is_root( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r57_repo_event_count, 1); + let (event, _) = scenario.await; + let euc_tag = event + .tags + .iter() + .find(|t| { + t.as_slice()[0] == "r" && t.as_slice().len() > 2 && t.as_slice()[2] == "euc" + }) + .expect("euc tag not found"); + assert_eq!( + euc_tag.as_slice()[1], + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d" + ); Ok(()) } } + } +} - mod git_config_updated { +// --------------------------------------------------------------------------- +// State B: Coordinate exists, no announcement found +// --------------------------------------------------------------------------- - use nostr::nips::{nip01::Coordinate, nip19::Nip19Coordinate}; - use nostr_sdk::ToBech32; +mod state_b_coordinate_only { + use super::*; - use super::*; + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } - async fn async_run_test() -> Result<()> { - let git_repo = prep_git_repo()?; - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), - ); + mod errors { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = + extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result { + let mut args = + vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = + extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap() + } - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); + #[tokio::test] + #[serial] + async fn bare_no_flags() -> Result<()> { + let output = run_init_expecting_error(vec![]).await?; + assert!( + output.contains("no announcement found for coordinate"), + "expected coordinate error, got: {output}" + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn defaults_still_requires_force() -> Result<()> { + let output = run_init_expecting_error(vec!["--defaults"]).await?; + assert!( + output.contains("no announcement found for coordinate"), + "expected coordinate error even with -d, got: {output}" + ); + Ok(()) + } + } + + mod success { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + #[fixture] + async fn state_b_force() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--force", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; } - assert_eq!( - git_repo - .git_repo - .config()? - .get_entry("nostr.repo")? - .value() - .unwrap(), - Nip19Coordinate { - coordinate: Coordinate { - kind: nostr_sdk::Kind::GitRepoAnnouncement, - identifier: "example-identifier".to_string(), - public_key: TEST_KEY_1_KEYS.public_key(), - }, - relays: vec![], - } - .to_bech32()?, - ); + Ok(()) + } + }); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn identifier_from_coordinate(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + assert_eq!( + get_tag_value(&event, "d"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_defaults_to_identifier(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + assert_eq!( + get_tag_value(&event, "name"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_from_grasp_server(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + let clone_urls = get_tag_values(&event, "clone"); + assert!( + clone_urls + .iter() + .any(|u| u.starts_with("http://localhost:8055/")), + "expected grasp-derived clone url, got: {:?}", + clone_urls + ); + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// State C: Existing announcement, it's mine +// --------------------------------------------------------------------------- + +mod state_c_my_announcement { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } + + async fn run_init(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - #[tokio::test] - #[serial] - async fn with_nostr_repo_set_to_user_and_identifer_naddr() -> Result<()> { - async_run_test().await?; + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result<()> { + let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } Ok(()) } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + + Ok(get_announcement(&r53.events).clone()) + } + + mod errors { + use super::*; + + #[tokio::test] + #[serial] + async fn identifier_change_requires_force() -> Result<()> { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--identifier", + "new-id", + ]; + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let output = cli_tester_handle.join().unwrap()?; + assert!( + output.contains("changing identifier creates a new repository"), + "expected identifier change error, got: {output}" + ); + Ok(()) } - mod tags_as_specified_in_args { - use super::*; + #[tokio::test] + #[serial] + async fn bare_no_flags_requires_force() -> Result<()> { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - #[derive(Clone)] - pub struct TagsAsSpecifiedScenario { - pub event: nostr::Event, - } + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result { + let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); - #[fixture] - async fn scenario() -> TagsAsSpecifiedScenario { - let (_, _, r53, _r55, _r56, _r57) = - prep_run_init().await.expect("prep_run_init failed"); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let output = cli_tester_handle.join().unwrap()?; + assert!( + output.contains("no arguments specified"), + "expected 'no arguments specified' error, got: {output}" + ); + Ok(()) + } + } - // Extract the GitRepoAnnouncement event (should be same on all relays) - let event = r53 - .events - .iter() - .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .expect("GitRepoAnnouncement event not found") - .clone(); + mod success { + use super::*; + + mod force_refresh { + use super::*; - TagsAsSpecifiedScenario { event } + #[fixture] + async fn scenario() -> nostr::Event { + run_init(vec!["--force"]).await.expect("init failed") } #[rstest] #[tokio::test] #[serial] - async fn d_replaceable_event_identifier( - #[future] scenario: TagsAsSpecifiedScenario, - ) -> Result<()> { - let s = scenario.await; - assert!( - s.event.tags.iter().any( - |t| t.as_slice()[0].eq("d") && t.as_slice()[1].eq("example-identifier") - ) - ); + async fn name_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn earliest_unique_commit_as_reference_with_euc_marker( - #[future] scenario: TagsAsSpecifiedScenario, - ) -> Result<()> { - let s = scenario.await; - assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("r") - && t.as_slice()[1].eq("9ee507fc4357d7ee16a5d8901bedcd103f23c17d") - && t.as_slice()[2].eq("euc"))); + async fn description_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn name(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; + async fn relays_from_my_event(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let relays = get_tag_values(&event, "relays"); assert!( - s.event - .tags - .iter() - .any(|t| t.as_slice()[0].eq("name") && t.as_slice()[1].eq("example-name")) + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include my existing relay: {:?}", + relays ); Ok(()) } @@ -377,160 +736,472 @@ mod when_repo_not_previously_claimed { #[rstest] #[tokio::test] #[serial] - async fn alt(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("alt") - && t.as_slice()[1].eq("git repository: example-name"))); - Ok(()) - } - - #[rstest] - #[tokio::test] - #[serial] - async fn description(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; + async fn maintainers_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); assert!( - s.event - .tags - .iter() - .any(|t| t.as_slice()[0].eq("description") - && t.as_slice()[1].eq("example-description")) + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1: {:?}", + maintainers ); - Ok(()) - } - - #[rstest] - #[tokio::test] - #[serial] - async fn git_server(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; assert!( - s.event.tags.iter().any(|t| t.as_slice()[0].eq("clone") - && t.as_slice()[1].eq("https://git.myhosting.com/my-repo.git")) /* todo check it defaults to origin */ + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2: {:?}", + maintainers ); Ok(()) } + } - #[rstest] - #[tokio::test] - #[serial] - async fn relays(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let relays_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("relays")) - .unwrap() - .as_slice(); - assert_eq!(relays_tag[1], "ws://localhost:8055",); - assert_eq!(relays_tag[2], "ws://localhost:8056",); - Ok(()) + mod name_override { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + run_init(vec!["--name", "New Name"]) + .await + .expect("init failed") } #[rstest] #[tokio::test] #[serial] - async fn web(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let web_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("web")) - .unwrap() - .as_slice(); - assert_eq!(web_tag[1], "https://exampleproject.xyz",); - assert_eq!(web_tag[2], "https://gitworkshop.dev/123",); + async fn name_overridden(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "New Name"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn maintainers(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let maintainers_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("maintainers")) - .unwrap() - .as_slice(); - assert_eq!(maintainers_tag[1], TEST_KEY_1_KEYS.public_key().to_string()); + async fn identifier_unchanged(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!( + get_tag_value(&event, "d"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); Ok(()) } } + } +} - mod cli_ouput { - use super::*; +// --------------------------------------------------------------------------- +// State D: Existing announcement, not mine, I'm listed as maintainer +// --------------------------------------------------------------------------- - #[tokio::test] - #[serial] - async fn check_cli_output() -> Result<()> { - let git_repo = prep_git_repo()?; - - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), - ); +mod state_d_co_maintainer { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + test_repo.set_nostr_repo_coordinate( + &TEST_KEY_2_KEYS.public_key(), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", + &["ws://localhost:8055", "ws://localhost:8056"], + ); + Ok(test_repo) + } + + mod success { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_listing_key_1(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); - expect_msgs_first(&mut p)?; - relay::expect_send_with_progress( - &mut p, - vec![ - (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), - (" [my-relay] ws://localhost:8053", true, ""), - (" [repo-relay] ws://localhost:8056", true, ""), - (" [default] ws://localhost:8051", true, ""), - (" [default] ws://localhost:8052", true, ""), - (" [default] ws://localhost:8057", true, ""), + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(()) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn description_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn web_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let web = get_tag_values(&event, "web"); + assert!( + web.iter().any(|w| w.contains("exampleproject.xyz")), + "web should be inherited from KEY_2's announcement: {:?}", + web + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_from_my_grasp_server_not_theirs( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let clone_urls = get_tag_values(&event, "clone"); + assert!( + clone_urls + .iter() + .any(|u| u.starts_with("http://localhost:8055/")), + "clone url should be from my grasp server: {:?}", + clone_urls + ); + assert!( + !clone_urls.iter().any(|u| u.contains("123.gitexample.com")), + "clone url should NOT contain KEY_2's git server: {:?}", + clone_urls + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn relays_from_my_grasp_server(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let relays = get_tag_values(&event, "relays"); + assert!( + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include my grasp-derived relay: {:?}", + relays + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!( + maintainers.len(), + 2, + "should have exactly 2 maintainers: {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1 (me): {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2 (trusted): {:?}", + maintainers + ); + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// State E: Existing announcement, not mine, I'm NOT listed as maintainer +// --------------------------------------------------------------------------- + +mod state_e_not_listed { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + // Point coordinate to KEY_2 (not the logged-in user) + test_repo.set_nostr_repo_coordinate( + &TEST_KEY_2_KEYS.public_key(), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", + &["ws://localhost:8055", "ws://localhost:8056"], + ); + Ok(test_repo) + } + + /// Run init with relays that serve KEY_2's announcement NOT listing KEY_1. + async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_not_listing_key_1(), ], - 1, )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result { + let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap() + } + + mod errors { + use super::*; + + #[tokio::test] + #[serial] + async fn bare_no_flags() -> Result<()> { + let output = run_init_expecting_error(vec![]).await?; + assert!( + output.contains("you are not listed as a maintainer"), + "expected not-listed error, got: {output}" + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn defaults_still_requires_force() -> Result<()> { + let output = run_init_expecting_error(vec!["--defaults"]).await?; + assert!( + output.contains("you are not listed as a maintainer"), + "expected not-listed error even with -d, got: {output}" + ); + Ok(()) + } + } + + mod success { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_not_listing_key_1(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--force", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; } Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn description_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn web_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let web = get_tag_values(&event, "web"); + assert!( + web.iter().any(|w| w.contains("exampleproject.xyz")), + "web should be inherited from KEY_2's announcement: {:?}", + web + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!( + maintainers.len(), + 2, + "should have exactly 2 maintainers: {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1 (me): {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2 (trusted): {:?}", + maintainers + ); + Ok(()) } } - // TODO: cli caputuring input } -// TODO: when_updating_existing_repoistory correct defaults are used -- cgit v1.2.3