From e75a5c1e96e13691ec5180c8fa1b71d7f9324818 Mon Sep 17 00:00:00 2001 From: sufforest Date: Fri, 3 Jul 2026 06:29:05 -0700 Subject: [PATCH 1/2] fix(sync): apply senders/contains_url filters, gate historical leaves by include_leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sync-filter gaps: - Room timeline/state filters ignored the `senders` allow-list and `contains_url` — only types/not_types/not_senders applied. Share one RoomEventFilter predicate and add both (not_senders keeps precedence over senders; contains_url matches a string url, Synapse-style). The state filter now also reaches MSC4222 `state_after`, where Element carries its state, so it was previously a no-op there for the primary client. - `include_leave` (spec default false) was ignored: an initial sync returned every room the user had ever left. Historical left rooms now appear only when the filter opts in, while a room left within the sync window is still always surfaced so the client learns of the leave. --- vela-api/src/sync/filters.rs | 314 +++++++++++++++++---------- vela-api/src/sync/mod.rs | 44 +++- vela-api/tests/sync_include_leave.rs | 111 ++++++++++ 3 files changed, 350 insertions(+), 119 deletions(-) create mode 100644 vela-api/tests/sync_include_leave.rs diff --git a/vela-api/src/sync/filters.rs b/vela-api/src/sync/filters.rs index 5d2584c..c4b437a 100644 --- a/vela-api/src/sync/filters.rs +++ b/vela-api/src/sync/filters.rs @@ -199,78 +199,99 @@ pub async fn get_filter( Ok(Json(f)) } -/// Apply the timeline-side of a sync filter to a single room sync block. -/// Edits the `timeline.events` array in place. -/// -/// Supported filter fields (spec subset): -/// - `room.timeline.limit`: cap event count. -/// - `room.timeline.types`: allow-list event types. -/// - `room.timeline.not_types`: deny-list event types. -/// - `room.timeline.not_senders`: deny-list senders. -/// - `room.timeline.rooms`/`not_rooms`: applied at the per-room loop, not here. -/// -/// The `state`, `ephemeral`, `presence`, and `account_data` sub-filters are -/// applied by their own helpers (`apply_state_filter` / `apply_event_filter`) -/// at the call sites. `event_format` / `event_fields` remain accepted-but- -/// ignored. -pub fn apply_timeline_filter(room: &mut Value, timeline_filter: &Value) { - let Some(events) = room - .pointer_mut("/timeline/events") - .and_then(|v| v.as_array_mut()) - else { - return; - }; - - let allow_types = timeline_filter - .get("types") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect::>() - }); - let deny_types = timeline_filter - .get("not_types") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect::>() - }); - let deny_senders = timeline_filter - .get("not_senders") - .and_then(|v| v.as_array()) - .map(|a| { +/// Apply a `RoomEventFilter`'s event-level predicates to an event array in +/// place: `types`/`not_types` (with `m.x.*` wildcards), `senders` (allow-list) +/// / `not_senders` (deny-list), and `contains_url` (keep only events that have +/// — or, if `false`, lack — a `content.url` key). Shared by the timeline and +/// state sync sections, which both carry a `RoomEventFilter`. `limit` is the +/// caller's concern: only the timeline honours it, and only after the +/// most-recent trim. Room events always carry a sender, so the sender lists +/// apply unconditionally (unlike the sender-less `apply_event_filter`). +fn retain_room_event_filter(events: &mut Vec, filter: &Value) { + let str_list = |k: &str| { + filter.get(k).and_then(|v| v.as_array()).map(|a| { a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) + .filter_map(|v| v.as_str().map(String::from)) .collect::>() - }); + }) + }; + let allow_types = str_list("types"); + let deny_types = str_list("not_types"); + let allow_senders = str_list("senders"); + let deny_senders = str_list("not_senders"); + let contains_url = filter.get("contains_url").and_then(|v| v.as_bool()); events.retain(|e| { let etype = e.event_type().unwrap_or(""); let sender = e.sender().unwrap_or(""); - if let Some(allow) = &allow_types { - // Wildcard support: `m.room.*` matches `m.room.message` etc. - let ok = allow.iter().any(|p| type_matches(etype, p)); - if !ok { - return false; - } + if let Some(allow) = &allow_types + && !allow.iter().any(|p| type_matches(etype, p)) + { + return false; } if let Some(deny) = &deny_types && deny.iter().any(|p| type_matches(etype, p)) { return false; } + // `not_senders` takes precedence over `senders` per spec: a sender in + // both lists is excluded. Checking the deny-list first honours that. if let Some(deny) = &deny_senders && deny.iter().any(|s| s == sender) { return false; } + if let Some(allow) = &allow_senders + && !allow.iter().any(|s| s == sender) + { + return false; + } + if let Some(want_url) = contains_url { + // Match Synapse: a `url` key whose value is a string. `{"url": + // null}` or a non-string url counts as "no url", so the two + // servers agree on which events a `contains_url` filter keeps. + let has_url = e + .get("content") + .and_then(|c| c.get("url")) + .is_some_and(|u| u.is_string()); + if has_url != want_url { + return false; + } + } true }); +} + +/// Apply the timeline-side of a sync filter to a single room sync block. +/// Edits the `timeline.events` array in place. +/// +/// Supported filter fields (spec subset): +/// - `room.timeline.limit`: cap event count. +/// - `room.timeline.types` / `not_types`: allow/deny event types. +/// - `room.timeline.senders` / `not_senders`: allow/deny senders. +/// - `room.timeline.contains_url`: keep only events with/without `content.url`. +/// - `room.timeline.rooms`/`not_rooms`: applied at the per-room loop, not here. +/// +/// The `state`, `ephemeral`, `presence`, and `account_data` sub-filters are +/// applied by their own helpers (`apply_state_filter` / `apply_event_filter`) +/// at the call sites. `event_format` / `event_fields` remain accepted-but- +/// ignored. +/// +/// Known limitation: the timeline is already trimmed to `limit` upstream +/// (the DB query), then filtered here, so a highly selective predicate +/// (`contains_url`, a narrow `senders`) can return fewer than `limit` +/// matching events even when more exist earlier in history. Matching +/// Synapse (filter in the query, then limit) would require pushing these +/// predicates into the timeline read. +pub fn apply_timeline_filter(room: &mut Value, timeline_filter: &Value) { + let Some(events) = room + .pointer_mut("/timeline/events") + .and_then(|v| v.as_array_mut()) + else { + return; + }; + + retain_room_event_filter(events, timeline_filter); if let Some(limit) = timeline_filter .get("limit") @@ -285,65 +306,20 @@ pub fn apply_timeline_filter(room: &mut Value, timeline_filter: &Value) { } } -/// Apply the state-side of a sync filter to a single room sync block. -/// Edits the `state.events` array in place. Same shape as the timeline -/// filter (the spec uses `RoomEventFilter` for both). +/// Apply the state-side of a sync filter to a single room sync block. Same +/// shape as the timeline filter (the spec uses `RoomEventFilter` for both). +/// +/// A room block carries state under the legacy `state` key or, under MSC4222, +/// the `state_after` key — built internally under the stable name and renamed +/// for the client afterwards, so at this point it's always `state_after`. +/// Element opts into MSC4222, so filtering only `/state/events` would be a +/// no-op for the primary client; we filter whichever key is present. pub fn apply_state_filter(room: &mut Value, state_filter: &Value) { - let Some(events) = room - .pointer_mut("/state/events") - .and_then(|v| v.as_array_mut()) - else { - return; - }; - - let allow_types = state_filter - .get("types") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect::>() - }); - let deny_types = state_filter - .get("not_types") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect::>() - }); - let deny_senders = state_filter - .get("not_senders") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect::>() - }); - - events.retain(|e| { - let etype = e.event_type().unwrap_or(""); - let sender = e.sender().unwrap_or(""); - if let Some(allow) = &allow_types - && !allow.iter().any(|p| type_matches(etype, p)) - { - return false; + for pointer in ["/state/events", "/state_after/events"] { + if let Some(events) = room.pointer_mut(pointer).and_then(|v| v.as_array_mut()) { + retain_room_event_filter(events, state_filter); } - if let Some(deny) = &deny_types - && deny.iter().any(|p| type_matches(etype, p)) - { - return false; - } - if let Some(deny) = &deny_senders - && deny.iter().any(|s| s == sender) - { - return false; - } - true - }); + } } /// Apply an `EventFilter`'s `types` / `not_types` / `senders` / `not_senders` @@ -735,4 +711,122 @@ mod tests { .collect(); assert_eq!(kinds, vec!["@alice:s"]); } + + fn timeline_senders(room: &Value) -> Vec { + room["timeline"]["events"] + .as_array() + .unwrap() + .iter() + .map(|e| e["sender"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn timeline_senders_allow_list() { + let mk = |s: &str| json!({"type": "m.room.message", "sender": s, "content": {}}); + let mut room = json!({"timeline": {"events": [mk("@a:s"), mk("@b:s"), mk("@c:s")]}}); + apply_timeline_filter(&mut room, &json!({"senders": ["@a:s", "@b:s"]})); + assert_eq!(timeline_senders(&room), vec!["@a:s", "@b:s"]); + } + + #[test] + fn not_senders_takes_precedence_over_senders() { + // A sender listed in both is excluded (spec). + let mk = |s: &str| json!({"type": "m.room.message", "sender": s, "content": {}}); + let mut room = json!({"timeline": {"events": [mk("@a:s"), mk("@b:s")]}}); + apply_timeline_filter( + &mut room, + &json!({"senders": ["@a:s", "@b:s"], "not_senders": ["@a:s"]}), + ); + assert_eq!(timeline_senders(&room), vec!["@b:s"]); + } + + #[test] + fn contains_url_filters_timeline_and_state() { + let with_url = + json!({"type": "m.room.message", "sender": "@a:s", "content": {"url": "mxc://x/y"}}); + let no_url = json!({"type": "m.room.message", "sender": "@a:s", "content": {"body": "hi"}}); + + // true → keep only events with a content.url key. + let mut room = json!({"timeline": {"events": [with_url.clone(), no_url.clone()]}}); + apply_timeline_filter(&mut room, &json!({"contains_url": true})); + let evs = room["timeline"]["events"].as_array().unwrap(); + assert_eq!(evs.len(), 1); + assert!(evs[0]["content"].get("url").is_some()); + + // false → drop events that have a url. + let mut room = json!({"timeline": {"events": [with_url.clone(), no_url.clone()]}}); + apply_timeline_filter(&mut room, &json!({"contains_url": false})); + let evs = room["timeline"]["events"].as_array().unwrap(); + assert_eq!(evs.len(), 1); + assert!(evs[0]["content"].get("url").is_none()); + + // state filter honours it too. + let mut room = json!({"state": {"events": [with_url, no_url]}}); + apply_state_filter(&mut room, &json!({"contains_url": true})); + assert_eq!(room["state"]["events"].as_array().unwrap().len(), 1); + + // omitted → no url-based filtering. + let mut room = json!({"timeline": {"events": [ + json!({"type": "m.room.message", "sender": "@a:s", "content": {"url": "mxc://x/y"}}), + json!({"type": "m.room.message", "sender": "@a:s", "content": {}}), + ]}}); + apply_timeline_filter(&mut room, &json!({})); + assert_eq!(room["timeline"]["events"].as_array().unwrap().len(), 2); + } + + #[test] + fn contains_url_requires_a_string_url() { + // A null or non-string url counts as "no url" (Synapse parity), so + // contains_url:true drops them and contains_url:false keeps them. + let null_url = + json!({"type": "m.room.message", "sender": "@a:s", "content": {"url": null}}); + let int_url = json!({"type": "m.room.message", "sender": "@a:s", "content": {"url": 5}}); + let no_content = json!({"type": "m.room.message", "sender": "@a:s"}); + + let mut room = json!({"timeline": {"events": [null_url.clone(), int_url.clone(), no_content.clone()]}}); + apply_timeline_filter(&mut room, &json!({"contains_url": true})); + assert_eq!( + room["timeline"]["events"].as_array().unwrap().len(), + 0, + "no string url anywhere → contains_url:true keeps nothing" + ); + + let mut room = json!({"timeline": {"events": [null_url, int_url, no_content]}}); + apply_timeline_filter(&mut room, &json!({"contains_url": false})); + assert_eq!( + room["timeline"]["events"].as_array().unwrap().len(), + 3, + "contains_url:false keeps events without a string url" + ); + } + + #[test] + fn empty_senders_list_excludes_all() { + // `senders: []` is present-but-empty → no sender matches → everything + // is dropped (distinct from an absent `senders`, which filters nothing). + let mk = |s: &str| json!({"type": "m.room.message", "sender": s, "content": {}}); + let mut room = json!({"timeline": {"events": [mk("@a:s"), mk("@b:s")]}}); + apply_timeline_filter(&mut room, &json!({"senders": []})); + assert!(room["timeline"]["events"].as_array().unwrap().is_empty()); + } + + #[test] + fn state_filter_applies_under_state_after() { + // MSC4222 rooms carry state under `state_after`, not `state`. The + // state filter must reach it or it's a no-op for Element. + let ev = |t: &str, sender: &str| json!({"type": t, "state_key": "", "sender": sender, "content": {}}); + let mut room = json!({"state_after": {"events": [ + ev("m.room.topic", "@a:s"), + ev("m.room.name", "@b:s"), + ]}}); + apply_state_filter(&mut room, &json!({"not_senders": ["@b:s"]})); + let types: Vec<&str> = room["state_after"]["events"] + .as_array() + .unwrap() + .iter() + .map(|e| e["type"].as_str().unwrap()) + .collect(); + assert_eq!(types, vec!["m.room.topic"], "state_after must be filtered"); + } } diff --git a/vela-api/src/sync/mod.rs b/vela-api/src/sync/mod.rs index f7f631d..84e9924 100644 --- a/vela-api/src/sync/mod.rs +++ b/vela-api/src/sync/mod.rs @@ -562,9 +562,19 @@ pub(crate) fn build_sync_response_inner( .db .get_user_left_rooms(user.user_nid) .map_err(|e| ApiError(VelaError::Store(e.to_string())))?; + // A room left within this sync window is always surfaced so the client + // learns of the leave. Historical left rooms (left at or before the last + // sync — and, on an initial sync, all of them, since nothing is "new") + // appear only when the room filter sets include_leave (spec default false). + let include_leave = room_filter + .and_then(|rf| rf.get("include_leave")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); let mut leave_rooms = serde_json::Map::new(); for &room_nid in &left_room_nids { - if !membership_changed_since(state, user.user_nid, room_nid, since)? { + let newly_left = + since.is_some() && membership_changed_since(state, user.user_nid, room_nid, since)?; + if !newly_left && !include_leave { continue; } let room_id = state @@ -3712,27 +3722,43 @@ mod tests { } #[test] - fn leave_appears_on_initial_and_disappears_on_incremental_after_pos() { + fn historical_leave_is_gated_by_include_leave() { let (state, _tmp) = build_test_state(); let db = &state.db; let room_nid = db.get_or_create_nid("!leftroom:example.com").unwrap(); let user = fake_user(&state, "@carol:example.com"); db.set_membership(room_nid, user.user_nid, 0).unwrap(); + let has_leave = |r: &Value| { + r.pointer("/rooms/leave") + .and_then(|v| v.as_object()) + .is_some_and(|o| o.contains_key("!leftroom:example.com")) + }; + + // Initial sync, no filter: include_leave defaults to false, so a room + // left before the (nonexistent) window must NOT appear. let resp = build_sync_response(&state, &user, &[], None).unwrap(); - let leaves = resp.pointer("/rooms/leave").unwrap().as_object().unwrap(); - assert!(leaves.contains_key("!leftroom:example.com")); + assert!( + !has_leave(&resp), + "historical left room must not appear on an initial sync without include_leave" + ); + // Same initial sync with include_leave=true → it appears. + let filter = json!({"room": {"include_leave": true}}); + let resp = build_sync_response_with_filter(&state, &user, &[], None, Some(&filter), false) + .unwrap(); + assert!( + has_leave(&resp), + "include_leave=true must surface the historical left room" + ); + + // Incremental sync past the leave position, no filter → no stale leave. let pos = db .get_user_room_membership_pos(user.user_nid, room_nid) .unwrap() .unwrap(); let resp = build_sync_response(&state, &user, &[], Some(pos)).unwrap(); - let has_stale_leave = resp - .pointer("/rooms/leave") - .and_then(|v| v.as_object()) - .is_some_and(|o| o.contains_key("!leftroom:example.com")); - assert!(!has_stale_leave, "stale leave should not reappear"); + assert!(!has_leave(&resp), "stale leave should not reappear"); } #[test] diff --git a/vela-api/tests/sync_include_leave.rs b/vela-api/tests/sync_include_leave.rs new file mode 100644 index 0000000..8f293c9 --- /dev/null +++ b/vela-api/tests/sync_include_leave.rs @@ -0,0 +1,111 @@ +//! `/sync` must honour the room filter's `include_leave` (spec default: +//! false). A room the user left before the sync window (and, on an initial +//! sync, any left room) is surfaced only when `include_leave` is set — but a +//! room left *within* the window is always surfaced so the client learns of +//! the leave. + +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{Value, json}; + +use common::{Harness, read_json}; + +async fn sync(h: &Harness, tok: &str, since: Option<&str>, filter_id: Option<&str>) -> Value { + let mut url = "/_matrix/client/v3/sync?timeout=0".to_string(); + if let Some(s) = since { + url.push_str(&format!("&since={s}")); + } + if let Some(f) = filter_id { + url.push_str(&format!("&filter={f}")); + } + let resp = h + .request( + Request::get(&url) + .header("authorization", format!("Bearer {tok}")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + read_json(resp).await +} + +async fn leave(h: &Harness, tok: &str, room: &str) { + let resp = h + .request( + Request::post(format!("/_matrix/client/v3/rooms/{room}/leave")) + .header("authorization", format!("Bearer {tok}")) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK, "leave failed"); +} + +async fn store_filter(h: &Harness, user: &str, tok: &str, body: Value) -> String { + let resp = h + .request( + Request::post(format!("/_matrix/client/v3/user/{user}/filter")) + .header("authorization", format!("Bearer {tok}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + read_json(resp).await["filter_id"] + .as_str() + .unwrap() + .to_string() +} + +#[tokio::test] +async fn initial_sync_excludes_historical_left_rooms_unless_include_leave() { + let h = Harness::new(); + let (alice, tok) = h.register("alice", "pw").await; + let room = h.create_room(&tok, json!({})).await; + leave(&h, &tok, &room).await; + + // Default filter (include_leave omitted → false): the left room is a + // historical leave on an initial sync and must not appear. + let s = sync(&h, &tok, None, None).await; + assert!( + s["rooms"]["leave"].get(&room).is_none(), + "left room leaked on an initial sync without include_leave: {:?}", + s["rooms"]["leave"] + ); + + // include_leave=true surfaces it. + let fid = store_filter(&h, &alice, &tok, json!({"room": {"include_leave": true}})).await; + let s = sync(&h, &tok, None, Some(&fid)).await; + assert!( + s["rooms"]["leave"].get(&room).is_some(), + "include_leave=true must surface the historical left room: {:?}", + s["rooms"]["leave"] + ); +} + +#[tokio::test] +async fn leave_within_window_is_always_surfaced() { + let h = Harness::new(); + let (_alice, tok) = h.register("alice", "pw").await; + let room = h.create_room(&tok, json!({})).await; + + // Anchor a since token before the leave. + let init = sync(&h, &tok, None, None).await; + let since = init["next_batch"].as_str().unwrap().to_string(); + + leave(&h, &tok, &room).await; + + // Incremental sync from before the leave, with the default filter: the + // client must still learn it left, even without include_leave. + let s = sync(&h, &tok, Some(&since), None).await; + assert!( + s["rooms"]["leave"].get(&room).is_some(), + "a leave within the sync window must be surfaced regardless of include_leave: {:?}", + s["rooms"]["leave"] + ); +} From ea36d1c913b8817c03362ec2c9c1e6998872d56b Mon Sep 17 00:00:00 2001 From: sufforest Date: Fri, 3 Jul 2026 07:05:06 -0700 Subject: [PATCH 2/2] fix(sync): include_leave must not re-surface historical leaves on incremental sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut gated left rooms on `newly_left || include_leave`, which made a room left long ago reappear in the leave section on every incremental sync when include_leave was set — breaking Complement's TestOlderLeftRoomsNotInLeaveSection. A left room now surfaces on an incremental sync only when the membership changed within the window; an initial or full-state sync includes historical left rooms exactly when include_leave is set. --- vela-api/src/sync/mod.rs | 36 ++++++++++++++++++++++++---- vela-api/tests/sync_include_leave.rs | 34 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/vela-api/src/sync/mod.rs b/vela-api/src/sync/mod.rs index 84e9924..fb18799 100644 --- a/vela-api/src/sync/mod.rs +++ b/vela-api/src/sync/mod.rs @@ -562,19 +562,24 @@ pub(crate) fn build_sync_response_inner( .db .get_user_left_rooms(user.user_nid) .map_err(|e| ApiError(VelaError::Store(e.to_string())))?; - // A room left within this sync window is always surfaced so the client - // learns of the leave. Historical left rooms (left at or before the last - // sync — and, on an initial sync, all of them, since nothing is "new") - // appear only when the room filter sets include_leave (spec default false). let include_leave = room_filter .and_then(|rf| rf.get("include_leave")) .and_then(|v| v.as_bool()) .unwrap_or(false); let mut leave_rooms = serde_json::Map::new(); for &room_nid in &left_room_nids { + // A room whose membership changed within this sync window (the leave + // itself) is always surfaced so the client learns of the leave. A + // historical left room is surfaced only on a full view of the account + // — an initial sync or a full_state sync — and only when the filter + // opts in via include_leave (spec default false). This is what stops + // include_leave from re-surfacing an already-reported leave on every + // incremental sync (Complement's TestOlderLeftRoomsNotInLeaveSection). let newly_left = since.is_some() && membership_changed_since(state, user.user_nid, room_nid, since)?; - if !newly_left && !include_leave { + let full_sync = since.is_none() || full_state; + let show = newly_left || (full_sync && include_leave); + if !show { continue; } let room_id = state @@ -3759,6 +3764,27 @@ mod tests { .unwrap(); let resp = build_sync_response(&state, &user, &[], Some(pos)).unwrap(); assert!(!has_leave(&resp), "stale leave should not reappear"); + + // Crucially, include_leave must NOT re-surface an already-reported + // historical leave on an incremental sync — the room was left before + // the window, so it appears once (on the leave) and never again. + let resp = + build_sync_response_with_filter(&state, &user, &[], Some(pos), Some(&filter), false) + .unwrap(); + assert!( + !has_leave(&resp), + "include_leave must not re-surface a historical leave on incremental sync" + ); + + // A full-state sync, by contrast, behaves like an initial sync: the + // historical leave reappears when include_leave is set. + let resp = + build_sync_response_with_filter(&state, &user, &[], Some(pos), Some(&filter), true) + .unwrap(); + assert!( + has_leave(&resp), + "a full-state sync with include_leave should surface historical left rooms" + ); } #[test] diff --git a/vela-api/tests/sync_include_leave.rs b/vela-api/tests/sync_include_leave.rs index 8f293c9..ae28b71 100644 --- a/vela-api/tests/sync_include_leave.rs +++ b/vela-api/tests/sync_include_leave.rs @@ -109,3 +109,37 @@ async fn leave_within_window_is_always_surfaced() { s["rooms"]["leave"] ); } + +#[tokio::test] +async fn historical_leave_not_resurfaced_on_incremental_even_with_include_leave() { + // Complement's TestOlderLeftRoomsNotInLeaveSection: once a leave has been + // reported, include_leave must not make it reappear on every later + // incremental sync. + let h = Harness::new(); + let (alice, tok) = h.register("alice", "pw").await; + let room = h.create_room(&tok, json!({})).await; + let fid = store_filter(&h, &alice, &tok, json!({"room": {"include_leave": true}})).await; + + let s1 = sync(&h, &tok, None, Some(&fid)).await; + let since1 = s1["next_batch"].as_str().unwrap().to_string(); + + leave(&h, &tok, &room).await; + + // The sync that spans the leave surfaces it once. + let s2 = sync(&h, &tok, Some(&since1), Some(&fid)).await; + assert!( + s2["rooms"]["leave"].get(&room).is_some(), + "the leave should appear on the incremental sync that spans it" + ); + let since2 = s2["next_batch"].as_str().unwrap().to_string(); + + // A later incremental sync (past the leave), still with include_leave, must + // NOT re-surface the now-historical leave. + let s3 = sync(&h, &tok, Some(&since2), Some(&fid)).await; + assert!( + s3["rooms"]["leave"].get(&room).is_none(), + "a historical leave must not reappear on a later incremental sync even with \ + include_leave: {:?}", + s3["rooms"]["leave"] + ); +}