Commitsdfffe9e+13ae501landed 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 showsce9e12bwas 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)".ce9e12bchanged 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. Whatce9e12bgot 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.
234 lines
8 KiB
Rust
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(®);
|
|
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");
|
|
}
|