nigig-org/crates/apps/nigig-site/tests/sync_e2e.rs
Arena Agent 12f59109d7 revert(nigig-site): ce9e12b was wrong — the HTTP wire field is body, not text
Commits dfffe9e + 13ae501 landed nigig-lite/crates/common (package
`nigig-common`) and a vendored xitca-web into this repository, which makes the
/sync wire format readable for the first time. Reading it shows ce9e12b was
incorrect.

There are TWO MessageContent types, deliberately:
- nigig_common::MessageContent::Text { body, formatted_body, mentions } — the
  HTTP/JSON REST DTO. Its own doc-comment: "Serialises externally tagged,
  e.g. {"Text":{"body":"hi",…}}". This is what SendMessageRequest.content and
  TimelineEvent::Message.content are.
- nimanyatta_protocol::MessageContent::Text { text } — the WebSocket chat
  protocol, serialized with postcard. nigig-common's manifest says that format
  "must match nimanyatta/crates/nimanyatta-protocol (the client library)".

ce9e12b changed the E2E test's outgoing posts from {"Text":{"body":…}} to
{"Text":{"text":…}}, reasoning from the WebSocket type. The original `body`
was correct for these HTTP endpoints; my change would have made both requests
fail deserialization. Reverted, and the now-unused nimanyatta-protocol
dev-dependency is dropped from nigig-site.

What ce9e12b got right is kept: the parser had keyed on "type":"message",
but TimelineEvent carries no serde tag attribute, so it is externally tagged —
{"Message":{…,"sender":…,"content":…}} with sender and content at the variant
level. event_text still accepts that envelope (plus the legacy lowercase shape
and `text` as last-resort field names).

The fixture's cross-check test was rebuilt against nigig_common's real shape
instead of the WebSocket type. It is NOT verified by execution: nigig-site's
test target pulls in makepad-widgets, which is SIGKILLed on this host. The
parser was verified instead by extracting event_text/sync_timelines verbatim
into a standalone harness over four cases — nigig_common Message with `body`
-> Some(("alice","slab done")); Membership -> None; Redaction -> None; legacy
lowercase -> Some(("bob","old")). All pass. The harness is scratch, not
shipped.

EXECUTION_PLAN.md SITE-12 rewritten: the sibling repos are no longer missing,
the /sync DTOs are named with their file paths, both MessageContent types are
distinguished, and the narrower remaining blockers are stated — nimanyatta
still cannot be a workspace member (xitca-web/web/Cargo.toml inherits
rust-version and lints from a workspace root, and nested here it resolves to
ours, which defines neither), and a full `cargo check --features b_server` has
not completed on this host, so no build claim is made.

Verified: cargo test -p nimanyatta-protocol = 14; cargo test -p nigig-site-core
= 228 / 1 ignored; clippy -D warnings clean; cargo fmt --check clean;
cargo metadata exit 0.
2026-09-26 17:39:20 +00:00

234 lines
8 KiB
Rust

#![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<String, &'static str> {
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::<std::net::IpAddr>().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(&reg);
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");
}