upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests/common
diff options
context:
space:
mode:
Diffstat (limited to 'tests/common')
-rw-r--r--tests/common/mod.rs2
-rw-r--r--tests/common/purgatory_helpers.rs674
2 files changed, 676 insertions, 0 deletions
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 37ac3bb..f511163 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -2,8 +2,10 @@
2#![allow(dead_code)] // Test helpers may not be used in all test configurations 2#![allow(dead_code)] // Test helpers may not be used in all test configurations
3#![allow(unused_imports)] // Re-exports may not be used in all test configurations 3#![allow(unused_imports)] // Re-exports may not be used in all test configurations
4 4
5pub mod purgatory_helpers;
5pub mod relay; 6pub mod relay;
6pub mod sync_helpers; 7pub mod sync_helpers;
7 8
9pub use purgatory_helpers::*;
8pub use relay::TestRelay; 10pub use relay::TestRelay;
9pub use sync_helpers::*; 11pub use sync_helpers::*;
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
14use nostr_sdk::prelude::*;
15use std::path::Path;
16use std::process::Command;
17use std::time::Duration;
18
19/// NIP-34 Repository State (kind 30618)
20pub const KIND_STATE: u16 = 30618;
21
22/// NIP-34 Pull Request (kind 1618)
23pub const KIND_PR: u16 = 1618;
24
25/// Commit variants for deterministic test commits
26#[derive(Debug, Clone, Copy)]
27pub 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
47pub 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
90pub 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)
119pub 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.
131fn 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.
149fn 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
184pub 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
255pub 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)
281pub 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
298pub 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
373pub 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
444pub 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
492pub 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)]
533mod 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}