upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/common/mock_relay.rs336
-rw-r--r--tests/common/mod.rs2
-rw-r--r--tests/common/purgatory_helpers.rs115
-rw-r--r--tests/purgatory_sync.rs496
4 files changed, 930 insertions, 19 deletions
diff --git a/tests/common/mock_relay.rs b/tests/common/mock_relay.rs
new file mode 100644
index 0000000..123c29e
--- /dev/null
+++ b/tests/common/mock_relay.rs
@@ -0,0 +1,336 @@
1//! Mock Nostr Relay for Testing
2//!
3//! Provides a simple Nostr relay that accepts all events without validation.
4//! Uses rust-nostr's `LocalRelayBuilder` to create an in-memory relay.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! use common::MockRelay;
10//!
11//! #[tokio::test]
12//! async fn test_mock_relay() {
13//! // Start the mock relay
14//! let mock = MockRelay::start().await;
15//!
16//! // Use mock.url() for WebSocket connections
17//! let client = Client::new(keys);
18//! client.add_relay(mock.url()).await.unwrap();
19//!
20//! // All events are accepted without validation
21//! client.send_event(&event).await.unwrap();
22//!
23//! // Cleanup
24//! mock.stop().await;
25//! }
26//! ```
27//!
28//! # How It Works
29//!
30//! The mock relay:
31//! - Uses `LocalRelayBuilder::default().build()` which accepts all events
32//! - Runs an HTTP server with WebSocket upgrade support
33//! - Stores events in an in-memory database
34//! - Does NOT perform any GRASP validation (no purgatory, no git data checks)
35
36use std::net::SocketAddr;
37use std::sync::Arc;
38
39use http_body_util::Full;
40use hyper::body::Bytes;
41use hyper::header::{CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, UPGRADE};
42use hyper::server::conn::http1;
43use hyper::service::service_fn;
44use hyper::{Request, Response, StatusCode};
45use hyper_util::rt::TokioIo;
46use nostr_relay_builder::prelude::*;
47use tokio::net::TcpListener;
48use tokio::sync::oneshot;
49
50/// Mock Nostr relay that accepts all events without validation.
51///
52/// This relay is useful for testing scenarios where you need a relay
53/// that serves events without GRASP validation (no purgatory, no git checks).
54pub struct MockRelay {
55 /// Shutdown signal sender
56 shutdown_tx: Option<oneshot::Sender<()>>,
57 /// Server task handle
58 handle: Option<tokio::task::JoinHandle<()>>,
59 /// Server URL (ws://127.0.0.1:<port>)
60 url: String,
61 /// Server port
62 #[allow(dead_code)]
63 port: u16,
64 /// The underlying LocalRelay (kept alive for the server lifetime)
65 #[allow(dead_code)]
66 relay: LocalRelay,
67}
68
69impl MockRelay {
70 /// Start a mock relay on a random free port.
71 ///
72 /// The relay accepts all events without validation and stores them
73 /// in an in-memory database.
74 pub async fn start() -> Self {
75 let port = find_free_port();
76 Self::start_on_port(port).await
77 }
78
79 /// Start a mock relay on a specific port.
80 pub async fn start_on_port(port: u16) -> Self {
81 let addr: SocketAddr = ([127, 0, 0, 1], port).into();
82
83 // Create a simple relay with no write policy (accepts all events)
84 let relay = LocalRelayBuilder::default().build();
85
86 // Create shutdown channel
87 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
88
89 // Clone relay for the server task
90 let server_relay = relay.clone();
91
92 // Start the HTTP/WebSocket server
93 let listener = TcpListener::bind(addr)
94 .await
95 .expect("Failed to bind to address");
96
97 let handle = tokio::spawn(async move {
98 loop {
99 tokio::select! {
100 accept_result = listener.accept() => {
101 match accept_result {
102 Ok((stream, remote_addr)) => {
103 let relay = server_relay.clone();
104 let io = TokioIo::new(stream);
105
106 tokio::spawn(async move {
107 let service = service_fn(move |req| {
108 let relay = relay.clone();
109 async move { handle_request(req, relay, remote_addr).await }
110 });
111
112 if let Err(e) = http1::Builder::new()
113 .serve_connection(io, service)
114 .with_upgrades()
115 .await
116 {
117 // Connection errors are expected when client disconnects
118 if !e.to_string().contains("connection") {
119 eprintln!("MockRelay connection error: {}", e);
120 }
121 }
122 });
123 }
124 Err(e) => {
125 eprintln!("MockRelay accept error: {}", e);
126 }
127 }
128 }
129 _ = &mut shutdown_rx => {
130 // Shutdown signal received
131 break;
132 }
133 }
134 }
135 });
136
137 let url = format!("ws://127.0.0.1:{}", port);
138
139 // Wait for server to be ready
140 wait_for_server_ready(port).await;
141
142 Self {
143 shutdown_tx: Some(shutdown_tx),
144 handle: Some(handle),
145 url,
146 port,
147 relay,
148 }
149 }
150
151 /// Get the relay WebSocket URL.
152 pub fn url(&self) -> &str {
153 &self.url
154 }
155
156 /// Stop the mock relay.
157 pub async fn stop(mut self) {
158 // Send shutdown signal
159 if let Some(tx) = self.shutdown_tx.take() {
160 let _ = tx.send(());
161 }
162
163 // Wait for server task to complete
164 if let Some(handle) = self.handle.take() {
165 let _ = handle.await;
166 }
167 }
168}
169
170impl Drop for MockRelay {
171 fn drop(&mut self) {
172 // Send shutdown signal if not already sent
173 if let Some(tx) = self.shutdown_tx.take() {
174 let _ = tx.send(());
175 }
176 }
177}
178
179/// Handle an HTTP request, upgrading to WebSocket if requested.
180async fn handle_request(
181 req: Request<hyper::body::Incoming>,
182 relay: LocalRelay,
183 addr: SocketAddr,
184) -> Result<Response<Full<Bytes>>, hyper::Error> {
185 // Check for WebSocket upgrade request
186 let is_websocket = req
187 .headers()
188 .get(UPGRADE)
189 .map(|v| v.to_str().unwrap_or("").to_lowercase() == "websocket")
190 .unwrap_or(false);
191
192 if is_websocket {
193 // Get the Sec-WebSocket-Key header
194 let key = req
195 .headers()
196 .get(SEC_WEBSOCKET_KEY)
197 .and_then(|k| k.to_str().ok())
198 .map(|k| k.to_string());
199
200 if let Some(key) = key {
201 let accept_key = derive_accept_key(key.as_bytes());
202
203 // Spawn task to handle the upgraded connection
204 tokio::spawn(async move {
205 match hyper::upgrade::on(req).await {
206 Ok(upgraded) => {
207 if let Err(e) = relay.take_connection(TokioIo::new(upgraded), addr).await {
208 eprintln!("MockRelay WebSocket error: {}", e);
209 }
210 }
211 Err(e) => eprintln!("MockRelay upgrade error: {}", e),
212 }
213 });
214
215 // Return 101 Switching Protocols
216 return Ok(Response::builder()
217 .status(StatusCode::SWITCHING_PROTOCOLS)
218 .header(CONNECTION, "upgrade")
219 .header(UPGRADE, "websocket")
220 .header(SEC_WEBSOCKET_ACCEPT, accept_key)
221 .body(Full::new(Bytes::new()))
222 .unwrap());
223 }
224 }
225
226 // Non-WebSocket request - return simple response
227 Ok(Response::builder()
228 .status(StatusCode::OK)
229 .header("Content-Type", "text/plain")
230 .body(Full::new(Bytes::from("MockRelay - Nostr test relay")))
231 .unwrap())
232}
233
234/// Derive the Sec-WebSocket-Accept key from the request key.
235fn derive_accept_key(request_key: &[u8]) -> String {
236 use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
237 use nostr_sdk::hashes::{Hash, HashEngine};
238
239 const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
240
241 let mut engine = Sha1Hash::engine();
242 engine.input(request_key);
243 engine.input(WS_GUID);
244 let hash = Sha1Hash::from_engine(engine);
245 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash.as_byte_array())
246}
247
248/// Find a free port to use for the server.
249fn find_free_port() -> u16 {
250 use std::net::TcpListener;
251
252 let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
253 let port = listener
254 .local_addr()
255 .expect("Failed to get local addr")
256 .port();
257 drop(listener);
258 port
259}
260
261/// Wait for the server to be ready to accept connections.
262async fn wait_for_server_ready(port: u16) {
263 let max_attempts = 50; // 5 seconds total
264 let delay = std::time::Duration::from_millis(100);
265
266 for attempt in 0..max_attempts {
267 match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await {
268 Ok(_) => {
269 // Connection successful, server is ready
270 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
271 return;
272 }
273 Err(_) => {
274 if attempt == max_attempts - 1 {
275 panic!(
276 "MockRelay failed to start after {} attempts",
277 max_attempts
278 );
279 }
280 tokio::time::sleep(delay).await;
281 }
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use nostr_sdk::prelude::*;
290 use std::time::Duration;
291
292 #[tokio::test]
293 async fn test_mock_relay_starts_and_stops() {
294 let mock = MockRelay::start().await;
295
296 // Verify URL is set
297 assert!(mock.url().starts_with("ws://127.0.0.1:"));
298
299 mock.stop().await;
300 }
301
302 #[tokio::test]
303 async fn test_mock_relay_accepts_events() {
304 let mock = MockRelay::start().await;
305
306 // Create a client and connect
307 let keys = Keys::generate();
308 let client = Client::new(keys.clone());
309 client.add_relay(mock.url()).await.expect("Failed to add relay");
310 client.connect().await;
311
312 // Wait for connection
313 tokio::time::sleep(Duration::from_millis(500)).await;
314
315 // Create and send a simple event
316 let event = EventBuilder::text_note("Test note from MockRelay test")
317 .sign_with_keys(&keys)
318 .expect("Failed to sign event");
319
320 let result = client.send_event(&event).await;
321 assert!(result.is_ok(), "MockRelay should accept events");
322
323 // Verify event was stored by fetching it back
324 let filter = Filter::new().id(event.id);
325 let events = client
326 .fetch_events(filter, Duration::from_secs(2))
327 .await
328 .expect("Failed to fetch events");
329
330 assert!(!events.is_empty(), "Event should be stored and retrievable");
331 assert_eq!(events.first().unwrap().id, event.id);
332
333 client.disconnect().await;
334 mock.stop().await;
335 }
336}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index e70bd71..32ce1b7 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -3,11 +3,13 @@
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 git_server; 5pub mod git_server;
6pub mod mock_relay;
6pub mod purgatory_helpers; 7pub mod purgatory_helpers;
7pub mod relay; 8pub mod relay;
8pub mod sync_helpers; 9pub mod sync_helpers;
9 10
10pub use git_server::SimpleGitServer; 11pub use git_server::SimpleGitServer;
12pub use mock_relay::MockRelay;
11pub use purgatory_helpers::*; 13pub use purgatory_helpers::*;
12pub use relay::TestRelay; 14pub use relay::TestRelay;
13pub use sync_helpers::*; 15pub use sync_helpers::*;
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs
index 7d8e908..fa1be73 100644
--- a/tests/common/purgatory_helpers.rs
+++ b/tests/common/purgatory_helpers.rs
@@ -271,6 +271,60 @@ pub fn create_pr_event(
271 .map_err(|e| format!("Failed to sign PR event: {}", e)) 271 .map_err(|e| format!("Failed to sign PR event: {}", e))
272} 272}
273 273
274/// Create a PR event (kind 1618) with clone URLs.
275///
276/// Creates a properly formatted NIP-34 PR event that references a repository
277/// via an `a` tag, includes the commit hash via a `c` tag, and specifies
278/// clone URLs where the PR commit can be fetched from.
279///
280/// Per NIP-34, PR events can include a `clone` tag:
281/// ```jsonc
282/// {
283/// "kind": 1618,
284/// "tags": [
285/// ["c", "<current-commit-id>"],
286/// ["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
287/// // ...
288/// ]
289/// }
290/// ```
291///
292/// # Arguments
293/// * `keys` - Keys for signing
294/// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier")
295/// * `commit_hash` - The commit hash (c-tag)
296/// * `title` - PR title (used as content)
297/// * `clone_urls` - Clone URLs where the PR commit can be fetched
298///
299/// # Returns
300/// * `Ok(Event)` - Signed PR event ready to send
301/// * `Err(String)` - If signing fails
302pub fn create_pr_event_with_clone(
303 keys: &Keys,
304 repo_coord: &str,
305 commit_hash: &str,
306 title: &str,
307 clone_urls: &[&str],
308) -> Result<Event, String> {
309 let mut tags = vec![
310 // a-tag referencing the repository
311 Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]),
312 // c-tag with the commit hash
313 Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]),
314 ];
315
316 // Add clone URLs if provided
317 if !clone_urls.is_empty() {
318 let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect();
319 tags.push(Tag::custom(TagKind::Clone, urls));
320 }
321
322 EventBuilder::new(Kind::Custom(KIND_PR), title)
323 .tags(tags)
324 .sign_with_keys(keys)
325 .map_err(|e| format!("Failed to sign PR event: {}", e))
326}
327
274/// Build a repository coordinate string for use in 'a' tags. 328/// Build a repository coordinate string for use in 'a' tags.
275/// 329///
276/// Format: `30617:pubkey_hex:identifier` 330/// Format: `30617:pubkey_hex:identifier`
@@ -738,4 +792,65 @@ mod tests {
738 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); 792 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
739 assert_eq!(branch_commit, commit_hash); 793 assert_eq!(branch_commit, commit_hash);
740 } 794 }
795
796 #[test]
797 fn test_create_pr_event_with_clone_has_correct_tags() {
798 let keys = Keys::generate();
799 let repo_coord = build_repo_coord(&keys, "test-repo");
800 let event = create_pr_event_with_clone(
801 &keys,
802 &repo_coord,
803 "abc123def456",
804 "Test PR with clone",
805 &["http://fork-server.com/repo.git", "http://another-server.com/repo.git"],
806 )
807 .expect("Failed to create PR event with clone");
808
809 assert_eq!(event.kind.as_u16(), KIND_PR);
810
811 // Check a-tag
812 let has_a_tag = event.tags.iter().any(|tag| {
813 let slice = tag.as_slice();
814 slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord)
815 });
816 assert!(has_a_tag, "Event should have 'a' tag");
817
818 // Check c-tag
819 let has_c_tag = event.tags.iter().any(|tag| {
820 let slice = tag.as_slice();
821 slice.first().is_some_and(|t| t == "c")
822 && slice.get(1).is_some_and(|v| v == "abc123def456")
823 });
824 assert!(has_c_tag, "Event should have 'c' tag with commit");
825
826 // Check clone tag with both URLs
827 let has_clone_tag = event.tags.iter().any(|tag| {
828 let slice = tag.as_slice();
829 slice.first().is_some_and(|t| t == "clone")
830 && slice.get(1).is_some_and(|v| v == "http://fork-server.com/repo.git")
831 && slice.get(2).is_some_and(|v| v == "http://another-server.com/repo.git")
832 });
833 assert!(has_clone_tag, "Event should have 'clone' tag with URLs");
834 }
835
836 #[test]
837 fn test_create_pr_event_with_clone_empty_urls() {
838 let keys = Keys::generate();
839 let repo_coord = build_repo_coord(&keys, "test-repo");
840 let event = create_pr_event_with_clone(
841 &keys,
842 &repo_coord,
843 "abc123def456",
844 "Test PR without clone URLs",
845 &[], // Empty clone URLs
846 )
847 .expect("Failed to create PR event");
848
849 // Should not have clone tag when no URLs provided
850 let has_clone_tag = event.tags.iter().any(|tag| {
851 let slice = tag.as_slice();
852 slice.first().is_some_and(|t| t == "clone")
853 });
854 assert!(!has_clone_tag, "Event should not have 'clone' tag when no URLs provided");
855 }
741} 856}
diff --git a/tests/purgatory_sync.rs b/tests/purgatory_sync.rs
index 0b4d864..fe03455 100644
--- a/tests/purgatory_sync.rs
+++ b/tests/purgatory_sync.rs
@@ -29,9 +29,10 @@ mod common;
29 29
30use common::{ 30use common::{
31 add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event, 31 add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event,
32 create_repo_announcement, create_state_event, create_test_repo_with_commit, push_ref_to_relay, 32 create_pr_event_with_clone, create_repo_announcement, create_state_event,
33 push_to_relay, verify_event_not_served, wait_for_event_served, wait_for_sync_connection, 33 create_test_repo_with_commit, push_ref_to_relay, push_to_relay, verify_event_not_served,
34 CommitVariant, TestRelay, 34 wait_for_event_served, wait_for_sync_connection, CommitVariant, MockRelay, SimpleGitServer,
35 TestRelay,
35}; 36};
36use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
37use std::time::Duration; 38use std::time::Duration;
@@ -55,9 +56,8 @@ async fn test_push_triggers_unified_processing() {
55 56
56 // 2. Create test repository locally with deterministic commit 57 // 2. Create test repository locally with deterministic commit
57 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); 58 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
58 let commit_hash = 59 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
59 create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) 60 .expect("Failed to create test repo");
60 .expect("Failed to create test repo");
61 61
62 // 3. Create and send announcement 62 // 3. Create and send announcement
63 let announcement = create_repo_announcement(&keys, &[&relay.domain()], identifier); 63 let announcement = create_repo_announcement(&keys, &[&relay.domain()], identifier);
@@ -343,8 +343,13 @@ async fn test_pr_event_syncs_from_remote() {
343 // The PR event goes to purgatory on source relay, which authorizes the push 343 // The PR event goes to purgatory on source relay, which authorizes the push
344 let repo_coord = build_repo_coord(&owner_keys, identifier); 344 let repo_coord = build_repo_coord(&owner_keys, identifier);
345 345
346 let pr_event = create_pr_event(&pr_author_keys, &repo_coord, &commit_hash, "Test PR for sync") 346 let pr_event = create_pr_event(
347 .expect("Failed to create PR event"); 347 &pr_author_keys,
348 &repo_coord,
349 &commit_hash,
350 "Test PR for sync",
351 )
352 .expect("Failed to create PR event");
348 353
349 let pr_event_id = pr_event.id; 354 let pr_event_id = pr_event.id;
350 355
@@ -418,15 +423,10 @@ async fn test_pr_event_syncs_from_remote() {
418 ); 423 );
419 424
420 // 8. Verify refs/nostr/<event-id> was created on syncing relay 425 // 8. Verify refs/nostr/<event-id> was created on syncing relay
421 let ref_correct = check_ref_at_commit( 426 let ref_correct =
422 &syncing_domain, 427 check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash)
423 &npub, 428 .await
424 identifier, 429 .expect("Failed to check PR ref");
425 &ref_name,
426 &commit_hash,
427 )
428 .await
429 .expect("Failed to check PR ref");
430 430
431 assert!( 431 assert!(
432 ref_correct, 432 ref_correct,
@@ -624,8 +624,8 @@ async fn test_concurrent_state_and_pr_sync() {
624 state_found.err() 624 state_found.err()
625 ); 625 );
626 626
627 let pr_found = wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)) 627 let pr_found =
628 .await; 628 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
629 629
630 assert!( 630 assert!(
631 pr_found.is_ok(), 631 pr_found.is_ok(),
@@ -674,3 +674,461 @@ async fn test_concurrent_state_and_pr_sync() {
674 syncing_relay.stop().await; 674 syncing_relay.stop().await;
675 source_relay.stop().await; 675 source_relay.stop().await;
676} 676}
677
678/// Test PR event clone tag sync with relay discovery from announcement tags and partial git data sync
679/// from multiple servers (state and pr git data from different places)
680///
681/// This comprehensive test verifies:
682/// 1. Relay discovery: syncing_relay discovers other relays from announcement's `relays` tag
683/// 2. PR clone tag sync: PR events with `clone` tags have their URLs used during purgatory sync
684/// 3. OID aggregation: OIDs can be aggregated from multiple sources when no single server has all data
685///
686/// ## Key Difference from Bootstrap-Based Sync
687///
688/// Unlike tests that use bootstrap relay configuration, this test:
689/// - Starts syncing_relay with NO bootstrap relay
690/// - Publishes announcement DIRECTLY to syncing_relay
691/// - syncing_relay discovers source_grasp and mock_relay from announcement's `relays` tag
692///
693/// This validates the relay discovery mechanism that allows GRASP relays to find
694/// and sync from other relays listed in repository announcements.
695///
696/// ## Architecture
697///
698/// ```text
699/// ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
700/// │ source_grasp │ │ mock_relay │ │ git_server │
701/// │ (GRASP relay) │ │ (rust-nostr relay) │ │ (SimpleGitServer) │
702/// │ │ │ │ │ │
703/// │ Has: │ │ Has: │ │ Has: │
704/// │ - Announcement │ │ - PR event │ │ - PR commit (commit_b) │
705/// │ - State event (served) │ │ (served immediately, │ │ at refs/heads/main │
706/// │ - refs/heads/main │ │ no purgatory) │ │ │
707/// │ → commit_a │ │ │ │ Does NOT have: │
708/// │ │ │ PR event has clone tag │ │ - commit_a │
709/// │ Does NOT have: │ │ pointing to git_server │ │ │
710/// │ - PR commit (commit_b) │ │ │ │ │
711/// └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
712/// │ │ │
713/// └───────────────────────────────┼───────────────────────────────┘
714/// ▼
715/// ┌─────────────────────────────────────────────────────────────────────────────────────────┐
716/// │ syncing_relay (GRASP relay under test) │
717/// │ │
718/// │ Flow: │
719/// │ 1. Started with NO bootstrap relay (sync enabled but no initial connections) │
720/// │ 2. Announcement published DIRECTLY to syncing_relay │
721/// │ 3. Relay discovers source_grasp and mock_relay from announcement's `relays` tag │
722/// │ 4. Syncs state event from source_grasp → purgatory (no commit_a locally) │
723/// │ 5. Syncs PR event from mock_relay → purgatory (no commit_b locally) │
724/// │ 6. Purgatory sync triggers │
725/// │ 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag) │
726/// │ 8. Fetches commit_b from git_server (from PR event's clone tag) │
727/// │ 9. Both events released when all OIDs available │
728/// │ │
729/// │ Result: │
730/// │ - State event served │
731/// │ - PR event served │
732/// │ - refs/heads/main → commit_a (from source_grasp) │
733/// │ - refs/nostr/<event-id> → commit_b (from git_server via PR clone tag) │
734/// └─────────────────────────────────────────────────────────────────────────────────────────┘
735/// ```
736#[tokio::test]
737async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple_server() {
738 // ========================================================================
739 // Step 1: Setup Repositories
740 // ========================================================================
741
742 // Repo A: main branch with commit_a (for state event)
743 let repo_a = tempfile::tempdir().expect("Failed to create temp dir for repo_a");
744 let commit_a = create_test_repo_with_commit(repo_a.path(), CommitVariant::StateTest)
745 .expect("Failed to create commit_a");
746
747 // Repo B: PR commit (commit_b) - different content
748 let repo_b = tempfile::tempdir().expect("Failed to create temp dir for repo_b");
749 let commit_b = create_test_repo_with_commit(repo_b.path(), CommitVariant::PrTest)
750 .expect("Failed to create commit_b");
751
752 // ========================================================================
753 // Step 2: Start Servers
754 // ========================================================================
755
756 // 1. source_grasp - GRASP relay with main branch data
757 let source_grasp = TestRelay::start().await;
758
759 // 2. mock_relay - rust-nostr relay for PR event (no validation, no purgatory)
760 let mock_relay = MockRelay::start().await;
761
762 // 3. git_server - SimpleGitServer with PR commit only
763 let git_server = SimpleGitServer::start(repo_b.path()).await;
764
765 // 4. Pre-allocate syncing_relay port for announcement tags
766 let syncing_port = TestRelay::find_free_port();
767 let syncing_domain = format!("127.0.0.1:{}", syncing_port);
768
769 // ========================================================================
770 // Step 3: Setup source_grasp with announcement and state event
771 // ========================================================================
772
773 let owner_keys = Keys::generate();
774 let pr_author_keys = Keys::generate();
775 let identifier = "pr-clone-partial-oid-test";
776 let npub = owner_keys
777 .public_key()
778 .to_bech32()
779 .expect("Failed to get npub");
780
781 // Build URLs for announcement
782 // - clone tag: ONLY source_grasp (has main branch data)
783 // - relays tag: source_grasp + mock_relay (mock_relay will serve PR event)
784 let clone_url_source = format!(
785 "http://{}/{}/{}.git",
786 source_grasp.domain(),
787 npub,
788 identifier
789 );
790 let clone_url_syncing = format!("http://{}/{}/{}.git", syncing_domain, npub, identifier);
791
792 // Create announcement with custom clone/relay URLs
793 // Clone URLs: source_grasp + syncing (NOT git_server - PR commit only via PR's clone tag)
794 // Relay URLs: source_grasp + mock_relay + syncing
795 let announcement = nostr_sdk::EventBuilder::new(
796 nostr_sdk::Kind::Custom(30617),
797 "Repository for PR clone tag + partial OID test",
798 )
799 .tags(vec![
800 nostr_sdk::Tag::identifier(identifier),
801 nostr_sdk::Tag::custom(
802 nostr_sdk::TagKind::custom("clone"),
803 vec![clone_url_source.clone(), clone_url_syncing.clone()],
804 ),
805 nostr_sdk::Tag::custom(
806 nostr_sdk::TagKind::custom("relays"),
807 vec![
808 source_grasp.url().to_string(),
809 mock_relay.url().to_string(),
810 format!("ws://{}", syncing_domain),
811 ],
812 ),
813 ])
814 .sign_with_keys(&owner_keys)
815 .expect("Failed to sign announcement");
816
817 // Connect to source_grasp and send announcement
818 let source_client = Client::new(owner_keys.clone());
819 source_client
820 .add_relay(source_grasp.url())
821 .await
822 .expect("Failed to add source_grasp relay");
823 source_client.connect().await;
824 tokio::time::sleep(Duration::from_millis(500)).await;
825
826 source_client
827 .send_event(&announcement)
828 .await
829 .expect("Failed to send announcement to source_grasp");
830 tokio::time::sleep(Duration::from_millis(200)).await;
831
832 // Create state event referencing commit_a
833 let state_event = create_state_event(
834 &owner_keys,
835 identifier,
836 &[("main", &commit_a)],
837 &[],
838 &[&clone_url_source, &clone_url_syncing],
839 &[
840 source_grasp.url(),
841 mock_relay.url(),
842 &format!("ws://{}", syncing_domain),
843 ],
844 )
845 .expect("Failed to create state event");
846
847 let state_event_id = state_event.id;
848
849 // Send state event to source_grasp (goes to purgatory - no git data yet)
850 source_client
851 .send_event(&state_event)
852 .await
853 .expect("Failed to send state event to source_grasp");
854 tokio::time::sleep(Duration::from_millis(200)).await;
855
856 // Push main branch (commit_a) to source_grasp - releases state event
857 push_to_relay(repo_a.path(), &source_grasp.domain(), &npub, identifier)
858 .expect("Push to source_grasp should succeed");
859
860 // Verify state event is served on source_grasp
861 wait_for_event_served(source_grasp.url(), &state_event_id, Duration::from_secs(5))
862 .await
863 .expect("State event should be served on source_grasp after push");
864
865 // ========================================================================
866 // Step 4: Setup mock_relay with PR event
867 // ========================================================================
868
869 // First, send announcement to mock_relay so it has the repo context
870 // This is needed because the sync system filters events based on whether
871 // they reference repos that list our relay
872 let mock_client = Client::new(owner_keys.clone());
873 mock_client
874 .add_relay(mock_relay.url())
875 .await
876 .expect("Failed to add mock_relay for announcement");
877 mock_client.connect().await;
878 tokio::time::sleep(Duration::from_millis(500)).await;
879
880 mock_client
881 .send_event(&announcement)
882 .await
883 .expect("Failed to send announcement to mock_relay");
884 tokio::time::sleep(Duration::from_millis(200)).await;
885
886 let repo_coord = build_repo_coord(&owner_keys, identifier);
887
888 // Create PR event with clone tag pointing to git_server
889 // This is the KEY part - the PR's clone tag provides the URL for commit_b
890 let pr_event = create_pr_event_with_clone(
891 &pr_author_keys,
892 &repo_coord,
893 &commit_b,
894 "Test PR for partial OID aggregation",
895 &[git_server.url()], // Clone URL points to SimpleGitServer
896 )
897 .expect("Failed to create PR event");
898
899 let pr_event_id = pr_event.id;
900
901 // Send PR event to mock_relay
902 // MockRelay accepts all events without validation (no purgatory)
903 let pr_client = Client::new(pr_author_keys.clone());
904 pr_client
905 .add_relay(mock_relay.url())
906 .await
907 .expect("Failed to add mock_relay");
908 pr_client.connect().await;
909 tokio::time::sleep(Duration::from_millis(500)).await;
910
911 pr_client
912 .send_event(&pr_event)
913 .await
914 .expect("Failed to send PR event to mock_relay");
915
916 // Verify PR event is served on mock_relay (immediate, no purgatory)
917 wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(5))
918 .await
919 .expect("PR event should be served on mock_relay immediately");
920
921 // ========================================================================
922 // Step 5: Start syncing_relay WITHOUT bootstrap and publish announcement directly
923 // ========================================================================
924
925 // Start syncing_relay with sync enabled but NO bootstrap relay
926 // This tests relay discovery from announcement's `relays` tag
927 // Note: We disable negentropy because MockRelay doesn't support NIP-77,
928 // and the sync system doesn't properly fall back to REQ+EOSE when negentropy fails.
929 let syncing_relay = TestRelay::start_on_port_with_options(
930 syncing_port,
931 None, // NO bootstrap - relay discovery via announcement tags
932 true, // Disable negentropy - MockRelay doesn't support NIP-77
933 )
934 .await;
935
936 // Publish announcement DIRECTLY to syncing_relay
937 // This triggers relay discovery from the announcement's `relays` tag
938 let syncing_client = Client::new(owner_keys.clone());
939 syncing_client
940 .add_relay(syncing_relay.url())
941 .await
942 .expect("Failed to add syncing_relay");
943 syncing_client.connect().await;
944 tokio::time::sleep(Duration::from_millis(500)).await;
945
946 syncing_client
947 .send_event(&announcement)
948 .await
949 .expect("Failed to send announcement to syncing_relay");
950 tokio::time::sleep(Duration::from_millis(200)).await;
951
952 // Wait for relay discovery and sync connections to establish
953 // syncing_relay should discover source_grasp and mock_relay from announcement's relays tag
954 println!("=== Waiting for sync connections ===");
955 println!("syncing_relay URL: {}", syncing_relay.url());
956 println!("source_grasp URL: {}", source_grasp.url());
957 println!("mock_relay URL: {}", mock_relay.url());
958 println!("git_server URL: {}", git_server.url());
959
960 wait_for_sync_connection(syncing_relay.url(), 2, Duration::from_secs(10))
961 .await
962 .expect(
963 "Sync connections should establish to discovered relays (source_grasp + mock_relay)",
964 );
965 println!("Sync connections established!");
966
967 // Debug: Check metrics to see what relays are connected
968 let metrics_url = syncing_relay
969 .url()
970 .replace("ws://", "http://")
971 .replace("/", "")
972 + "/metrics";
973 println!("Checking metrics at: {}", metrics_url);
974 if let Ok(response) = reqwest::get(&metrics_url).await {
975 if let Ok(metrics) = response.text().await {
976 // Print sync-related metrics
977 for line in metrics.lines() {
978 if line.contains("sync") && !line.starts_with('#') {
979 println!(" {}", line);
980 }
981 }
982 }
983 }
984
985 // Give some time for sync to happen
986 println!("Waiting 10s for events to sync...");
987 tokio::time::sleep(Duration::from_secs(10)).await;
988
989 // Check metrics again after waiting
990 println!("=== Checking metrics after sync wait ===");
991 if let Ok(response) = reqwest::get(&metrics_url).await {
992 if let Ok(metrics) = response.text().await {
993 for line in metrics.lines() {
994 if line.contains("sync") && !line.starts_with('#') {
995 println!(" {}", line);
996 }
997 }
998 }
999 }
1000
1001 // Debug: Check if PR event is still on mock_relay
1002 println!("=== Debug: Checking PR event on mock_relay ===");
1003 let pr_on_mock =
1004 wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(2)).await;
1005 println!("PR event on mock_relay: {:?}", pr_on_mock.is_ok());
1006 if let Ok(ref pr) = pr_on_mock {
1007 println!("PR event tags:");
1008 for tag in pr.tags.iter() {
1009 println!(" {:?}", tag.as_slice());
1010 }
1011 }
1012
1013 // Debug: Check repo coordinate
1014 let repo_coord = build_repo_coord(&owner_keys, identifier);
1015 println!("Expected repo coordinate: {}", repo_coord);
1016
1017 // Debug: Test if mock_relay responds to tag-based filter (Layer 2 style)
1018 println!("=== Debug: Testing mock_relay tag filter response ===");
1019 let test_client = Client::new(Keys::generate());
1020 test_client
1021 .add_relay(mock_relay.url())
1022 .await
1023 .expect("Failed to add mock_relay");
1024 test_client.connect().await;
1025 tokio::time::sleep(Duration::from_millis(500)).await;
1026
1027 // Build a Layer 2 style filter (by 'a' tag)
1028 let tag_filter =
1029 Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::A), repo_coord.as_str());
1030 println!("Tag filter: {:?}", tag_filter);
1031
1032 let tag_results = test_client
1033 .fetch_events(tag_filter, Duration::from_secs(5))
1034 .await;
1035 match tag_results {
1036 Ok(events) => {
1037 println!("Tag filter returned {} events", events.len());
1038 for event in events.iter() {
1039 println!(" Event ID: {}, Kind: {}", event.id, event.kind.as_u16());
1040 }
1041 }
1042 Err(e) => {
1043 println!("Tag filter query failed: {:?}", e);
1044 }
1045 }
1046 test_client.disconnect().await;
1047
1048 // The syncing relay will:
1049 // 1. Receive announcement directly (creates bare repo)
1050 // 2. Discover source_grasp and mock_relay from announcement's `relays` tag
1051 // 3. Connect to discovered relays
1052 // 4. Sync state event from source_grasp → purgatory (no commit_a locally)
1053 // 5. Sync PR event from mock_relay → purgatory (no commit_b locally)
1054 // 6. Purgatory sync triggers
1055 // 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag)
1056 // 8. Fetches commit_b from git_server (from PR event's clone tag)
1057 // 9. Both events released when all OIDs available
1058
1059 // ========================================================================
1060 // Step 6: Verify Results
1061 // ========================================================================
1062
1063 println!("=== Step 6: Verify Results ===");
1064 println!("State event ID: {}", state_event_id);
1065 println!("PR event ID: {}", pr_event_id);
1066 println!("commit_a: {}", commit_a);
1067 println!("commit_b: {}", commit_b);
1068
1069 // Wait for state event to be served on syncing_relay
1070 println!("Waiting for state event on syncing_relay...");
1071 let state_found = wait_for_event_served(
1072 syncing_relay.url(),
1073 &state_event_id,
1074 Duration::from_secs(30),
1075 )
1076 .await;
1077 println!("State event result: {:?}", state_found);
1078 assert!(
1079 state_found.is_ok(),
1080 "State event should be served on syncing_relay: {:?}",
1081 state_found.err()
1082 );
1083
1084 // Wait for PR event to be served on syncing_relay
1085 println!("Waiting for PR event on syncing_relay...");
1086 let pr_found =
1087 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
1088 println!("PR event result: {:?}", pr_found);
1089 assert!(
1090 pr_found.is_ok(),
1091 "PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}",
1092 pr_found.err()
1093 );
1094
1095 // Verify refs/heads/main → commit_a (from source_grasp)
1096 let main_correct = check_ref_at_commit(
1097 &syncing_domain,
1098 &npub,
1099 identifier,
1100 "refs/heads/main",
1101 &commit_a,
1102 )
1103 .await
1104 .expect("Failed to check main ref");
1105 assert!(
1106 main_correct,
1107 "main should point to commit_a ({}) from source_grasp",
1108 commit_a
1109 );
1110
1111 // Verify refs/nostr/<event-id> → commit_b (from git_server via PR clone tag)
1112 let pr_ref = format!("refs/nostr/{}", pr_event_id.to_hex());
1113 let pr_correct = check_ref_at_commit(&syncing_domain, &npub, identifier, &pr_ref, &commit_b)
1114 .await
1115 .expect("Failed to check PR ref");
1116 assert!(
1117 pr_correct,
1118 "PR ref should point to commit_b ({}) fetched from git_server via PR clone tag",
1119 commit_b
1120 );
1121
1122 // ========================================================================
1123 // Step 7: Cleanup
1124 // ========================================================================
1125
1126 source_client.disconnect().await;
1127 mock_client.disconnect().await;
1128 pr_client.disconnect().await;
1129 syncing_client.disconnect().await;
1130 git_server.stop().await;
1131 mock_relay.stop().await;
1132 syncing_relay.stop().await;
1133 source_grasp.stop().await;
1134}