//! Purgatory Sync Test Helpers //! //! Provides utilities for testing purgatory sync functionality: //! - Git repository setup with deterministic commits //! - State event creation with specific OIDs //! - PR event creation referencing repositories //! - Purgatory state inspection helpers //! //! # nostr-sdk 0.43 API Notes //! - Use field access: `event.id`, `event.tags`, `event.tags.iter()` //! - Use `Tag::custom(TagKind::custom("name"), vec![...])` syntax //! - Use `EventBuilder::new(kind, content).tags(tags)` syntax use nostr_sdk::prelude::*; use std::path::Path; use std::process::Command; use std::time::Duration; // NOTE: Using rust-nostr Kind variants: // - Kind::RepoState.as_u16() -> Kind::RepoState (30618) // - Kind::GitPullRequest.as_u16() -> Kind::GitPullRequest (1618) /// Commit variants for deterministic test commits #[derive(Debug, Clone, Copy)] pub enum CommitVariant { /// State event test commit (for testing state sync) StateTest, /// PR event test commit (for testing PR sync) PrTest, /// Second commit for partial sync tests SecondCommit, } /// Create a git repository with a deterministic commit for testing. /// /// Creates a new git repository at the given path with a single commit. /// The commit is deterministic based on the variant for reproducible tests. /// /// # Arguments /// * `path` - Directory to create repository in /// * `variant` - Which deterministic commit to create /// /// # Returns /// The commit hash of the created commit pub fn create_test_repo_with_commit(path: &Path, variant: CommitVariant) -> Result { // Initialize git repo run_git(path, &["init", "--initial-branch=main"])?; // Configure git user for commits run_git(path, &["config", "user.email", "test@example.com"])?; run_git(path, &["config", "user.name", "Test User"])?; // Disable GPG signing for tests (prevents yubikey prompts) run_git(path, &["config", "commit.gpgsign", "false"])?; run_git(path, &["config", "tag.gpgsign", "false"])?; // Create a file based on variant let (filename, content) = match variant { CommitVariant::StateTest => ("state_test.txt", "State test content for purgatory sync"), CommitVariant::PrTest => ("pr_test.txt", "PR test content for purgatory sync"), CommitVariant::SecondCommit => ("second.txt", "Second commit content for partial sync"), }; std::fs::write(path.join(filename), content) .map_err(|e| format!("Failed to write test file: {}", e))?; // Add and commit run_git(path, &["add", "."])?; let commit_message = match variant { CommitVariant::StateTest => "State test commit", CommitVariant::PrTest => "PR test commit", CommitVariant::SecondCommit => "Second test commit", }; run_git(path, &["commit", "-m", commit_message])?; // Get the commit hash get_head_commit(path) } /// Add an additional commit to an existing repository. /// /// Useful for tests that need multiple commits (e.g., partial OID aggregation). /// /// # Arguments /// * `path` - Path to existing repository /// * `variant` - Which commit variant to add /// /// # Returns /// The commit hash of the new commit pub fn add_commit_to_repo(path: &Path, variant: CommitVariant) -> Result { let (filename, content) = match variant { CommitVariant::StateTest => ("state_test.txt", "Updated state test content"), CommitVariant::PrTest => ("pr_test.txt", "Updated PR test content"), CommitVariant::SecondCommit => ("second.txt", "Second commit content"), }; std::fs::write(path.join(filename), content) .map_err(|e| format!("Failed to write test file: {}", e))?; run_git(path, &["add", "."])?; let commit_message = match variant { CommitVariant::StateTest => "Updated state commit", CommitVariant::PrTest => "Updated PR commit", CommitVariant::SecondCommit => "Second commit", }; run_git(path, &["commit", "-m", commit_message])?; get_head_commit(path) } /// Create a branch at a specific commit. /// /// # Arguments /// * `path` - Path to repository /// * `branch_name` - Name of the branch to create /// * `commit_hash` - Commit hash to point the branch at (or None for HEAD) pub fn create_branch( path: &Path, branch_name: &str, commit_hash: Option<&str>, ) -> Result<(), String> { match commit_hash { Some(hash) => run_git(path, &["branch", branch_name, hash]), None => run_git(path, &["branch", branch_name]), } } /// Get the HEAD commit hash. fn get_head_commit(path: &Path) -> Result { let output = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(path) .output() .map_err(|e| format!("Failed to run git rev-parse: {}", e))?; if !output.status.success() { return Err(format!( "git rev-parse failed: {}", String::from_utf8_lossy(&output.stderr) )); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Run a git command in the specified directory. fn run_git(path: &Path, args: &[&str]) -> Result<(), String> { let output = Command::new("git") .args(args) .current_dir(path) .output() .map_err(|e| format!("Failed to run git {}: {}", args.join(" "), e))?; if !output.status.success() { return Err(format!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr) )); } Ok(()) } /// Create a state event (kind 30618) with specific branch/tag OIDs. /// /// Creates a properly formatted NIP-34 repository state event that can be /// sent to a relay. The event includes refs/heads/* and refs/tags/* tags /// for the specified branches and tags. /// /// # Arguments /// * `keys` - Keys for signing /// * `identifier` - Repository identifier (d-tag) /// * `branches` - Vec of (name, commit_hash) for branches /// * `tags` - Vec of (name, commit_hash) for tags /// * `clone_urls` - Clone URLs to include /// * `relay_urls` - Relay URLs to include /// /// # Returns /// * `Ok(Event)` - Signed state event ready to send /// * `Err(String)` - If signing fails pub fn create_state_event( keys: &Keys, identifier: &str, branches: &[(&str, &str)], tags: &[(&str, &str)], clone_urls: &[&str], relay_urls: &[&str], ) -> Result { let mut event_tags = vec![ // d-tag (identifier) Tag::custom(TagKind::d(), vec![identifier.to_string()]), ]; // Add clone URLs if !clone_urls.is_empty() { let urls: Vec = clone_urls.iter().map(|s| s.to_string()).collect(); event_tags.push(Tag::custom(TagKind::Clone, urls)); } // Add relay URLs if !relay_urls.is_empty() { let urls: Vec = relay_urls.iter().map(|s| s.to_string()).collect(); event_tags.push(Tag::custom(TagKind::Relays, urls)); } // Add branch refs (refs/heads/*) for (name, commit) in branches { let ref_name = format!("refs/heads/{}", name); event_tags.push(Tag::custom( TagKind::Custom(ref_name.into()), vec![commit.to_string()], )); } // Add tag refs (refs/tags/*) for (name, commit) in tags { let ref_name = format!("refs/tags/{}", name); event_tags.push(Tag::custom( TagKind::Custom(ref_name.into()), vec![commit.to_string()], )); } // Add HEAD pointing to main (if main exists) if branches.iter().any(|(name, _)| *name == "main") { event_tags.push(Tag::custom( TagKind::Custom("HEAD".into()), vec!["refs/heads/main".to_string()], )); } EventBuilder::new(Kind::RepoState, "") .tags(event_tags) .sign_with_keys(keys) .map_err(|e| format!("Failed to sign state event: {}", e)) } /// Create a PR event (kind 1618) referencing a repository and commit. /// /// Creates a properly formatted NIP-34 PR event that references a repository /// via an `a` tag and includes the commit hash via a `c` tag. /// /// # Arguments /// * `keys` - Keys for signing /// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier") /// * `commit_hash` - The commit hash (c-tag) /// * `title` - PR title (used as content) /// /// # Returns /// * `Ok(Event)` - Signed PR event ready to send /// * `Err(String)` - If signing fails pub fn create_pr_event( keys: &Keys, repo_coord: &str, commit_hash: &str, title: &str, ) -> Result { let tags = vec![ // a-tag referencing the repository Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]), // c-tag with the commit hash Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]), ]; EventBuilder::new(Kind::GitPullRequest, title) .tags(tags) .sign_with_keys(keys) .map_err(|e| format!("Failed to sign PR event: {}", e)) } /// Create a PR event (kind 1618) with clone URLs. /// /// Creates a properly formatted NIP-34 PR event that references a repository /// via an `a` tag, includes the commit hash via a `c` tag, and specifies /// clone URLs where the PR commit can be fetched from. /// /// Per NIP-34, PR events can include a `clone` tag: /// ```jsonc /// { /// "kind": 1618, /// "tags": [ /// ["c", ""], /// ["clone", "", ...], // at least one git clone url where commit can be downloaded /// // ... /// ] /// } /// ``` /// /// # Arguments /// * `keys` - Keys for signing /// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier") /// * `commit_hash` - The commit hash (c-tag) /// * `title` - PR title (used as content) /// * `clone_urls` - Clone URLs where the PR commit can be fetched /// /// # Returns /// * `Ok(Event)` - Signed PR event ready to send /// * `Err(String)` - If signing fails pub fn create_pr_event_with_clone( keys: &Keys, repo_coord: &str, commit_hash: &str, title: &str, clone_urls: &[&str], ) -> Result { let mut tags = vec![ // a-tag referencing the repository Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]), // c-tag with the commit hash Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]), ]; // Add clone URLs if provided if !clone_urls.is_empty() { let urls: Vec = clone_urls.iter().map(|s| s.to_string()).collect(); tags.push(Tag::custom(TagKind::Clone, urls)); } EventBuilder::new(Kind::GitPullRequest, title) .tags(tags) .sign_with_keys(keys) .map_err(|e| format!("Failed to sign PR event: {}", e)) } /// Build a repository coordinate string for use in 'a' tags. /// /// Format: `30617:pubkey_hex:identifier` /// /// # Arguments /// * `keys` - Keys whose public key will be used /// * `identifier` - Repository identifier (d-tag value) pub fn build_repo_coord(keys: &Keys, identifier: &str) -> String { format!("30617:{}:{}", keys.public_key().to_hex(), identifier) } /// Create a repository announcement event (kind 30617) for purgatory tests. /// /// Creates a minimal but valid NIP-34 repository announcement with a `d` tag, /// optional `clone` URLs, and optional `relays` URLs. /// /// # Arguments /// * `keys` - Keys for signing /// * `identifier` - Repository identifier (d-tag) /// * `clone_urls` - Clone URLs to include (may be empty) /// * `relay_urls` - Relay URLs to include (may be empty) /// /// # Returns /// * `Ok(Event)` - Signed announcement event /// * `Err(String)` - If signing fails pub fn create_announcement_event( keys: &Keys, identifier: &str, clone_urls: &[&str], relay_urls: &[&str], ) -> Result { let mut tags = vec![Tag::identifier(identifier)]; if !clone_urls.is_empty() { let urls: Vec = clone_urls.iter().map(|s| s.to_string()).collect(); tags.push(Tag::custom(TagKind::custom("clone"), urls)); } if !relay_urls.is_empty() { let urls: Vec = relay_urls.iter().map(|s| s.to_string()).collect(); tags.push(Tag::custom(TagKind::custom("relays"), urls)); } EventBuilder::new(Kind::GitRepoAnnouncement, "") .tags(tags) .sign_with_keys(keys) .map_err(|e| format!("Failed to sign announcement event: {}", e)) } /// Wait for an event to be served by a relay (not in purgatory). /// /// Polls the relay until the event is queryable, indicating it has /// been released from purgatory. Uses exponential backoff for polling. /// /// # Arguments /// * `relay_url` - WebSocket URL of the relay /// * `event_id` - Event ID to wait for /// * `timeout` - Maximum time to wait /// /// # Returns /// * `Ok(Event)` - The event was found /// * `Err(String)` - Timeout or error pub async fn wait_for_event_served( relay_url: &str, event_id: &EventId, timeout: Duration, ) -> Result { let temp_keys = Keys::generate(); let client = Client::new(temp_keys); client .add_relay(relay_url) .await .map_err(|e| format!("Failed to add relay: {}", e))?; client.connect().await; // Wait for connection let mut connected = false; for _ in 0..20 { tokio::time::sleep(Duration::from_millis(100)).await; let relays = client.relays().await; if relays.values().any(|r| r.is_connected()) { connected = true; break; } } if !connected { client.disconnect().await; return Err("Failed to connect to relay".to_string()); } // Poll for the event with exponential backoff let start = std::time::Instant::now(); let mut poll_interval = Duration::from_millis(100); let max_interval = Duration::from_secs(2); while start.elapsed() < timeout { let filter = Filter::new().id(*event_id); match client.fetch_events(filter, Duration::from_secs(2)).await { Ok(events) => { if let Some(event) = events.into_iter().next() { client.disconnect().await; return Ok(event); } } Err(_) => { // Ignore fetch errors, will retry } } tokio::time::sleep(poll_interval).await; poll_interval = std::cmp::min(poll_interval * 2, max_interval); } client.disconnect().await; Err(format!( "Timeout waiting for event {} after {:?}", event_id, timeout )) } /// Wait for an event to NOT be served by a relay (still in purgatory). /// /// Polls the relay and verifies the event is NOT returned, indicating /// it is still in purgatory. /// /// # Arguments /// * `relay_url` - WebSocket URL of the relay /// * `event_id` - Event ID to check /// * `check_duration` - How long to verify the event stays absent /// /// # Returns /// * `Ok(())` - Event is not served (in purgatory) /// * `Err(String)` - Event was found (not in purgatory) or error pub async fn verify_event_not_served( relay_url: &str, event_id: &EventId, check_duration: Duration, ) -> Result<(), String> { let temp_keys = Keys::generate(); let client = Client::new(temp_keys); client .add_relay(relay_url) .await .map_err(|e| format!("Failed to add relay: {}", e))?; client.connect().await; // Wait for connection let mut connected = false; for _ in 0..20 { tokio::time::sleep(Duration::from_millis(100)).await; let relays = client.relays().await; if relays.values().any(|r| r.is_connected()) { connected = true; break; } } if !connected { client.disconnect().await; return Err("Failed to connect to relay".to_string()); } // Check that event is NOT served let filter = Filter::new().id(*event_id); match client.fetch_events(filter, check_duration).await { Ok(events) => { client.disconnect().await; if events.is_empty() { Ok(()) } else { Err(format!( "Event {} was served (expected to be in purgatory)", event_id )) } } Err(e) => { client.disconnect().await; // Fetch error could mean timeout (expected) or actual error // For our purposes, if we couldn't find it, that's success tracing::debug!("Fetch returned error (expected for purgatory check): {}", e); Ok(()) } } } /// Check if a ref exists at a specific commit on a relay's git endpoint. /// /// Uses git ls-remote to check the remote refs without cloning. /// /// # Arguments /// * `relay_domain` - The relay domain (e.g., "127.0.0.1:8080") /// * `npub` - Owner's npub /// * `repo_id` - Repository identifier /// * `ref_name` - Ref to check (e.g., "refs/heads/main") /// * `expected_commit` - Expected commit hash /// /// # Returns /// * `Ok(true)` - Ref exists and points to expected commit /// * `Ok(false)` - Ref doesn't exist or points to different commit /// * `Err(String)` - Error checking ref pub async fn check_ref_at_commit( relay_domain: &str, npub: &str, repo_id: &str, ref_name: &str, expected_commit: &str, ) -> Result { let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); let output = Command::new("git") .args(["ls-remote", &remote_url, ref_name]) .output() .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; if !output.status.success() { // ls-remote can fail if repo doesn't exist yet, which is expected in some tests return Ok(false); } let stdout = String::from_utf8_lossy(&output.stdout); // Parse output: "\t" for line in stdout.lines() { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() >= 2 && parts[1] == ref_name { // Compare commit hashes (handle both full and short hashes) let remote_commit = parts[0]; return Ok(remote_commit.starts_with(expected_commit) || expected_commit.starts_with(remote_commit)); } } Ok(false) } /// Push a local repository to a relay. /// /// Adds the relay as a remote and pushes all refs. /// /// # Arguments /// * `local_path` - Path to local git repository /// * `relay_domain` - The relay domain (e.g., "127.0.0.1:8080") /// * `npub` - Owner's npub /// * `repo_id` - Repository identifier /// /// # Returns /// * `Ok(())` - Push successful /// * `Err(String)` - Push failed pub fn push_to_relay( local_path: &Path, relay_domain: &str, npub: &str, repo_id: &str, ) -> Result<(), String> { let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); // Check if origin already exists let check_output = Command::new("git") .args(["remote", "get-url", "origin"]) .current_dir(local_path) .output() .map_err(|e| format!("Failed to check remote: {}", e))?; if check_output.status.success() { // Remote exists, update it run_git(local_path, &["remote", "set-url", "origin", &remote_url])?; } else { // Add new remote run_git(local_path, &["remote", "add", "origin", &remote_url])?; } // Push all refs let output = Command::new("git") .args(["push", "-u", "origin", "--all"]) .current_dir(local_path) .output() .map_err(|e| format!("Failed to run git push: {}", e))?; if !output.status.success() { return Err(format!( "git push failed: {}", String::from_utf8_lossy(&output.stderr) )); } Ok(()) } /// Push a specific ref to a relay. /// /// This is used for pushing to refs/nostr/ for PR events. /// Unlike `push_to_relay` which pushes all refs, this pushes a specific /// commit to a specific ref name. /// /// # Arguments /// * `local_path` - Path to local git repository /// * `relay_domain` - The relay domain (e.g., "127.0.0.1:8080") /// * `npub` - Owner's npub /// * `repo_id` - Repository identifier /// * `commit_hash` - The commit to push /// * `ref_name` - The ref name to push to (e.g., "refs/nostr/") /// /// # Returns /// * `Ok(())` - Push successful /// * `Err(String)` - Push failed pub fn push_ref_to_relay( local_path: &Path, relay_domain: &str, npub: &str, repo_id: &str, commit_hash: &str, ref_name: &str, ) -> Result<(), String> { let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); // Check if origin already exists let check_output = Command::new("git") .args(["remote", "get-url", "origin"]) .current_dir(local_path) .output() .map_err(|e| format!("Failed to check remote: {}", e))?; if check_output.status.success() { // Remote exists, update it let _ = Command::new("git") .args(["remote", "set-url", "origin", &remote_url]) .current_dir(local_path) .output(); } else { // Add new remote let _ = Command::new("git") .args(["remote", "add", "origin", &remote_url]) .current_dir(local_path) .output(); } // Push specific commit to specific ref // Format: git push origin : let refspec = format!("{}:{}", commit_hash, ref_name); let output = Command::new("git") .args(["push", "origin", &refspec]) .current_dir(local_path) .output() .map_err(|e| format!("Failed to run git push: {}", e))?; if !output.status.success() { return Err(format!( "git push failed: {}", String::from_utf8_lossy(&output.stderr) )); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_test_repo_with_commit() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Verify commit hash is a valid git hash (40 hex chars) assert_eq!(commit_hash.len(), 40); assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); // Verify the file was created assert!(temp_dir.path().join("state_test.txt").exists()); } #[test] fn test_add_commit_to_repo() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); // Create initial repo let first_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Add second commit let second_commit = add_commit_to_repo(temp_dir.path(), CommitVariant::SecondCommit) .expect("Failed to add commit"); // Commits should be different assert_ne!(first_commit, second_commit); // Both files should exist assert!(temp_dir.path().join("state_test.txt").exists()); assert!(temp_dir.path().join("second.txt").exists()); } #[test] fn test_create_state_event_has_correct_tags() { let keys = Keys::generate(); let event = create_state_event( &keys, "test-repo", &[("main", "abc123def456")], &[("v1.0", "def456abc123")], &["http://example.com/test.git"], &["ws://example.com"], ) .expect("Failed to create state event"); assert_eq!(event.kind.as_u16(), Kind::RepoState.as_u16()); // Check d-tag let has_d_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "d") && slice.get(1).is_some_and(|v| v == "test-repo") }); assert!(has_d_tag, "Event should have 'd' tag with identifier"); // Check refs/heads/main tag let has_branch_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "refs/heads/main") && slice.get(1).is_some_and(|v| v == "abc123def456") }); assert!(has_branch_tag, "Event should have refs/heads/main tag"); // Check refs/tags/v1.0 tag let has_tag_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "refs/tags/v1.0") && slice.get(1).is_some_and(|v| v == "def456abc123") }); assert!(has_tag_tag, "Event should have refs/tags/v1.0 tag"); // Check HEAD tag let has_head_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "HEAD") && slice.get(1).is_some_and(|v| v == "refs/heads/main") }); assert!(has_head_tag, "Event should have HEAD tag"); } #[test] fn test_create_pr_event_has_correct_tags() { let keys = Keys::generate(); let repo_coord = build_repo_coord(&keys, "test-repo"); let event = create_pr_event(&keys, &repo_coord, "def456abc123", "Test PR") .expect("Failed to create PR event"); assert_eq!(event.kind.as_u16(), Kind::GitPullRequest.as_u16()); // Check a-tag let has_a_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord) }); assert!(has_a_tag, "Event should have 'a' tag"); // Check c-tag let has_c_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "c") && slice.get(1).is_some_and(|v| v == "def456abc123") }); assert!(has_c_tag, "Event should have 'c' tag with commit"); } #[test] fn test_build_repo_coord_format() { let keys = Keys::generate(); let coord = build_repo_coord(&keys, "my-repo"); assert!(coord.starts_with("30617:")); assert!(coord.ends_with(":my-repo")); assert_eq!(coord.split(':').count(), 3); } #[test] fn test_create_branch() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); // Create initial repo let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Create a branch at HEAD create_branch(temp_dir.path(), "feature", None).expect("Failed to create branch"); // Verify branch exists let output = Command::new("git") .args(["rev-parse", "feature"]) .current_dir(temp_dir.path()) .output() .expect("Failed to run git rev-parse"); assert!(output.status.success()); let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(branch_commit, commit_hash); } #[test] fn test_create_pr_event_with_clone_has_correct_tags() { let keys = Keys::generate(); let repo_coord = build_repo_coord(&keys, "test-repo"); let event = create_pr_event_with_clone( &keys, &repo_coord, "abc123def456", "Test PR with clone", &[ "http://fork-server.com/repo.git", "http://another-server.com/repo.git", ], ) .expect("Failed to create PR event with clone"); assert_eq!(event.kind.as_u16(), Kind::GitPullRequest.as_u16()); // Check a-tag let has_a_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord) }); assert!(has_a_tag, "Event should have 'a' tag"); // Check c-tag let has_c_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "c") && slice.get(1).is_some_and(|v| v == "abc123def456") }); assert!(has_c_tag, "Event should have 'c' tag with commit"); // Check clone tag with both URLs let has_clone_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "clone") && slice .get(1) .is_some_and(|v| v == "http://fork-server.com/repo.git") && slice .get(2) .is_some_and(|v| v == "http://another-server.com/repo.git") }); assert!(has_clone_tag, "Event should have 'clone' tag with URLs"); } #[test] fn test_create_pr_event_with_clone_empty_urls() { let keys = Keys::generate(); let repo_coord = build_repo_coord(&keys, "test-repo"); let event = create_pr_event_with_clone( &keys, &repo_coord, "abc123def456", "Test PR without clone URLs", &[], // Empty clone URLs ) .expect("Failed to create PR event"); // Should not have clone tag when no URLs provided let has_clone_tag = event.tags.iter().any(|tag| { let slice = tag.as_slice(); slice.first().is_some_and(|t| t == "clone") }); assert!( !has_clone_tag, "Event should not have 'clone' tag when no URLs provided" ); } }