From e335cac8f7cf98083b6b4bb9f63ee50b7dc2ad92 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 23 Mar 2026 20:37:03 +0100 Subject: [PATCH] fix(timeline): validate beacon_info content for session matching --- .../src/timeline/controller/aggregations.rs | 56 ++-- .../src/timeline/event_handler.rs | 27 +- .../event_item/content/live_location.rs | 17 ++ .../src/timeline/event_item/content/mod.rs | 7 +- .../src/timeline/event_item/mod.rs | 3 +- .../src/timeline/tests/live_location.rs | 240 +++++++++++++++--- 6 files changed, 276 insertions(+), 74 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs b/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs index 5418f2c36..07ae29953 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs @@ -57,6 +57,7 @@ use super::{ObservableItemsTransaction, rfind_event_by_item_id}; use crate::timeline::{ BeaconInfo, EventTimelineItem, LiveLocationState, MsgLikeContent, MsgLikeKind, PollState, ReactionInfo, ReactionStatus, TimelineEventItemId, TimelineItem, TimelineItemContent, + event_item::beacon_info_matches, }; #[derive(Clone)] @@ -425,17 +426,10 @@ pub(crate) struct Aggregations { /// A pending beacon-stop aggregation received before the corresponding live /// `beacon_info` start item has arrived. /// - /// Keyed by the sender's user ID (the state key of both the start and stop - /// `beacon_info` events). At most one stop can be pending per sender at any - /// time: `beacon_info` is a state event whose state key is the sender's - /// user ID, so there is only ever one live session per sender, and - /// therefore only one meaningful pending stop. A later stop simply - /// replaces an earlier one. - /// - /// When the live start item is eventually inserted via `add_item`, the - /// entry here is promoted into [`Self::related_events`] under the - /// now-known start event ID so that [`Self::apply_all`] can apply the - /// stop immediately. + /// Keyed by the sender's user ID. When a live start item is eventually + /// inserted via `add_item`, we check if the pending stop matches and + /// promote it into [`Self::related_events`] so that [`Self::apply_all`] + /// can apply it immediately. pending_beacon_stops: HashMap, } @@ -452,22 +446,42 @@ impl Aggregations { /// [`Self::related_events`] (and thus picked up by [`Self::apply_all`]) /// when the live item is inserted via /// [`Self::promote_pending_beacon_stop`]. - /// - /// If a stop was already stashed for this sender, the new one replaces it: - /// the later state event is always authoritative. pub fn add_pending_beacon_stop(&mut self, sender: OwnedUserId, aggregation: Aggregation) { self.pending_beacon_stops.insert(sender, aggregation); } - /// Promote any stashed beacon-stop aggregations for `sender` into the + /// Promote a matching stashed beacon-stop aggregation for `sender` into the /// regular aggregation map, now that the live start item's /// `target_event_id` is known. /// + /// The pending stop's content must match the start event's content (except + /// for the `live` field) for promotion to occur. If they don't match, the + /// pending stop is discarded because it belongs to a different session. + /// /// Should be called from `add_item` just before `apply_all`, when inserting /// a live `beacon_info` item. - fn promote_pending_beacon_stop(&mut self, sender: &OwnedUserId, target_event_id: OwnedEventId) { + fn promote_pending_beacon_stop( + &mut self, + sender: &OwnedUserId, + target_event_id: OwnedEventId, + start_content: &BeaconInfoEventContent, + ) { + if !start_content.live { + return; + } + let Some(stop) = self.pending_beacon_stops.remove(sender) else { return }; + let AggregationKind::BeaconStop { content: stop_content } = &stop.kind else { + warn!("pending beacon stop has unexpected aggregation kind"); + return; + }; + + if !beacon_info_matches(start_content, stop_content) { + trace!("discarding stale pending beacon stop (content mismatch)"); + return; + } + let target = TimelineEventItemId::EventId(target_event_id); self.add(target, stop); } @@ -620,8 +634,14 @@ impl Aggregations { // in `pending_beacon_stops` keyed by sender. Promote it into // `related_events` under the now-known start event ID so the loop below // applies it together with any other pending aggregations. - if let TimelineEventItemId::EventId(event_id) = item_id { - self.promote_pending_beacon_stop(sender, event_id.clone()); + // + // The promotion verifies that the pending stop's content matches the + // start event's content to ensure we don't apply an old stop to a new + // session. + if let TimelineEventItemId::EventId(event_id) = item_id + && let Some(live_location) = event.content().as_live_location_state() + { + self.promote_pending_beacon_stop(sender, event_id.clone(), &live_location.beacon_info); } let Some(aggregations) = self.related_events.get(item_id) else { diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 71bd086fe..7683906bb 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -338,6 +338,8 @@ impl TimelineAction { // existing live item from the same sender rather than creating a // new timeline item. Self::HandleAggregation { + // There is no explicit relates_to on a beacon_info state event; + // the target is identified by sender in handle_beacon_stop. related_event: ev.event_id, kind: HandleAggregationKind::BeaconStop { content: ev.content }, } @@ -772,27 +774,30 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { /// Handle a stop `beacon_info` state event by finding the existing live /// `LiveLocation` timeline item from the same sender and updating it via /// the aggregation system. + /// + /// The stop event's content must match the start item's content (except for + /// the `live` field) to ensure we apply the stop to the correct session. #[instrument(skip(self, content))] fn handle_beacon_stop(&mut self, content: BeaconInfoEventContent) { let sender = &self.ctx.sender; + // Find the live start item by sender and matching content. + let target_event_id = super::algorithms::rfind_event_item(self.items, |item| { + item.sender() == sender + && item.content().as_live_location_state().is_some_and(|s| s.matches_stop(&content)) + }) + .and_then(|(_, event_item)| event_item.inner.event_id().map(ToOwned::to_owned)); + let aggregation = Aggregation::new( self.ctx.flow.timeline_item_id(), AggregationKind::BeaconStop { content }, ); - // The stop beacon_info has no explicit `relates_to`; find the target - // live item by matching sender and liveness, then extract its event ID - // so we can address the aggregation correctly. - let Some(target_event_id) = super::algorithms::rfind_event_item(self.items, |item| { - item.sender() == sender - && item.content().as_live_location_state().is_some_and(|s| s.is_live()) - }) - .and_then(|(_, event_item)| event_item.inner.event_id().map(ToOwned::to_owned)) else { - // The live start item hasn't arrived yet. Stash the stop so it can - // be applied when the start item is eventually inserted. + let Some(target_event_id) = target_event_id else { + // The live start item hasn't arrived yet (or the content doesn't match). + // Stash the stop so it can be applied when the matching start item arrives. trace!( - "no live beacon_info item found for {sender}; \ + "no matching live beacon_info item found for {sender}; \ stashing stop event to apply when the start item arrives" ); self.meta.aggregations.add_pending_beacon_stop(sender.clone(), aggregation); diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs index e02f5bfba..0d91c2113 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs @@ -175,4 +175,21 @@ impl LiveLocationState { assert!(!beacon_info.is_live(), "A stop `beacon_info` event must not be live."); self.beacon_info = beacon_info; } + + /// Check if a stop `beacon_info` matches this session. + /// + /// Returns `true` if all fields except `live` match and this session is + /// still live. This is used to verify that a stop event belongs to the + /// same session as this start event. + pub(in crate::timeline) fn matches_stop(&self, stop: &BeaconInfoEventContent) -> bool { + self.beacon_info.live && beacon_info_matches(&self.beacon_info, stop) + } +} + +/// Check if two `BeaconInfoEventContent` values belong to the same session. +/// +/// Compares all fields except `live`, which differs between start and stop +/// events. Returns `true` if all other fields match. +pub fn beacon_info_matches(a: &BeaconInfoEventContent, b: &BeaconInfoEventContent) -> bool { + a.ts == b.ts && a.timeout == b.timeout && a.description == b.description && a.asset == b.asset } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index 3ad6016ec..b41830516 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -62,8 +62,11 @@ mod reply; pub use pinned_events::RoomPinnedEventsChange; -pub(in crate::timeline) use self::message::{ - extract_bundled_edit_event_json, extract_poll_edit_content, extract_room_msg_edit_content, +pub(in crate::timeline) use self::{ + live_location::beacon_info_matches, + message::{ + extract_bundled_edit_event_json, extract_poll_edit_content, extract_room_msg_edit_content, + }, }; pub use self::{ live_location::{BeaconInfo, LiveLocationState}, diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index 82b712c68..997d45995 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -49,7 +49,8 @@ pub use self::{ }; pub(super) use self::{ content::{ - extract_bundled_edit_event_json, extract_poll_edit_content, extract_room_msg_edit_content, + beacon_info_matches, extract_bundled_edit_event_json, extract_poll_edit_content, + extract_room_msg_edit_content, }, local::LocalEventTimelineItem, remote::{RemoteEventOrigin, RemoteEventTimelineItem}, diff --git a/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs b/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs index b6cc42636..53c8083c3 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs @@ -21,12 +21,14 @@ use eyeball_im::VectorDiff; use matrix_sdk_test::{ALICE, BOB, async_test}; use ruma::{ EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, event_id, - events::beacon_info::RedactedBeaconInfoEventContent, owned_event_id, uint, + events::beacon_info::{BeaconInfoEventContent, RedactedBeaconInfoEventContent}, + owned_event_id, uint, }; use stream_assert::{assert_next_matches, assert_pending}; use crate::timeline::{ - EventTimelineItem, ReactionStatus, TimelineEventItemId, tests::TestTimeline, + EventTimelineItem, ReactionStatus, TimelineEventItemId, event_item::beacon_info_matches, + tests::TestTimeline, }; /// A `beacon_info` state event creates a `MsgLikeKind::LiveLocation` @@ -44,6 +46,7 @@ async fn test_beacon_info_creates_timeline_item() { Some("Alice's walk".to_owned()), Duration::from_secs(3600), true, + None, ) .await; @@ -70,16 +73,7 @@ async fn test_beacon_info_with_expired_timeout_still_creates_item() { let past_ts = MilliSecondsSinceUnixEpoch(uint!(1_000)); // Very early timestamp let short_duration = Duration::from_millis(1); // Effectively expired immediately - timeline - .send_beacon_info_with_ts( - &ALICE, - beacon_id, - None, - short_duration, - true, - Some(past_ts), - ) - .await; + timeline.send_beacon_info(&ALICE, beacon_id, None, short_duration, true, Some(past_ts)).await; // The item should still be created even though the timeout has expired. let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); @@ -101,7 +95,7 @@ async fn test_beacon_info_stopped_without_start_produces_no_item() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_stop:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(60), false).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(60), false, None).await; assert_pending!(stream); @@ -119,7 +113,7 @@ async fn test_beacon_update_aggregates_onto_beacon_info() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); let state = item.content().as_live_location_state().unwrap(); @@ -152,7 +146,7 @@ async fn test_multiple_beacon_updates_accumulate_in_order() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let _item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); // Send in non-chronological order. @@ -204,7 +198,9 @@ async fn test_beacon_update_before_beacon_info_is_applied_when_parent_arrives() ); // Now the beacon_info arrives. - timeline.send_beacon_info(&ALICE, &beacon_id, None, Duration::from_secs(3600), true).await; + timeline + .send_beacon_info(&ALICE, &beacon_id, None, Duration::from_secs(3600), true, None) + .await; // A single PushBack with the stashed location already applied. let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); @@ -232,6 +228,7 @@ async fn test_multiple_users_sharing_produce_independent_items() { Some("Alice".to_owned()), Duration::from_secs(3600), true, + None, ) .await; @@ -246,6 +243,7 @@ async fn test_multiple_users_sharing_produce_independent_items() { Some("Bob".to_owned()), Duration::from_secs(3600), true, + None, ) .await; @@ -318,7 +316,12 @@ async fn test_beacon_stop_updates_existing_item() { let start_id = event_id!("$beacon_start:example.org"); let stop_id = event_id!("$beacon_stop:example.org"); - timeline.send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true).await; + // Both start and stop use the same session timestamp. + let session_ts = MilliSecondsSinceUnixEpoch::now(); + + timeline + .send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true, Some(session_ts)) + .await; let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); assert!(item.content().as_live_location_state().unwrap().is_live()); @@ -326,7 +329,9 @@ async fn test_beacon_stop_updates_existing_item() { assert_pending!(stream); - timeline.send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false).await; + timeline + .send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false, Some(session_ts)) + .await; // The existing item is updated — a Set diff, not a PushBack. let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); @@ -349,23 +354,30 @@ async fn test_beacon_stop_preserves_locations() { let start_id = event_id!("$beacon_start:example.org"); let stop_id = event_id!("$beacon_stop:example.org"); - timeline.send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true).await; + // Both start and stop use the same session timestamp. + let session_ts = MilliSecondsSinceUnixEpoch::now(); + + timeline + .send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true, Some(session_ts)) + .await; let _item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); - let ts = MilliSecondsSinceUnixEpoch(uint!(1_000)); - timeline.send_beacon_location(&ALICE, start_id, 51.5, 0.1, ts).await; + let location_ts = MilliSecondsSinceUnixEpoch(uint!(1_000)); + timeline.send_beacon_location(&ALICE, start_id, 51.5, 0.1, location_ts).await; let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); assert_eq!(item.content().as_live_location_state().unwrap().locations().len(), 1); // Stop the session. - timeline.send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false).await; + timeline + .send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false, Some(session_ts)) + .await; let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); let state = item.content().as_live_location_state().unwrap(); assert!(!state.is_live(), "should be stopped"); assert_eq!(state.locations().len(), 1, "location updates should be preserved after stop"); - assert_eq!(state.latest_location().unwrap().ts(), ts); + assert_eq!(state.latest_location().unwrap().ts(), location_ts); assert_pending!(stream); } @@ -380,8 +392,13 @@ async fn test_beacon_stop_before_start_is_applied_later() { let start_id = event_id!("$beacon_start:example.org"); let stop_id = event_id!("$beacon_stop:example.org"); + // Both start and stop use the same session timestamp. + let session_ts = MilliSecondsSinceUnixEpoch::now(); + // Send the stop event first — the live start item doesn't exist yet. - timeline.send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false).await; + timeline + .send_beacon_info(&ALICE, stop_id, None, Duration::from_secs(3600), false, Some(session_ts)) + .await; // No item should have been added to the timeline yet. assert_pending!(stream); @@ -391,7 +408,9 @@ async fn test_beacon_stop_before_start_is_applied_later() { ); // Now send the live start event. - timeline.send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true).await; + timeline + .send_beacon_info(&ALICE, start_id, None, Duration::from_secs(3600), true, Some(session_ts)) + .await; // The item should appear already stopped — a single PushBack, no follow-up Set. let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); @@ -404,6 +423,66 @@ async fn test_beacon_stop_before_start_is_applied_later() { assert_pending!(stream); } +/// A pending beacon stop from an OLD session should NOT be applied to a NEW +/// session. This tests the scenario where: +/// 1. User starts session A +/// 2. Stop for session A arrives out-of-order and is stashed +/// 3. User starts session B — the stashed stop should NOT apply +#[async_test] +async fn test_pending_beacon_stop_not_applied_to_different_session() { + let timeline = TestTimeline::new(); + let mut stream = timeline.subscribe_events().await; + let old_stop_id = event_id!("$old_stop:example.org"); + let new_start_id = event_id!("$new_start:example.org"); + + // Use the current time as base for session B. + // Session A's timestamp doesn't matter since its stop will be discarded. + // Session B needs a recent timestamp so is_live() doesn't fail due to timeout. + let old_session_ts = MilliSecondsSinceUnixEpoch(uint!(1)); // Old session (past) + let new_session_ts = MilliSecondsSinceUnixEpoch::now(); // New session (now) + + // A stop event from the OLD session arrives first (out-of-order). + // Its corresponding start event is missing (maybe it's from long ago). + timeline + .send_beacon_info( + &ALICE, + old_stop_id, + None, + Duration::from_secs(3600), + false, + Some(old_session_ts), + ) + .await; + + // No item should appear — there's no start item for this stop. + assert_pending!(stream); + + // Now a NEW session starts with a DIFFERENT timestamp. + timeline + .send_beacon_info( + &ALICE, + new_start_id, + Some("New session".to_owned()), + Duration::from_secs(3600), + true, + Some(new_session_ts), + ) + .await; + + // The new session should appear and remain LIVE because the pending stop + // belongs to a different session (different ts). + let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + let state = item.content().as_live_location_state().expect("should be a live location item"); + + assert!( + state.is_live(), + "the new session should be live; the old session's stop should NOT apply" + ); + assert_eq!(state.description(), Some("New session")); + + assert_pending!(stream); +} + /// Duplicate beacon location updates (same timestamp) are de-duplicated. #[async_test] async fn test_duplicate_beacon_location_is_deduplicated() { @@ -411,7 +490,7 @@ async fn test_duplicate_beacon_location_is_deduplicated() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let _item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); let ts = MilliSecondsSinceUnixEpoch(uint!(1_000)); @@ -466,7 +545,7 @@ async fn test_reaction_on_live_location_item() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); assert!(item.content().as_live_location_state().is_some()); @@ -495,7 +574,7 @@ async fn test_multiple_reactions_on_live_location_item() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let _item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); // ALICE and BOB both react, with different keys. @@ -531,7 +610,7 @@ async fn test_reaction_before_live_location_item_is_applied_when_parent_arrives( assert_pending!(stream); // Now the beacon_info arrives. - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; // The item is inserted with the reaction already applied. let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); @@ -552,7 +631,7 @@ async fn test_local_reaction_on_live_location_item() { let mut stream = timeline.subscribe_events().await; let beacon_id = event_id!("$beacon_info:example.org"); - timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true, None).await; let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); let item_id = TimelineEventItemId::EventId(item.event_id().unwrap().to_owned()); @@ -583,6 +662,95 @@ async fn test_local_reaction_on_live_location_item() { assert_pending!(stream); } +/// `beacon_info_matches` returns true for identical content. +#[test] +fn test_beacon_info_matches_identical_content() { + let ts = MilliSecondsSinceUnixEpoch(uint!(1_000_000)); + let a = BeaconInfoEventContent::new( + Some("Test".to_owned()), + Duration::from_secs(3600), + true, + Some(ts), + ); + let b = BeaconInfoEventContent::new( + Some("Test".to_owned()), + Duration::from_secs(3600), + true, + Some(ts), + ); + + assert!(beacon_info_matches(&a, &b)); +} + +/// `beacon_info_matches` ignores the `live` field (start vs stop). +#[test] +fn test_beacon_info_matches_ignores_live_field() { + let ts = MilliSecondsSinceUnixEpoch(uint!(1_000_000)); + let start = BeaconInfoEventContent::new( + Some("Test".to_owned()), + Duration::from_secs(3600), + true, + Some(ts), + ); + let stop = BeaconInfoEventContent::new( + Some("Test".to_owned()), + Duration::from_secs(3600), + false, + Some(ts), + ); + + assert!(beacon_info_matches(&start, &stop), "should match even though live differs"); +} + +/// `beacon_info_matches` returns false for different timestamps. +#[test] +fn test_beacon_info_matches_different_ts() { + let a = BeaconInfoEventContent::new( + None, + Duration::from_secs(3600), + true, + Some(MilliSecondsSinceUnixEpoch(uint!(1_000))), + ); + let b = BeaconInfoEventContent::new( + None, + Duration::from_secs(3600), + true, + Some(MilliSecondsSinceUnixEpoch(uint!(2_000))), + ); + + assert!(!beacon_info_matches(&a, &b), "different ts should not match"); +} + +/// `beacon_info_matches` returns false for different timeouts. +#[test] +fn test_beacon_info_matches_different_timeout() { + let ts = MilliSecondsSinceUnixEpoch(uint!(1_000_000)); + let a = BeaconInfoEventContent::new(None, Duration::from_secs(3600), true, Some(ts)); + let b = BeaconInfoEventContent::new(None, Duration::from_secs(7200), true, Some(ts)); + + assert!(!beacon_info_matches(&a, &b), "different timeout should not match"); +} + +/// `beacon_info_matches` returns false for different descriptions. +#[test] +fn test_beacon_info_matches_different_description() { + let ts = MilliSecondsSinceUnixEpoch(uint!(1_000_000)); + let a = BeaconInfoEventContent::new( + Some("Session A".to_owned()), + Duration::from_secs(3600), + true, + Some(ts), + ); + let b = BeaconInfoEventContent::new( + Some("Session B".to_owned()), + Duration::from_secs(3600), + true, + Some(ts), + ); + + assert!(!beacon_info_matches(&a, &b), "different description should not match"); +} + impl TestTimeline { /// Collect every event timeline item (no virtual items). async fn live_location_event_items(&self) -> Vec { @@ -597,18 +765,6 @@ impl TestTimeline { description: Option, duration: Duration, live: bool, - ) { - self.send_beacon_info_with_ts(sender, event_id, description, duration, live, None).await; - } - - /// Convenience: send a `beacon_info` state event with a custom timestamp. - async fn send_beacon_info_with_ts( - &self, - sender: &ruma::UserId, - event_id: &EventId, - description: Option, - duration: Duration, - live: bool, ts: Option, ) { let event = self