diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-07 14:54:37 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-07 14:54:37 +0000 |
| commit | bdb2a99fe7f146f7cce8e23b7f308abccddf22e2 (patch) | |
| tree | eecd450d94bb19656341ed2822d12a6b9aa6b801 /tests/common/purgatory_helpers.rs | |
| parent | 3dfec1e449f260295e8c5c505dd1edb82d787c58 (diff) | |
Add purgatory sync test helpers
Add tests/common/purgatory_helpers.rs with utilities for purgatory sync
integration tests:
- Git repository setup helpers (create_test_repo_with_commit,
add_commit_to_repo, create_branch) for deterministic test commits
- State event creation (create_state_event) for kind 30618 events with
refs/heads/*, refs/tags/*, and HEAD tags
- PR event creation (create_pr_event) for kind 1618 events with a and c tags
- Purgatory state inspection helpers (wait_for_event_served,
verify_event_not_served) for polling event availability
- Git ref verification (check_ref_at_commit) for validating remote refs
- Push helper (push_to_relay) for pushing local repos to relay
All helpers include comprehensive unit tests verifying correct tag
structure and git operations.
Diffstat (limited to 'tests/common/purgatory_helpers.rs')
| -rw-r--r-- | tests/common/purgatory_helpers.rs | 674 |
1 files changed, 674 insertions, 0 deletions
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs new file mode 100644 index 0000000..e61e2e2 --- /dev/null +++ b/tests/common/purgatory_helpers.rs | |||
| @@ -0,0 +1,674 @@ | |||
| 1 | //! Purgatory Sync Test Helpers | ||
| 2 | //! | ||
| 3 | //! Provides utilities for testing purgatory sync functionality: | ||
| 4 | //! - Git repository setup with deterministic commits | ||
| 5 | //! - State event creation with specific OIDs | ||
| 6 | //! - PR event creation referencing repositories | ||
| 7 | //! - Purgatory state inspection helpers | ||
| 8 | //! | ||
| 9 | //! # nostr-sdk 0.43 API Notes | ||
| 10 | //! - Use field access: `event.id`, `event.tags`, `event.tags.iter()` | ||
| 11 | //! - Use `Tag::custom(TagKind::custom("name"), vec![...])` syntax | ||
| 12 | //! - Use `EventBuilder::new(kind, content).tags(tags)` syntax | ||
| 13 | |||
| 14 | use nostr_sdk::prelude::*; | ||
| 15 | use std::path::Path; | ||
| 16 | use std::process::Command; | ||
| 17 | use std::time::Duration; | ||
| 18 | |||
| 19 | /// NIP-34 Repository State (kind 30618) | ||
| 20 | pub const KIND_STATE: u16 = 30618; | ||
| 21 | |||
| 22 | /// NIP-34 Pull Request (kind 1618) | ||
| 23 | pub const KIND_PR: u16 = 1618; | ||
| 24 | |||
| 25 | /// Commit variants for deterministic test commits | ||
| 26 | #[derive(Debug, Clone, Copy)] | ||
| 27 | pub enum CommitVariant { | ||
| 28 | /// State event test commit (for testing state sync) | ||
| 29 | StateTest, | ||
| 30 | /// PR event test commit (for testing PR sync) | ||
| 31 | PrTest, | ||
| 32 | /// Second commit for partial sync tests | ||
| 33 | SecondCommit, | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Create a git repository with a deterministic commit for testing. | ||
| 37 | /// | ||
| 38 | /// Creates a new git repository at the given path with a single commit. | ||
| 39 | /// The commit is deterministic based on the variant for reproducible tests. | ||
| 40 | /// | ||
| 41 | /// # Arguments | ||
| 42 | /// * `path` - Directory to create repository in | ||
| 43 | /// * `variant` - Which deterministic commit to create | ||
| 44 | /// | ||
| 45 | /// # Returns | ||
| 46 | /// The commit hash of the created commit | ||
| 47 | pub fn create_test_repo_with_commit(path: &Path, variant: CommitVariant) -> Result<String, String> { | ||
| 48 | // Initialize git repo | ||
| 49 | run_git(path, &["init", "--initial-branch=main"])?; | ||
| 50 | |||
| 51 | // Configure git user for commits | ||
| 52 | run_git(path, &["config", "user.email", "test@example.com"])?; | ||
| 53 | run_git(path, &["config", "user.name", "Test User"])?; | ||
| 54 | |||
| 55 | // Create a file based on variant | ||
| 56 | let (filename, content) = match variant { | ||
| 57 | CommitVariant::StateTest => ("state_test.txt", "State test content for purgatory sync"), | ||
| 58 | CommitVariant::PrTest => ("pr_test.txt", "PR test content for purgatory sync"), | ||
| 59 | CommitVariant::SecondCommit => ("second.txt", "Second commit content for partial sync"), | ||
| 60 | }; | ||
| 61 | |||
| 62 | std::fs::write(path.join(filename), content) | ||
| 63 | .map_err(|e| format!("Failed to write test file: {}", e))?; | ||
| 64 | |||
| 65 | // Add and commit | ||
| 66 | run_git(path, &["add", "."])?; | ||
| 67 | |||
| 68 | let commit_message = match variant { | ||
| 69 | CommitVariant::StateTest => "State test commit", | ||
| 70 | CommitVariant::PrTest => "PR test commit", | ||
| 71 | CommitVariant::SecondCommit => "Second test commit", | ||
| 72 | }; | ||
| 73 | |||
| 74 | run_git(path, &["commit", "-m", commit_message])?; | ||
| 75 | |||
| 76 | // Get the commit hash | ||
| 77 | get_head_commit(path) | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Add an additional commit to an existing repository. | ||
| 81 | /// | ||
| 82 | /// Useful for tests that need multiple commits (e.g., partial OID aggregation). | ||
| 83 | /// | ||
| 84 | /// # Arguments | ||
| 85 | /// * `path` - Path to existing repository | ||
| 86 | /// * `variant` - Which commit variant to add | ||
| 87 | /// | ||
| 88 | /// # Returns | ||
| 89 | /// The commit hash of the new commit | ||
| 90 | pub fn add_commit_to_repo(path: &Path, variant: CommitVariant) -> Result<String, String> { | ||
| 91 | let (filename, content) = match variant { | ||
| 92 | CommitVariant::StateTest => ("state_test.txt", "Updated state test content"), | ||
| 93 | CommitVariant::PrTest => ("pr_test.txt", "Updated PR test content"), | ||
| 94 | CommitVariant::SecondCommit => ("second.txt", "Second commit content"), | ||
| 95 | }; | ||
| 96 | |||
| 97 | std::fs::write(path.join(filename), content) | ||
| 98 | .map_err(|e| format!("Failed to write test file: {}", e))?; | ||
| 99 | |||
| 100 | run_git(path, &["add", "."])?; | ||
| 101 | |||
| 102 | let commit_message = match variant { | ||
| 103 | CommitVariant::StateTest => "Updated state commit", | ||
| 104 | CommitVariant::PrTest => "Updated PR commit", | ||
| 105 | CommitVariant::SecondCommit => "Second commit", | ||
| 106 | }; | ||
| 107 | |||
| 108 | run_git(path, &["commit", "-m", commit_message])?; | ||
| 109 | |||
| 110 | get_head_commit(path) | ||
| 111 | } | ||
| 112 | |||
| 113 | /// Create a branch at a specific commit. | ||
| 114 | /// | ||
| 115 | /// # Arguments | ||
| 116 | /// * `path` - Path to repository | ||
| 117 | /// * `branch_name` - Name of the branch to create | ||
| 118 | /// * `commit_hash` - Commit hash to point the branch at (or None for HEAD) | ||
| 119 | pub fn create_branch( | ||
| 120 | path: &Path, | ||
| 121 | branch_name: &str, | ||
| 122 | commit_hash: Option<&str>, | ||
| 123 | ) -> Result<(), String> { | ||
| 124 | match commit_hash { | ||
| 125 | Some(hash) => run_git(path, &["branch", branch_name, hash]), | ||
| 126 | None => run_git(path, &["branch", branch_name]), | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | /// Get the HEAD commit hash. | ||
| 131 | fn get_head_commit(path: &Path) -> Result<String, String> { | ||
| 132 | let output = Command::new("git") | ||
| 133 | .args(["rev-parse", "HEAD"]) | ||
| 134 | .current_dir(path) | ||
| 135 | .output() | ||
| 136 | .map_err(|e| format!("Failed to run git rev-parse: {}", e))?; | ||
| 137 | |||
| 138 | if !output.status.success() { | ||
| 139 | return Err(format!( | ||
| 140 | "git rev-parse failed: {}", | ||
| 141 | String::from_utf8_lossy(&output.stderr) | ||
| 142 | )); | ||
| 143 | } | ||
| 144 | |||
| 145 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) | ||
| 146 | } | ||
| 147 | |||
| 148 | /// Run a git command in the specified directory. | ||
| 149 | fn run_git(path: &Path, args: &[&str]) -> Result<(), String> { | ||
| 150 | let output = Command::new("git") | ||
| 151 | .args(args) | ||
| 152 | .current_dir(path) | ||
| 153 | .output() | ||
| 154 | .map_err(|e| format!("Failed to run git {}: {}", args.join(" "), e))?; | ||
| 155 | |||
| 156 | if !output.status.success() { | ||
| 157 | return Err(format!( | ||
| 158 | "git {} failed: {}", | ||
| 159 | args.join(" "), | ||
| 160 | String::from_utf8_lossy(&output.stderr) | ||
| 161 | )); | ||
| 162 | } | ||
| 163 | |||
| 164 | Ok(()) | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Create a state event (kind 30618) with specific branch/tag OIDs. | ||
| 168 | /// | ||
| 169 | /// Creates a properly formatted NIP-34 repository state event that can be | ||
| 170 | /// sent to a relay. The event includes refs/heads/* and refs/tags/* tags | ||
| 171 | /// for the specified branches and tags. | ||
| 172 | /// | ||
| 173 | /// # Arguments | ||
| 174 | /// * `keys` - Keys for signing | ||
| 175 | /// * `identifier` - Repository identifier (d-tag) | ||
| 176 | /// * `branches` - Vec of (name, commit_hash) for branches | ||
| 177 | /// * `tags` - Vec of (name, commit_hash) for tags | ||
| 178 | /// * `clone_urls` - Clone URLs to include | ||
| 179 | /// * `relay_urls` - Relay URLs to include | ||
| 180 | /// | ||
| 181 | /// # Returns | ||
| 182 | /// * `Ok(Event)` - Signed state event ready to send | ||
| 183 | /// * `Err(String)` - If signing fails | ||
| 184 | pub fn create_state_event( | ||
| 185 | keys: &Keys, | ||
| 186 | identifier: &str, | ||
| 187 | branches: &[(&str, &str)], | ||
| 188 | tags: &[(&str, &str)], | ||
| 189 | clone_urls: &[&str], | ||
| 190 | relay_urls: &[&str], | ||
| 191 | ) -> Result<Event, String> { | ||
| 192 | let mut event_tags = vec![ | ||
| 193 | // d-tag (identifier) | ||
| 194 | Tag::custom(TagKind::d(), vec![identifier.to_string()]), | ||
| 195 | ]; | ||
| 196 | |||
| 197 | // Add clone URLs | ||
| 198 | if !clone_urls.is_empty() { | ||
| 199 | let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect(); | ||
| 200 | event_tags.push(Tag::custom(TagKind::Clone, urls)); | ||
| 201 | } | ||
| 202 | |||
| 203 | // Add relay URLs | ||
| 204 | if !relay_urls.is_empty() { | ||
| 205 | let urls: Vec<String> = relay_urls.iter().map(|s| s.to_string()).collect(); | ||
| 206 | event_tags.push(Tag::custom(TagKind::Relays, urls)); | ||
| 207 | } | ||
| 208 | |||
| 209 | // Add branch refs (refs/heads/*) | ||
| 210 | for (name, commit) in branches { | ||
| 211 | let ref_name = format!("refs/heads/{}", name); | ||
| 212 | event_tags.push(Tag::custom( | ||
| 213 | TagKind::Custom(ref_name.into()), | ||
| 214 | vec![commit.to_string()], | ||
| 215 | )); | ||
| 216 | } | ||
| 217 | |||
| 218 | // Add tag refs (refs/tags/*) | ||
| 219 | for (name, commit) in tags { | ||
| 220 | let ref_name = format!("refs/tags/{}", name); | ||
| 221 | event_tags.push(Tag::custom( | ||
| 222 | TagKind::Custom(ref_name.into()), | ||
| 223 | vec![commit.to_string()], | ||
| 224 | )); | ||
| 225 | } | ||
| 226 | |||
| 227 | // Add HEAD pointing to main (if main exists) | ||
| 228 | if branches.iter().any(|(name, _)| *name == "main") { | ||
| 229 | event_tags.push(Tag::custom( | ||
| 230 | TagKind::Custom("HEAD".into()), | ||
| 231 | vec!["refs/heads/main".to_string()], | ||
| 232 | )); | ||
| 233 | } | ||
| 234 | |||
| 235 | EventBuilder::new(Kind::Custom(KIND_STATE), "") | ||
| 236 | .tags(event_tags) | ||
| 237 | .sign_with_keys(keys) | ||
| 238 | .map_err(|e| format!("Failed to sign state event: {}", e)) | ||
| 239 | } | ||
| 240 | |||
| 241 | /// Create a PR event (kind 1618) referencing a repository and commit. | ||
| 242 | /// | ||
| 243 | /// Creates a properly formatted NIP-34 PR event that references a repository | ||
| 244 | /// via an `a` tag and includes the commit hash via a `c` tag. | ||
| 245 | /// | ||
| 246 | /// # Arguments | ||
| 247 | /// * `keys` - Keys for signing | ||
| 248 | /// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier") | ||
| 249 | /// * `commit_hash` - The commit hash (c-tag) | ||
| 250 | /// * `title` - PR title (used as content) | ||
| 251 | /// | ||
| 252 | /// # Returns | ||
| 253 | /// * `Ok(Event)` - Signed PR event ready to send | ||
| 254 | /// * `Err(String)` - If signing fails | ||
| 255 | pub fn create_pr_event( | ||
| 256 | keys: &Keys, | ||
| 257 | repo_coord: &str, | ||
| 258 | commit_hash: &str, | ||
| 259 | title: &str, | ||
| 260 | ) -> Result<Event, String> { | ||
| 261 | let tags = vec![ | ||
| 262 | // a-tag referencing the repository | ||
| 263 | Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]), | ||
| 264 | // c-tag with the commit hash | ||
| 265 | Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]), | ||
| 266 | ]; | ||
| 267 | |||
| 268 | EventBuilder::new(Kind::Custom(KIND_PR), title) | ||
| 269 | .tags(tags) | ||
| 270 | .sign_with_keys(keys) | ||
| 271 | .map_err(|e| format!("Failed to sign PR event: {}", e)) | ||
| 272 | } | ||
| 273 | |||
| 274 | /// Build a repository coordinate string for use in 'a' tags. | ||
| 275 | /// | ||
| 276 | /// Format: `30617:pubkey_hex:identifier` | ||
| 277 | /// | ||
| 278 | /// # Arguments | ||
| 279 | /// * `keys` - Keys whose public key will be used | ||
| 280 | /// * `identifier` - Repository identifier (d-tag value) | ||
| 281 | pub fn build_repo_coord(keys: &Keys, identifier: &str) -> String { | ||
| 282 | format!("30617:{}:{}", keys.public_key().to_hex(), identifier) | ||
| 283 | } | ||
| 284 | |||
| 285 | /// Wait for an event to be served by a relay (not in purgatory). | ||
| 286 | /// | ||
| 287 | /// Polls the relay until the event is queryable, indicating it has | ||
| 288 | /// been released from purgatory. Uses exponential backoff for polling. | ||
| 289 | /// | ||
| 290 | /// # Arguments | ||
| 291 | /// * `relay_url` - WebSocket URL of the relay | ||
| 292 | /// * `event_id` - Event ID to wait for | ||
| 293 | /// * `timeout` - Maximum time to wait | ||
| 294 | /// | ||
| 295 | /// # Returns | ||
| 296 | /// * `Ok(Event)` - The event was found | ||
| 297 | /// * `Err(String)` - Timeout or error | ||
| 298 | pub async fn wait_for_event_served( | ||
| 299 | relay_url: &str, | ||
| 300 | event_id: &EventId, | ||
| 301 | timeout: Duration, | ||
| 302 | ) -> Result<Event, String> { | ||
| 303 | let temp_keys = Keys::generate(); | ||
| 304 | let client = Client::new(temp_keys); | ||
| 305 | |||
| 306 | client | ||
| 307 | .add_relay(relay_url) | ||
| 308 | .await | ||
| 309 | .map_err(|e| format!("Failed to add relay: {}", e))?; | ||
| 310 | |||
| 311 | client.connect().await; | ||
| 312 | |||
| 313 | // Wait for connection | ||
| 314 | let mut connected = false; | ||
| 315 | for _ in 0..20 { | ||
| 316 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 317 | let relays = client.relays().await; | ||
| 318 | if relays.values().any(|r| r.is_connected()) { | ||
| 319 | connected = true; | ||
| 320 | break; | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | if !connected { | ||
| 325 | client.disconnect().await; | ||
| 326 | return Err("Failed to connect to relay".to_string()); | ||
| 327 | } | ||
| 328 | |||
| 329 | // Poll for the event with exponential backoff | ||
| 330 | let start = std::time::Instant::now(); | ||
| 331 | let mut poll_interval = Duration::from_millis(100); | ||
| 332 | let max_interval = Duration::from_secs(2); | ||
| 333 | |||
| 334 | while start.elapsed() < timeout { | ||
| 335 | let filter = Filter::new().id(*event_id); | ||
| 336 | |||
| 337 | match client.fetch_events(filter, Duration::from_secs(2)).await { | ||
| 338 | Ok(events) => { | ||
| 339 | if let Some(event) = events.into_iter().next() { | ||
| 340 | client.disconnect().await; | ||
| 341 | return Ok(event); | ||
| 342 | } | ||
| 343 | } | ||
| 344 | Err(_) => { | ||
| 345 | // Ignore fetch errors, will retry | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | tokio::time::sleep(poll_interval).await; | ||
| 350 | poll_interval = std::cmp::min(poll_interval * 2, max_interval); | ||
| 351 | } | ||
| 352 | |||
| 353 | client.disconnect().await; | ||
| 354 | Err(format!( | ||
| 355 | "Timeout waiting for event {} after {:?}", | ||
| 356 | event_id, timeout | ||
| 357 | )) | ||
| 358 | } | ||
| 359 | |||
| 360 | /// Wait for an event to NOT be served by a relay (still in purgatory). | ||
| 361 | /// | ||
| 362 | /// Polls the relay and verifies the event is NOT returned, indicating | ||
| 363 | /// it is still in purgatory. | ||
| 364 | /// | ||
| 365 | /// # Arguments | ||
| 366 | /// * `relay_url` - WebSocket URL of the relay | ||
| 367 | /// * `event_id` - Event ID to check | ||
| 368 | /// * `check_duration` - How long to verify the event stays absent | ||
| 369 | /// | ||
| 370 | /// # Returns | ||
| 371 | /// * `Ok(())` - Event is not served (in purgatory) | ||
| 372 | /// * `Err(String)` - Event was found (not in purgatory) or error | ||
| 373 | pub async fn verify_event_not_served( | ||
| 374 | relay_url: &str, | ||
| 375 | event_id: &EventId, | ||
| 376 | check_duration: Duration, | ||
| 377 | ) -> Result<(), String> { | ||
| 378 | let temp_keys = Keys::generate(); | ||
| 379 | let client = Client::new(temp_keys); | ||
| 380 | |||
| 381 | client | ||
| 382 | .add_relay(relay_url) | ||
| 383 | .await | ||
| 384 | .map_err(|e| format!("Failed to add relay: {}", e))?; | ||
| 385 | |||
| 386 | client.connect().await; | ||
| 387 | |||
| 388 | // Wait for connection | ||
| 389 | let mut connected = false; | ||
| 390 | for _ in 0..20 { | ||
| 391 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 392 | let relays = client.relays().await; | ||
| 393 | if relays.values().any(|r| r.is_connected()) { | ||
| 394 | connected = true; | ||
| 395 | break; | ||
| 396 | } | ||
| 397 | } | ||
| 398 | |||
| 399 | if !connected { | ||
| 400 | client.disconnect().await; | ||
| 401 | return Err("Failed to connect to relay".to_string()); | ||
| 402 | } | ||
| 403 | |||
| 404 | // Check that event is NOT served | ||
| 405 | let filter = Filter::new().id(*event_id); | ||
| 406 | |||
| 407 | match client.fetch_events(filter, check_duration).await { | ||
| 408 | Ok(events) => { | ||
| 409 | client.disconnect().await; | ||
| 410 | if events.is_empty() { | ||
| 411 | Ok(()) | ||
| 412 | } else { | ||
| 413 | Err(format!( | ||
| 414 | "Event {} was served (expected to be in purgatory)", | ||
| 415 | event_id | ||
| 416 | )) | ||
| 417 | } | ||
| 418 | } | ||
| 419 | Err(e) => { | ||
| 420 | client.disconnect().await; | ||
| 421 | // Fetch error could mean timeout (expected) or actual error | ||
| 422 | // For our purposes, if we couldn't find it, that's success | ||
| 423 | tracing::debug!("Fetch returned error (expected for purgatory check): {}", e); | ||
| 424 | Ok(()) | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | /// Check if a ref exists at a specific commit on a relay's git endpoint. | ||
| 430 | /// | ||
| 431 | /// Uses git ls-remote to check the remote refs without cloning. | ||
| 432 | /// | ||
| 433 | /// # Arguments | ||
| 434 | /// * `relay_domain` - The relay domain (e.g., "127.0.0.1:8080") | ||
| 435 | /// * `npub` - Owner's npub | ||
| 436 | /// * `repo_id` - Repository identifier | ||
| 437 | /// * `ref_name` - Ref to check (e.g., "refs/heads/main") | ||
| 438 | /// * `expected_commit` - Expected commit hash | ||
| 439 | /// | ||
| 440 | /// # Returns | ||
| 441 | /// * `Ok(true)` - Ref exists and points to expected commit | ||
| 442 | /// * `Ok(false)` - Ref doesn't exist or points to different commit | ||
| 443 | /// * `Err(String)` - Error checking ref | ||
| 444 | pub async fn check_ref_at_commit( | ||
| 445 | relay_domain: &str, | ||
| 446 | npub: &str, | ||
| 447 | repo_id: &str, | ||
| 448 | ref_name: &str, | ||
| 449 | expected_commit: &str, | ||
| 450 | ) -> Result<bool, String> { | ||
| 451 | let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); | ||
| 452 | |||
| 453 | let output = Command::new("git") | ||
| 454 | .args(["ls-remote", &remote_url, ref_name]) | ||
| 455 | .output() | ||
| 456 | .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; | ||
| 457 | |||
| 458 | if !output.status.success() { | ||
| 459 | // ls-remote can fail if repo doesn't exist yet, which is expected in some tests | ||
| 460 | return Ok(false); | ||
| 461 | } | ||
| 462 | |||
| 463 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 464 | |||
| 465 | // Parse output: "<commit>\t<ref>" | ||
| 466 | for line in stdout.lines() { | ||
| 467 | let parts: Vec<&str> = line.split('\t').collect(); | ||
| 468 | if parts.len() >= 2 && parts[1] == ref_name { | ||
| 469 | // Compare commit hashes (handle both full and short hashes) | ||
| 470 | let remote_commit = parts[0]; | ||
| 471 | return Ok(remote_commit.starts_with(expected_commit) | ||
| 472 | || expected_commit.starts_with(remote_commit)); | ||
| 473 | } | ||
| 474 | } | ||
| 475 | |||
| 476 | Ok(false) | ||
| 477 | } | ||
| 478 | |||
| 479 | /// Push a local repository to a relay. | ||
| 480 | /// | ||
| 481 | /// Adds the relay as a remote and pushes all refs. | ||
| 482 | /// | ||
| 483 | /// # Arguments | ||
| 484 | /// * `local_path` - Path to local git repository | ||
| 485 | /// * `relay_domain` - The relay domain (e.g., "127.0.0.1:8080") | ||
| 486 | /// * `npub` - Owner's npub | ||
| 487 | /// * `repo_id` - Repository identifier | ||
| 488 | /// | ||
| 489 | /// # Returns | ||
| 490 | /// * `Ok(())` - Push successful | ||
| 491 | /// * `Err(String)` - Push failed | ||
| 492 | pub fn push_to_relay( | ||
| 493 | local_path: &Path, | ||
| 494 | relay_domain: &str, | ||
| 495 | npub: &str, | ||
| 496 | repo_id: &str, | ||
| 497 | ) -> Result<(), String> { | ||
| 498 | let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); | ||
| 499 | |||
| 500 | // Check if origin already exists | ||
| 501 | let check_output = Command::new("git") | ||
| 502 | .args(["remote", "get-url", "origin"]) | ||
| 503 | .current_dir(local_path) | ||
| 504 | .output() | ||
| 505 | .map_err(|e| format!("Failed to check remote: {}", e))?; | ||
| 506 | |||
| 507 | if check_output.status.success() { | ||
| 508 | // Remote exists, update it | ||
| 509 | run_git(local_path, &["remote", "set-url", "origin", &remote_url])?; | ||
| 510 | } else { | ||
| 511 | // Add new remote | ||
| 512 | run_git(local_path, &["remote", "add", "origin", &remote_url])?; | ||
| 513 | } | ||
| 514 | |||
| 515 | // Push all refs | ||
| 516 | let output = Command::new("git") | ||
| 517 | .args(["push", "-u", "origin", "--all"]) | ||
| 518 | .current_dir(local_path) | ||
| 519 | .output() | ||
| 520 | .map_err(|e| format!("Failed to run git push: {}", e))?; | ||
| 521 | |||
| 522 | if !output.status.success() { | ||
| 523 | return Err(format!( | ||
| 524 | "git push failed: {}", | ||
| 525 | String::from_utf8_lossy(&output.stderr) | ||
| 526 | )); | ||
| 527 | } | ||
| 528 | |||
| 529 | Ok(()) | ||
| 530 | } | ||
| 531 | |||
| 532 | #[cfg(test)] | ||
| 533 | mod tests { | ||
| 534 | use super::*; | ||
| 535 | |||
| 536 | #[test] | ||
| 537 | fn test_create_test_repo_with_commit() { | ||
| 538 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); | ||
| 539 | let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 540 | .expect("Failed to create test repo"); | ||
| 541 | |||
| 542 | // Verify commit hash is a valid git hash (40 hex chars) | ||
| 543 | assert_eq!(commit_hash.len(), 40); | ||
| 544 | assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 545 | |||
| 546 | // Verify the file was created | ||
| 547 | assert!(temp_dir.path().join("state_test.txt").exists()); | ||
| 548 | } | ||
| 549 | |||
| 550 | #[test] | ||
| 551 | fn test_add_commit_to_repo() { | ||
| 552 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); | ||
| 553 | |||
| 554 | // Create initial repo | ||
| 555 | let first_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 556 | .expect("Failed to create test repo"); | ||
| 557 | |||
| 558 | // Add second commit | ||
| 559 | let second_commit = add_commit_to_repo(temp_dir.path(), CommitVariant::SecondCommit) | ||
| 560 | .expect("Failed to add commit"); | ||
| 561 | |||
| 562 | // Commits should be different | ||
| 563 | assert_ne!(first_commit, second_commit); | ||
| 564 | |||
| 565 | // Both files should exist | ||
| 566 | assert!(temp_dir.path().join("state_test.txt").exists()); | ||
| 567 | assert!(temp_dir.path().join("second.txt").exists()); | ||
| 568 | } | ||
| 569 | |||
| 570 | #[test] | ||
| 571 | fn test_create_state_event_has_correct_tags() { | ||
| 572 | let keys = Keys::generate(); | ||
| 573 | let event = create_state_event( | ||
| 574 | &keys, | ||
| 575 | "test-repo", | ||
| 576 | &[("main", "abc123def456")], | ||
| 577 | &[("v1.0", "def456abc123")], | ||
| 578 | &["http://example.com/test.git"], | ||
| 579 | &["ws://example.com"], | ||
| 580 | ) | ||
| 581 | .expect("Failed to create state event"); | ||
| 582 | |||
| 583 | assert_eq!(event.kind.as_u16(), KIND_STATE); | ||
| 584 | |||
| 585 | // Check d-tag | ||
| 586 | let has_d_tag = event.tags.iter().any(|tag| { | ||
| 587 | let slice = tag.as_slice(); | ||
| 588 | slice.first().is_some_and(|t| t == "d") && slice.get(1).is_some_and(|v| v == "test-repo") | ||
| 589 | }); | ||
| 590 | assert!(has_d_tag, "Event should have 'd' tag with identifier"); | ||
| 591 | |||
| 592 | // Check refs/heads/main tag | ||
| 593 | let has_branch_tag = event.tags.iter().any(|tag| { | ||
| 594 | let slice = tag.as_slice(); | ||
| 595 | slice.first().is_some_and(|t| t == "refs/heads/main") | ||
| 596 | && slice.get(1).is_some_and(|v| v == "abc123def456") | ||
| 597 | }); | ||
| 598 | assert!(has_branch_tag, "Event should have refs/heads/main tag"); | ||
| 599 | |||
| 600 | // Check refs/tags/v1.0 tag | ||
| 601 | let has_tag_tag = event.tags.iter().any(|tag| { | ||
| 602 | let slice = tag.as_slice(); | ||
| 603 | slice.first().is_some_and(|t| t == "refs/tags/v1.0") | ||
| 604 | && slice.get(1).is_some_and(|v| v == "def456abc123") | ||
| 605 | }); | ||
| 606 | assert!(has_tag_tag, "Event should have refs/tags/v1.0 tag"); | ||
| 607 | |||
| 608 | // Check HEAD tag | ||
| 609 | let has_head_tag = event.tags.iter().any(|tag| { | ||
| 610 | let slice = tag.as_slice(); | ||
| 611 | slice.first().is_some_and(|t| t == "HEAD") | ||
| 612 | && slice.get(1).is_some_and(|v| v == "refs/heads/main") | ||
| 613 | }); | ||
| 614 | assert!(has_head_tag, "Event should have HEAD tag"); | ||
| 615 | } | ||
| 616 | |||
| 617 | #[test] | ||
| 618 | fn test_create_pr_event_has_correct_tags() { | ||
| 619 | let keys = Keys::generate(); | ||
| 620 | let repo_coord = build_repo_coord(&keys, "test-repo"); | ||
| 621 | let event = create_pr_event(&keys, &repo_coord, "def456abc123", "Test PR") | ||
| 622 | .expect("Failed to create PR event"); | ||
| 623 | |||
| 624 | assert_eq!(event.kind.as_u16(), KIND_PR); | ||
| 625 | |||
| 626 | // Check a-tag | ||
| 627 | let has_a_tag = event.tags.iter().any(|tag| { | ||
| 628 | let slice = tag.as_slice(); | ||
| 629 | slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord) | ||
| 630 | }); | ||
| 631 | assert!(has_a_tag, "Event should have 'a' tag"); | ||
| 632 | |||
| 633 | // Check c-tag | ||
| 634 | let has_c_tag = event.tags.iter().any(|tag| { | ||
| 635 | let slice = tag.as_slice(); | ||
| 636 | slice.first().is_some_and(|t| t == "c") | ||
| 637 | && slice.get(1).is_some_and(|v| v == "def456abc123") | ||
| 638 | }); | ||
| 639 | assert!(has_c_tag, "Event should have 'c' tag with commit"); | ||
| 640 | } | ||
| 641 | |||
| 642 | #[test] | ||
| 643 | fn test_build_repo_coord_format() { | ||
| 644 | let keys = Keys::generate(); | ||
| 645 | let coord = build_repo_coord(&keys, "my-repo"); | ||
| 646 | |||
| 647 | assert!(coord.starts_with("30617:")); | ||
| 648 | assert!(coord.ends_with(":my-repo")); | ||
| 649 | assert_eq!(coord.split(':').count(), 3); | ||
| 650 | } | ||
| 651 | |||
| 652 | #[test] | ||
| 653 | fn test_create_branch() { | ||
| 654 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); | ||
| 655 | |||
| 656 | // Create initial repo | ||
| 657 | let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) | ||
| 658 | .expect("Failed to create test repo"); | ||
| 659 | |||
| 660 | // Create a branch at HEAD | ||
| 661 | create_branch(temp_dir.path(), "feature", None).expect("Failed to create branch"); | ||
| 662 | |||
| 663 | // Verify branch exists | ||
| 664 | let output = Command::new("git") | ||
| 665 | .args(["rev-parse", "feature"]) | ||
| 666 | .current_dir(temp_dir.path()) | ||
| 667 | .output() | ||
| 668 | .expect("Failed to run git rev-parse"); | ||
| 669 | |||
| 670 | assert!(output.status.success()); | ||
| 671 | let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); | ||
| 672 | assert_eq!(branch_commit, commit_hash); | ||
| 673 | } | ||
| 674 | } | ||