#![cfg(target_os = "linux")] //! Live end-to-end test against a real `nimanyatta` server. //! //! Run: start the server first, then //! ```sh //! NIMANYATTA_E2E_URL=http://127.0.0.1:8080 cargo test -p nigig-site --test sync_e2e //! ``` //! This test is explicitly ignored in ordinary test runs because it requires //! a separately launched real server. The dedicated interoperability job runs //! it with `--ignored --exact live_round_trip`; missing configuration is then //! a hard failure, never an early-returning green test. It exercises a //! test-only candidate contract: register → create room → send text → `/sync` //! timeline → snapshot push → messages fetch → decode → merge. SITE-01 ships //! none of this transport or snapshot-codec surface in the production library. #[path = "support/nimanyatta_fixture.rs"] mod nimanyatta_fixture; #[path = "support/sync_fixture.rs"] mod sync_fixture; use reqwest::blocking::Client; use serde_json::{json, Value}; fn normalize_loopback_base_url(value: &str) -> Result { let url = reqwest::Url::parse(value.trim()).map_err(|_| "URL is invalid")?; if url.scheme() != "http" { return Err("only loopback HTTP is supported by the test server"); } let is_loopback = url .host_str() .map(|host| host.trim_start_matches('[').trim_end_matches(']')) .and_then(|host| host.parse::().ok()) .is_some_and(|ip| ip.is_loopback()); if !is_loopback || url.port().is_none() { return Err("an explicit loopback IP and port are required"); } if !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") { return Err("credentials, query, fragment, and path are forbidden"); } Ok(url.as_str().trim_end_matches('/').to_string()) } fn required_base_url() -> String { let value = std::env::var("NIMANYATTA_E2E_URL") .expect("NIMANYATTA_E2E_URL is required by the dedicated live-server job"); normalize_loopback_base_url(&value) .unwrap_or_else(|reason| panic!("invalid NIMANYATTA_E2E_URL: {reason}")) } #[test] fn live_server_url_contract_is_loopback_only() { assert_eq!( normalize_loopback_base_url(" http://127.0.0.1:8080/ "), Ok("http://127.0.0.1:8080".to_string()) ); assert_eq!( normalize_loopback_base_url("http://[::1]:9090"), Ok("http://[::1]:9090".to_string()) ); for invalid in [ "https://127.0.0.1:8080", "http://localhost:8080", "http://192.168.1.20:8080", "http://127.0.0.1", "http://user:secret@127.0.0.1:8080", "http://127.0.0.1:8080/api", "not-a-url", ] { assert!( normalize_loopback_base_url(invalid).is_err(), "accepted unsafe live-server URL: {invalid}" ); } } fn token_of(v: &Value) -> String { v.pointer("/tokens/access_token/token") .or_else(|| v.pointer("/access_token/token")) .or_else(|| v.pointer("/access_token")) .and_then(|t| t.as_str()) .unwrap_or_default() .to_string() } fn id_string(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Object(_) => v .get("id") .and_then(|x| x.as_str()) .unwrap_or_default() .to_string(), _ => String::new(), } } #[test] #[ignore = "requires a pinned local nimanyatta server; run only in the dedicated interoperability job"] fn live_round_trip() { let base = required_base_url(); let client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .unwrap(); let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); // 1. Register. let reg: Value = client .post(format!("{base}/api/v1/auth/register")) .json(&json!({ "username": format!("e2e_{stamp}"), "password": "Password123!", "display_name": "E2E", "device_display_name": "e2e-test" })) .send() .expect("register reachable") .json() .expect("register json"); let token = token_of(®); assert!( !token.is_empty(), "server must issue an access token: {reg}" ); // 2. Create a project room using the test-only candidate shape. let room: Value = client .post(format!("{base}/api/v1/rooms/create")) .bearer_auth(&token) .json(&json!({ "name": "Site — E2E", "topic": "e2e", "is_direct": false, "invite": [] })) .send() .expect("create room reachable") .json() .expect("create room json"); let room_id = room .get("room_id") .map(id_string) .filter(|s| !s.is_empty()) .unwrap_or_else(|| panic!("create room must return room_id: {room}")); // 3. Send a text message using the test-only candidate envelope. let marker = format!("e2e-marker-{stamp}"); let send: Value = client .post(format!("{base}/api/v1/rooms/{room_id}/send")) .bearer_auth(&token) .json(&json!({ "content": { "Text": { "body": marker, "formatted_body": null, "mentions": [] } }, "reply_to": null })) .send() .expect("send reachable") .json() .expect("send json"); assert!( send.get("event_id").is_some(), "send must return event_id: {send}" ); // 4. `/sync` must carry the message back in a parseable timeline. let sync: Value = client .post(format!("{base}/api/v1/sync")) .bearer_auth(&token) .json(&json!({ "timeout_ms": 1000 })) .send() .expect("sync reachable") .json() .expect("sync json"); let sync_text = serde_json::to_string(&sync).unwrap(); assert!( sync_text.contains(&marker), "sync timeline must contain our marker" ); let timelines = nimanyatta_fixture::sync_timelines(&sync_text); assert!( timelines .iter() .any(|(_, lines)| lines.iter().any(|l| l.contains(&marker))), "app parser must extract the marker: {timelines:?}" ); // 5. Test-only candidate snapshot push → fetch → decode → merge. let mut store = nigig_site::store::SiteStore::default(); store.sites.push(nigig_site::domain::site::Site::new( "E2E Site", nigig_site::domain::site::SiteNature::Road, "x", )); let chunks = sync_fixture::encode_snapshot(&store, &format!("e2e-{stamp}")); assert!(!chunks.is_empty()); for chunk in &chunks { let res: Value = client .post(format!("{base}/api/v1/rooms/{room_id}/send")) .bearer_auth(&token) .json(&json!({ "content": { "Text": { "body": chunk, "formatted_body": null, "mentions": [] } }, "reply_to": null })) .send() .expect("chunk send reachable") .json() .expect("chunk send json"); assert!(res.get("event_id").is_some()); } let messages: Value = client .post(format!("{base}/api/v1/rooms/{room_id}/messages")) .bearer_auth(&token) .json(&json!({ "limit": 100 })) .send() .expect("messages reachable") .json() .expect("messages json"); let bodies = nimanyatta_fixture::timeline_bodies(&serde_json::to_string(&messages).unwrap()); let pulled = sync_fixture::extract_chunks_from_bodies(&bodies); assert_eq!(pulled.len(), chunks.len(), "all chunks must come back"); let remote = sync_fixture::decode_snapshot(&pulled).expect("snapshot must reassemble"); let mut local = nigig_site::store::SiteStore::default(); let stats = sync_fixture::merge_remote(&mut local, remote); assert_eq!(stats.sites_added, 1); assert_eq!(local.sites[0].name, "E2E Site"); }