atagen

macltok

public
Star0
Watch0
74befbc72baf5d21a371cff0b839aba9c142afd0
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
tests/mentions_inbox.rsRust1108 lines36.8 KB
1
mod support;
2
3
use std::{
4
    collections::{BTreeMap, HashSet},
5
    sync::{
6
        Arc, Mutex,
7
        atomic::{AtomicUsize, Ordering},
8
    },
9
    time::Duration,
10
};
11
12
#[cfg(feature = "sqlite")]
13
use macltok::{EncryptedSessionStore, PasswordLogin, Restore, Session, SessionStore, StoreConfig};
14
use macltok::{Engine, EngineConfig, Event, MentionReason, MentionsPage};
15
use matrix_sdk::{
16
    Client,
17
    ruma::{OwnedRoomId, OwnedUserId, event_id, events::Mentions, room_id, user_id},
18
    test_utils::mocks::{MatrixMockServer, RoomContextResponseTemplate},
19
};
20
use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
21
use ruma::push::{
22
    Action, EventMatchConditionData, HighlightTweakValue, NewConditionalPushRule,
23
    NewPatternedPushRule, PushCondition, Ruleset, SoundTweakValue, Tweak,
24
};
25
use serde_json::{Value, json};
26
#[cfg(feature = "sqlite")]
27
use support::receive_until;
28
use support::shut_down;
29
use wiremock::{
30
    Mock, Request, Respond, ResponseTemplate,
31
    matchers::{method, path, path_regex, query_param},
32
};
33
34
#[cfg(feature = "serde")]
35
use macltok::{MentionCoverage, MentionsBoundary, MentionsCommand};
36
37
const SLIDING_SYNC_PATH: &str = "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
38
39
struct SlidingFeed {
40
    rooms: Arc<Mutex<BTreeMap<OwnedRoomId, Vec<Value>>>>,
41
    required_state: Arc<Mutex<BTreeMap<OwnedRoomId, Vec<Value>>>>,
42
    requests: Arc<AtomicUsize>,
43
}
44
45
impl SlidingFeed {
46
    fn new(room_id: OwnedRoomId, events: Vec<Value>) -> Self {
47
        Self {
48
            rooms: Arc::new(Mutex::new(BTreeMap::from([(room_id, events)]))),
49
            required_state: Arc::new(Mutex::new(BTreeMap::new())),
50
            requests: Arc::new(AtomicUsize::new(0)),
51
        }
52
    }
53
54
    fn replace(&self, room_id: &ruma::RoomId, events: Vec<Value>) {
55
        self.rooms
56
            .lock()
57
            .unwrap()
58
            .insert(room_id.to_owned(), events);
59
    }
60
61
    fn add_room(&self, room_id: &ruma::RoomId, events: Vec<Value>) {
62
        self.rooms
63
            .lock()
64
            .unwrap()
65
            .insert(room_id.to_owned(), events);
66
    }
67
68
    fn set_required_state(&self, room_id: &ruma::RoomId, events: Vec<Value>) {
69
        self.required_state
70
            .lock()
71
            .unwrap()
72
            .insert(room_id.to_owned(), events);
73
    }
74
75
    async fn mount(&self, server: &MatrixMockServer) {
76
        Mock::given(method("POST"))
77
            .and(path(SLIDING_SYNC_PATH))
78
            .respond_with(self.clone())
79
            .mount(server.server())
80
            .await;
81
    }
82
}
83
84
impl Clone for SlidingFeed {
85
    fn clone(&self) -> Self {
86
        Self {
87
            rooms: self.rooms.clone(),
88
            required_state: self.required_state.clone(),
89
            requests: self.requests.clone(),
90
        }
91
    }
92
}
93
94
impl Respond for SlidingFeed {
95
    fn respond(&self, _request: &Request) -> ResponseTemplate {
96
        let request = self.requests.fetch_add(1, Ordering::AcqRel);
97
        let required_state = self.required_state.lock().unwrap().clone();
98
        let rooms: serde_json::Map<String, Value> = self
99
            .rooms
100
            .lock()
101
            .unwrap()
102
            .iter()
103
            .map(|(room_id, events)| {
104
                (
105
                    room_id.to_string(),
106
                    json!({
107
                        "initial": request == 0,
108
                        "required_state": required_state
109
                            .get(room_id)
110
                            .cloned()
111
                            .unwrap_or_default(),
112
                        "timeline": events,
113
                    }),
114
                )
115
            })
116
            .collect();
117
        ResponseTemplate::new(200)
118
            .set_body_json(json!({
119
                "txn_id": null,
120
                "pos": format!("mentions-{request}"),
121
                "lists": {},
122
                "rooms": rooms,
123
                "extensions": {},
124
            }))
125
            .set_delay(Duration::from_millis(20))
126
    }
127
}
128
129
async fn joined_client(server: &MatrixMockServer, room_id: &ruma::RoomId) -> (Client, OwnedUserId) {
130
    let client = server.client_builder().build().await;
131
    let own_user = client.user_id().unwrap().to_owned();
132
    let room_id = room_id.to_owned();
133
    server
134
        .mock_sync()
135
        .ok_and_run(&client, {
136
            let own_user = own_user.clone();
137
            move |builder| {
138
                let factory = EventFactory::new().room(&room_id);
139
                builder.add_global_account_data(
140
                    factory.push_rules(Ruleset::server_default(&own_user)),
141
                );
142
                builder.add_joined_room(
143
                    JoinedRoomBuilder::new(&room_id).add_state_event(factory.member(&own_user)),
144
                );
145
            }
146
        })
147
        .await;
148
    (client, own_user)
149
}
150
151
fn message(
152
    event_id: &str,
153
    sender: &str,
154
    timestamp: u64,
155
    body: &str,
156
    mentioned: Option<&str>,
157
) -> Value {
158
    let mut content = json!({ "msgtype": "m.text", "body": body });
159
    if let Some(mentioned) = mentioned {
160
        content["m.mentions"] = json!({ "user_ids": [mentioned] });
161
    }
162
    json!({
163
        "type": "m.room.message",
164
        "event_id": event_id,
165
        "sender": sender,
166
        "origin_server_ts": timestamp,
167
        "content": content,
168
    })
169
}
170
171
fn room_mention(event_id: &str, sender: &str, timestamp: u64) -> Value {
172
    let mut event = message(event_id, sender, timestamp, "room mention", None);
173
    event["content"]["m.mentions"] = json!({ "room": true });
174
    event
175
}
176
177
async fn mentions_page(
178
    events: &mut macltok::EventStream,
179
    room_id: &ruma::RoomId,
180
    predicate: impl Fn(&MentionsPage) -> bool,
181
) -> MentionsPage {
182
    mentions_page_with_diagnostics(events, room_id, predicate, None).await
183
}
184
185
async fn mentions_page_with_diagnostics(
186
    events: &mut macltok::EventStream,
187
    room_id: &ruma::RoomId,
188
    predicate: impl Fn(&MentionsPage) -> bool,
189
    diagnostic_server: Option<&MatrixMockServer>,
190
) -> MentionsPage {
191
    let mut last_page = None;
192
    let result = tokio::time::timeout(Duration::from_secs(5), async {
193
        while let Some(event) = events.recv().await {
194
            match event {
195
                Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id => {
196
                    panic!("the unopened room produced a timeline reset");
197
                }
198
                Event::MentionsChanged(update) => {
199
                    let snapshot = update.snapshot;
200
                    let matched = predicate(&snapshot);
201
                    last_page = Some(snapshot.clone());
202
                    if matched {
203
                        return snapshot;
204
                    }
205
                }
206
                _ => {}
207
            }
208
        }
209
        panic!("event stream closed before the expected mentions page");
210
    })
211
    .await;
212
213
    match result {
214
        Ok(page) => page,
215
        Err(_) => {
216
            let last_state = last_page.map(|page: MentionsPage| {
217
                (
218
                    page.revision,
219
                    page.unread_count,
220
                    page.entries
221
                        .iter()
222
                        .map(|entry| {
223
                            (
224
                                entry.key.room_id.to_string(),
225
                                entry.key.event_id.to_string(),
226
                                entry.capture.clone(),
227
                                entry.revision,
228
                            )
229
                        })
230
                        .collect::<Vec<_>>(),
231
                )
232
            });
233
            let requests = if let Some(server) = diagnostic_server {
234
                server.server().received_requests().await.map(|requests| {
235
                    requests
236
                        .iter()
237
                        .filter(|request| {
238
                            let path = request.url.path();
239
                            path.contains(room_id.as_str())
240
                                && (path.contains("/context/") || path.ends_with("/messages"))
241
                        })
242
                        .map(|request| format!("{} {}", request.method, request.url))
243
                        .collect::<Vec<_>>()
244
                })
245
            } else {
246
                None
247
            };
248
            panic!(
249
                "expected mentions update did not arrive, last state {last_state:?}, requests {requests:?}"
250
            );
251
        }
252
    }
253
}
254
255
fn entry_ids(page: &MentionsPage) -> HashSet<String> {
256
    page.entries
257
        .iter()
258
        .map(|entry| entry.key.event_id.to_string())
259
        .collect()
260
}
261
262
async fn wait_for_requests(feed: &SlidingFeed, minimum: usize) {
263
    tokio::time::timeout(Duration::from_secs(5), async {
264
        while feed.requests.load(Ordering::Acquire) < minimum {
265
            tokio::task::yield_now().await;
266
        }
267
    })
268
    .await
269
    .expect("the sliding sync feed did not receive another request");
270
}
271
272
async fn mount_messages_page(
273
    server: &MatrixMockServer,
274
    room_id: &ruma::RoomId,
275
    direction: &str,
276
    token: &str,
277
    limit: u16,
278
) {
279
    Mock::given(method("GET"))
280
        .and(path_regex(format!(
281
            r"^/_matrix/client/v3/rooms/{room_id}/messages$"
282
        )))
283
        .and(query_param("dir", direction))
284
        .and(query_param("from", token))
285
        .and(move |request: &Request| {
286
            request
287
                .url
288
                .query_pairs()
289
                .find(|(key, _)| key == "limit")
290
                .map_or(limit == 10, |(_, value)| value == limit.to_string())
291
        })
292
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
293
            "start": token,
294
            "end": format!("{token}-next"),
295
            "chunk": [],
296
            "state": [],
297
        })))
298
        .expect(1)
299
        .mount(server.server())
300
        .await;
301
}
302
303
#[tokio::test]
304
async fn unopened_room_mentions_are_deduplicated_and_read_through_uses_a_revision_boundary() {
305
    let server = MatrixMockServer::new().await;
306
    let room_id = room_id!("!mentions-inbox:localhost");
307
    let (client, own_user) = joined_client(&server, room_id).await;
308
    let sender = user_id!("@alice:localhost");
309
    let initial = vec![
310
        message(
311
            "$mention",
312
            sender.as_str(),
313
            2_000,
314
            "direct mention",
315
            Some(own_user.as_str()),
316
        ),
317
        message(
318
            "$own-mention",
319
            own_user.as_str(),
320
            2_100,
321
            "own event",
322
            Some(own_user.as_str()),
323
        ),
324
        message(
325
            "$unrelated",
326
            sender.as_str(),
327
            2_200,
328
            "ordinary message",
329
            None,
330
        ),
331
    ];
332
    let feed = SlidingFeed::new(room_id.to_owned(), initial.clone());
333
    feed.mount(&server).await;
334
335
    let (engine, handle, mut events) = Engine::from_client(
336
        client,
337
        EngineConfig::default()
338
            .with_initial_history_limit(0)
339
            .with_search_backfill_pages_per_room(0),
340
    )
341
    .await
342
    .unwrap();
343
    let running = tokio::spawn(engine.run());
344
345
    let first = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
346
    assert_eq!(entry_ids(&first), HashSet::from(["$mention".to_owned()]));
347
    assert_eq!(first.unread_count, 1);
348
    let first_boundary = first.boundary.clone();
349
350
    wait_for_requests(&feed, 3).await;
351
    let replayed = handle.mentions_page(None, 50).await.unwrap();
352
    assert_eq!(entry_ids(&replayed), HashSet::from(["$mention".to_owned()]));
353
354
    let key = replayed.entries[0].key.clone();
355
    handle
356
        .set_mentions_read(vec![key.clone()], true)
357
        .await
358
        .unwrap();
359
    let read = handle.mentions_page(None, 50).await.unwrap();
360
    assert_eq!(read.unread_count, 0);
361
362
    feed.replace(
363
        room_id,
364
        [
365
            initial,
366
            vec![message(
367
                "$older-mention",
368
                sender.as_str(),
369
                1_000,
370
                "older direct mention",
371
                Some(own_user.as_str()),
372
            )],
373
        ]
374
        .concat(),
375
    );
376
    let updated = mentions_page(&mut events, room_id, |page| page.entries.len() == 2).await;
377
    assert_eq!(
378
        entry_ids(&updated),
379
        HashSet::from(["$mention".to_owned(), "$older-mention".to_owned(),])
380
    );
381
382
    handle
383
        .mark_mentions_read_through(first_boundary)
384
        .await
385
        .unwrap();
386
    let after_boundary = handle.mentions_page(None, 50).await.unwrap();
387
    let read_states: Vec<_> = after_boundary
388
        .entries
389
        .iter()
390
        .map(|entry| (entry.key.event_id.to_string(), entry.read))
391
        .collect();
392
    assert!(read_states.contains(&("$mention".to_owned(), true)));
393
    assert!(read_states.contains(&("$older-mention".to_owned(), false)));
394
    assert_eq!(after_boundary.unread_count, 1);
395
396
    shut_down(&handle, &mut events, running).await;
397
}
398
399
#[tokio::test]
400
async fn edits_can_remove_and_restore_a_mention_until_redaction_removes_its_target() {
401
    let server = MatrixMockServer::new().await;
402
    let room_id = room_id!("!mentions-edits:localhost");
403
    let (client, own_user) = joined_client(&server, room_id).await;
404
    let sender = user_id!("@alice:localhost");
405
    let original = message(
406
        "$edited-target",
407
        sender.as_str(),
408
        1_000,
409
        "original mention",
410
        Some(own_user.as_str()),
411
    );
412
    let removal = json!({
413
        "type": "m.room.message",
414
        "event_id": "$remove-edit",
415
        "sender": sender,
416
        "origin_server_ts": 2_000,
417
        "content": {
418
            "msgtype": "m.text",
419
            "body": "* edited without a mention",
420
            "m.relates_to": { "rel_type": "m.replace", "event_id": "$edited-target" },
421
            "m.new_content": { "msgtype": "m.text", "body": "edited without a mention" },
422
        },
423
    });
424
    let addition = json!({
425
        "type": "m.room.message",
426
        "event_id": "$add-edit",
427
        "sender": sender,
428
        "origin_server_ts": 3_000,
429
        "content": {
430
            "msgtype": "m.text",
431
            "body": "* edited to mention you",
432
            "m.relates_to": { "rel_type": "m.replace", "event_id": "$edited-target" },
433
            "m.new_content": {
434
                "msgtype": "m.text",
435
                "body": "edited to mention you",
436
                "m.mentions": { "user_ids": [own_user.as_str()] },
437
            },
438
        },
439
    });
440
    let redaction = json!({
441
        "type": "m.room.redaction",
442
        "event_id": "$redaction",
443
        "sender": sender,
444
        "origin_server_ts": 4_000,
445
        "redacts": "$edited-target",
446
        "content": {},
447
    });
448
    let feed = SlidingFeed::new(room_id.to_owned(), vec![original.clone()]);
449
    feed.mount(&server).await;
450
451
    let (engine, handle, mut events) = Engine::from_client(
452
        client,
453
        EngineConfig::default()
454
            .with_initial_history_limit(0)
455
            .with_search_backfill_pages_per_room(0),
456
    )
457
    .await
458
    .unwrap();
459
    let running = tokio::spawn(engine.run());
460
461
    let discovered = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
462
    assert_eq!(
463
        entry_ids(&discovered),
464
        HashSet::from(["$edited-target".to_owned()])
465
    );
466
467
    feed.replace(room_id, vec![original.clone(), removal.clone()]);
468
    let removed = mentions_page(&mut events, room_id, |page| {
469
        page.revision > discovered.revision && page.entries.is_empty()
470
    })
471
    .await;
472
    assert!(removed.entries.is_empty());
473
    assert_eq!(
474
        handle
475
            .mention_context(room_id, ruma::event_id!("$edited-target"))
476
            .await
477
            .unwrap_err()
478
            .kind,
479
        macltok::ErrorKind::EventNotFound
480
    );
481
482
    feed.replace(room_id, vec![original.clone(), removal, addition]);
483
    let restored = mentions_page(&mut events, room_id, |page| {
484
        page.revision > removed.revision && entry_ids(page).contains("$edited-target")
485
    })
486
    .await;
487
    assert_eq!(
488
        entry_ids(&restored),
489
        HashSet::from(["$edited-target".to_owned()])
490
    );
491
492
    feed.replace(room_id, vec![original, redaction]);
493
    let redacted = mentions_page(&mut events, room_id, |page| {
494
        page.revision > restored.revision && page.entries.is_empty()
495
    })
496
    .await;
497
    assert!(redacted.entries.is_empty());
498
    assert_eq!(
499
        handle
500
            .mention_context(room_id, ruma::event_id!("$edited-target"))
501
            .await
502
            .unwrap_err()
503
            .kind,
504
        macltok::ErrorKind::EventNotFound
505
    );
506
507
    shut_down(&handle, &mut events, running).await;
508
}
509
510
#[tokio::test]
511
async fn room_mentions_require_power_while_muted_direct_mentions_remain_and_ignored_senders_drop() {
512
    let server = MatrixMockServer::new().await;
513
    let client = server.client_builder().build().await;
514
    let own_user = client.user_id().unwrap().to_owned();
515
    let room_mention_room = room_id!("!room-mention-policy:localhost");
516
    let muted_room = room_id!("!muted-mention-policy:localhost");
517
    let ignored_sender = user_id!("@ignored:localhost");
518
    let sender = user_id!("@alice:localhost");
519
    let mut rules = Ruleset::server_default(&own_user);
520
    rules.override_.shift_insert(
521
        1,
522
        NewConditionalPushRule::new(
523
            "mute-inbox-room".to_owned(),
524
            vec![PushCondition::EventMatch(EventMatchConditionData::new(
525
                "room_id".to_owned(),
526
                muted_room.to_string(),
527
            ))],
528
            Vec::new(),
529
        )
530
        .into(),
531
    );
532
    server
533
        .mock_sync()
534
        .ok_and_run(&client, {
535
            let own_user = own_user.clone();
536
            let ignored_sender = ignored_sender.to_owned();
537
            move |builder| {
538
                let factory = EventFactory::new();
539
                builder.add_global_account_data(factory.push_rules(rules));
540
                builder.add_global_account_data(factory.ignored_user_list([ignored_sender]));
541
                builder.add_joined_room(
542
                    JoinedRoomBuilder::new(room_mention_room)
543
                        .add_state_event(
544
                            EventFactory::new()
545
                                .room(room_mention_room)
546
                                .member(&own_user),
547
                        )
548
                        .add_state_event(
549
                            EventFactory::new()
550
                                .room(room_mention_room)
551
                                .default_power_levels()
552
                                .sender(&own_user),
553
                        ),
554
                );
555
                builder.add_joined_room(
556
                    JoinedRoomBuilder::new(muted_room)
557
                        .add_state_event(EventFactory::new().room(muted_room).member(&own_user))
558
                        .add_state_event(
559
                            EventFactory::new()
560
                                .room(muted_room)
561
                                .default_power_levels()
562
                                .sender(&own_user),
563
                        ),
564
                );
565
            }
566
        })
567
        .await;
568
569
    let feed = SlidingFeed::new(
570
        room_mention_room.to_owned(),
571
        vec![room_mention(
572
            "$unauthorized-room-mention",
573
            sender.as_str(),
574
            3_000,
575
        )],
576
    );
577
    feed.mount(&server).await;
578
    let muted_events = vec![message(
579
        "$muted-direct-mention",
580
        sender.as_str(),
581
        2_000,
582
        "muted direct mention",
583
        Some(own_user.as_str()),
584
    )];
585
    let ignored = message(
586
        "$ignored-direct-mention",
587
        ignored_sender.as_str(),
588
        1_000,
589
        "ignored direct mention",
590
        Some(own_user.as_str()),
591
    );
592
    let mut muted_events = muted_events;
593
    muted_events.push(ignored);
594
    feed.add_room(muted_room, muted_events);
595
596
    let (engine, handle, mut events) = Engine::from_client(
597
        client,
598
        EngineConfig::default()
599
            .with_initial_history_limit(0)
600
            .with_search_backfill_pages_per_room(0),
601
    )
602
    .await
603
    .unwrap();
604
    let running = tokio::spawn(engine.run());
605
606
    let page = mentions_page(&mut events, room_mention_room, |page| {
607
        page.entries
608
            .iter()
609
            .any(|entry| entry.key.event_id.as_str() == "$muted-direct-mention")
610
    })
611
    .await;
612
    assert_eq!(
613
        entry_ids(&page),
614
        HashSet::from(["$muted-direct-mention".to_owned()])
615
    );
616
    assert!(page.entries[0].notification_suppressed);
617
    assert!(!entry_ids(&page).contains("$unauthorized-room-mention"));
618
    assert!(!entry_ids(&page).contains("$ignored-direct-mention"));
619
    let room_ids: HashSet<_> = page
620
        .entries
621
        .iter()
622
        .map(|entry| entry.key.room_id.clone())
623
        .collect();
624
    assert!(room_ids.contains(&muted_room.to_owned()));
625
626
    shut_down(&handle, &mut events, running).await;
627
}
628
629
#[tokio::test]
630
async fn event_context_uses_independent_direction_limits_and_saved_mention_context_is_offline() {
631
    let server = MatrixMockServer::new().await;
632
    let room_id = room_id!("!mentions-context:localhost");
633
    let (client, own_user) = joined_client(&server, room_id).await;
634
    let mention_id = event_id!("$mention-context");
635
    let context_id = event_id!("$manual-context");
636
    let mention_event = EventFactory::new()
637
        .room(room_id)
638
        .text_msg("mention with saved context")
639
        .mentions(Mentions::with_user_ids([own_user.clone()]))
640
        .event_id(mention_id)
641
        .sender(&ALICE)
642
        .into_event();
643
    let context_event = EventFactory::new()
644
        .room(room_id)
645
        .text_msg("manually fetched context")
646
        .event_id(context_id)
647
        .sender(&ALICE)
648
        .into_event();
649
650
    server
651
        .mock_room_event_context()
652
        .room(room_id.to_owned())
653
        .match_event_id()
654
        .ok(RoomContextResponseTemplate::new(mention_event.clone())
655
            .start("mention-before")
656
            .end("mention-after"))
657
        .expect(1)
658
        .mount()
659
        .await;
660
    server
661
        .mock_room_event_context()
662
        .room(room_id.to_owned())
663
        .match_event_id()
664
        .ok(RoomContextResponseTemplate::new(context_event)
665
            .start("manual-before")
666
            .end("manual-after"))
667
        .expect(1)
668
        .mount()
669
        .await;
670
    mount_messages_page(&server, room_id, "b", "mention-before", 10).await;
671
    mount_messages_page(&server, room_id, "f", "mention-after", 10).await;
672
    mount_messages_page(&server, room_id, "b", "manual-before", 2).await;
673
    mount_messages_page(&server, room_id, "f", "manual-after", 3).await;
674
675
    let feed = SlidingFeed::new(
676
        room_id.to_owned(),
677
        vec![message(
678
            mention_id.as_str(),
679
            ALICE.as_str(),
680
            5_000,
681
            "mention with saved context",
682
            Some(own_user.as_str()),
683
        )],
684
    );
685
    feed.mount(&server).await;
686
    let (engine, handle, mut events) = Engine::from_client(
687
        client,
688
        EngineConfig::default()
689
            .with_initial_history_limit(0)
690
            .with_search_backfill_pages_per_room(0),
691
    )
692
    .await
693
    .unwrap();
694
    let running = tokio::spawn(engine.run());
695
696
    let captured = mentions_page_with_diagnostics(
697
        &mut events,
698
        room_id,
699
        |page| {
700
            page.entries.iter().any(|entry| {
701
                entry.key.event_id == mention_id
702
                    && matches!(&entry.capture, macltok::MentionCaptureState::Partial)
703
            })
704
        },
705
        Some(&server),
706
    )
707
    .await;
708
    let saved = handle.mention_context(room_id, mention_id).await.unwrap();
709
    assert!(saved.context.is_some());
710
    assert!(matches!(saved.state, macltok::MentionCaptureState::Partial));
711
712
    let before_saved_request_count = server
713
        .server()
714
        .received_requests()
715
        .await
716
        .unwrap()
717
        .iter()
718
        .filter(|request| request.url.path().contains("/context/$mention-context"))
719
        .count();
720
    let _ = handle.mention_context(room_id, mention_id).await.unwrap();
721
    let after_saved_request_count = server
722
        .server()
723
        .received_requests()
724
        .await
725
        .unwrap()
726
        .iter()
727
        .filter(|request| request.url.path().contains("/context/$mention-context"))
728
        .count();
729
    assert_eq!(after_saved_request_count, before_saved_request_count);
730
731
    let session = handle
732
        .event_context(room_id, context_id, 2, 3)
733
        .await
734
        .unwrap();
735
    let context = &session.context;
736
    assert_eq!(context.start_token.as_deref(), Some("manual-before-next"));
737
    assert_eq!(context.end_token.as_deref(), Some("manual-after-next"));
738
    handle
739
        .close_event_context(&session.context_id)
740
        .await
741
        .unwrap();
742
    let closed = handle
743
        .event_context_before(&session.context_id, "manual-before-next", 5)
744
        .await
745
        .unwrap_err();
746
    assert_eq!(closed.kind, macltok::ErrorKind::EventContextNotOpen);
747
748
    let requests = server.server().received_requests().await.unwrap();
749
    let context_limits: Vec<_> = requests
750
        .iter()
751
        .filter(|request| request.url.path().contains("/context/"))
752
        .map(|request| {
753
            request
754
                .url
755
                .query_pairs()
756
                .find(|(key, _)| key == "limit")
757
                .map(|(_, value)| value.into_owned())
758
                .unwrap()
759
        })
760
        .collect();
761
    assert_eq!(context_limits, ["0", "0"]);
762
    let queries: Vec<_> = requests
763
        .iter()
764
        .filter(|request| request.url.path().ends_with("/messages"))
765
        .map(|request| {
766
            let query: std::collections::HashMap<_, _> =
767
                request.url.query_pairs().into_owned().collect();
768
            (
769
                query.get("dir").cloned().unwrap(),
770
                query.get("from").cloned().unwrap(),
771
                query
772
                    .get("limit")
773
                    .cloned()
774
                    .unwrap_or_else(|| "10".to_owned()),
775
            )
776
        })
777
        .collect();
778
    assert!(queries.contains(&("b".to_owned(), "mention-before".to_owned(), "10".to_owned())));
779
    assert!(queries.contains(&("f".to_owned(), "mention-after".to_owned(), "10".to_owned())));
780
    assert!(queries.contains(&("b".to_owned(), "manual-before".to_owned(), "2".to_owned())));
781
    assert!(queries.contains(&("f".to_owned(), "manual-after".to_owned(), "3".to_owned())));
782
    assert_eq!(captured.unread_count, 1);
783
784
    shut_down(&handle, &mut events, running).await;
785
    server.server().verify().await;
786
}
787
788
#[tokio::test]
789
async fn highlighting_keywords_are_mentions_and_quiet_ones_are_not() {
790
    let server = MatrixMockServer::new().await;
791
    let client = server.client_builder().build().await;
792
    let own_user = client.user_id().unwrap().to_owned();
793
    let room_id = room_id!("!keyword-policy:localhost");
794
    let sender = user_id!("@alice:localhost");
795
    let mut rules = Ruleset::server_default(&own_user);
796
    let notify = [
797
        Action::Notify,
798
        Action::SetTweak(Tweak::Sound(SoundTweakValue::Default)),
799
    ];
800
    let mut loud = notify.to_vec();
801
    loud.push(Action::SetTweak(Tweak::Highlight(HighlightTweakValue::Yes)));
802
    rules
803
        .content
804
        .insert(NewPatternedPushRule::new("deploy".to_owned(), "deploy".to_owned(), loud).into());
805
    rules.content.insert(
806
        NewPatternedPushRule::new("lunch".to_owned(), "lunch".to_owned(), notify.to_vec()).into(),
807
    );
808
    server
809
        .mock_sync()
810
        .ok_and_run(&client, {
811
            let own_user = own_user.clone();
812
            move |builder| {
813
                let factory = EventFactory::new().room(room_id);
814
                builder.add_global_account_data(factory.push_rules(rules));
815
                builder.add_joined_room(
816
                    JoinedRoomBuilder::new(room_id).add_state_event(factory.member(&own_user)),
817
                );
818
            }
819
        })
820
        .await;
821
    let feed = SlidingFeed::new(
822
        room_id.to_owned(),
823
        vec![
824
            message(
825
                "$keyword",
826
                sender.as_str(),
827
                1_000,
828
                "the deploy is done",
829
                None,
830
            ),
831
            message("$quiet", sender.as_str(), 2_000, "lunch anyone", None),
832
            message("$plain", sender.as_str(), 3_000, "nothing to see", None),
833
            message(
834
                "$keyword-direct",
835
                sender.as_str(),
836
                4_000,
837
                "deploy now",
838
                Some(own_user.as_str()),
839
            ),
840
            message("$own-keyword", own_user.as_str(), 5_000, "my deploy", None),
841
        ],
842
    );
843
    feed.mount(&server).await;
844
845
    let (engine, handle, mut events) = Engine::from_client(
846
        client,
847
        EngineConfig::default()
848
            .with_initial_history_limit(0)
849
            .with_search_backfill_pages_per_room(0),
850
    )
851
    .await
852
    .unwrap();
853
    let running = tokio::spawn(engine.run());
854
855
    let page = mentions_page(&mut events, room_id, |page| page.entries.len() >= 2).await;
856
    assert_eq!(
857
        entry_ids(&page),
858
        HashSet::from(["$keyword".to_owned(), "$keyword-direct".to_owned()])
859
    );
860
    let reasons: BTreeMap<_, _> = page
861
        .entries
862
        .iter()
863
        .map(|entry| (entry.key.event_id.to_string(), entry.reasons.clone()))
864
        .collect();
865
    assert_eq!(reasons["$keyword"], vec![MentionReason::Keyword]);
866
    assert_eq!(reasons["$keyword-direct"], vec![MentionReason::Direct]);
867
    assert_eq!(page.unread_count, 2);
868
    assert!(
869
        page.entries
870
            .iter()
871
            .all(|entry| !entry.notification_suppressed)
872
    );
873
874
    shut_down(&handle, &mut events, running).await;
875
}
876
877
fn actions_are(expected: Value) -> impl Fn(&Request) -> bool + Send + Sync + 'static {
878
    move |request: &Request| {
879
        serde_json::from_slice::<Value>(&request.body).is_ok_and(|body| body["actions"] == expected)
880
    }
881
}
882
883
async fn mount_keyword_rule(server: &MatrixMockServer, actions: Value) {
884
    Mock::given(method("GET"))
885
        .and(path("/_matrix/client/v3/pushrules/global/content/deploy"))
886
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
887
            "rule_id": "deploy",
888
            "pattern": "deploy",
889
            "default": false,
890
            "enabled": true,
891
            "actions": actions,
892
        })))
893
        .mount(server.server())
894
        .await;
895
}
896
897
#[tokio::test]
898
async fn adding_a_keyword_gives_its_rule_a_highlight() {
899
    let server = MatrixMockServer::new().await;
900
    let room_id = room_id!("!keyword-rule:localhost");
901
    let (client, _) = joined_client(&server, room_id).await;
902
    Mock::given(method("PUT"))
903
        .and(path("/_matrix/client/v3/pushrules/global/content/deploy"))
904
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
905
        .expect(1)
906
        .mount(server.server())
907
        .await;
908
    mount_keyword_rule(
909
        &server,
910
        json!(["notify", {"set_tweak": "sound", "value": "default"}]),
911
    )
912
    .await;
913
    Mock::given(method("PUT"))
914
        .and(path(
915
            "/_matrix/client/v3/pushrules/global/content/deploy/actions",
916
        ))
917
        .and(actions_are(json!([
918
            "notify",
919
            {"set_tweak": "sound", "value": "default"},
920
            {"set_tweak": "highlight"},
921
        ])))
922
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
923
        .expect(1)
924
        .mount(server.server())
925
        .await;
926
    SlidingFeed::new(room_id.to_owned(), Vec::new())
927
        .mount(&server)
928
        .await;
929
930
    let (engine, handle, mut events) = Engine::from_client(
931
        client,
932
        EngineConfig::default().with_search_backfill_pages_per_room(0),
933
    )
934
    .await
935
    .unwrap();
936
    let running = tokio::spawn(engine.run());
937
    handle.add_notification_keyword("deploy").await.unwrap();
938
    shut_down(&handle, &mut events, running).await;
939
    server.server().verify().await;
940
}
941
942
#[tokio::test]
943
async fn a_quiet_keyword_rule_only_gains_the_highlight() {
944
    let server = MatrixMockServer::new().await;
945
    let client = server.client_builder().build().await;
946
    let own_user = client.user_id().unwrap().to_owned();
947
    let room_id = room_id!("!quiet-keyword-rule:localhost");
948
    let mut rules = Ruleset::server_default(&own_user);
949
    rules.content.insert(
950
        NewPatternedPushRule::new("deploy".to_owned(), "deploy".to_owned(), Vec::new()).into(),
951
    );
952
    server
953
        .mock_sync()
954
        .ok_and_run(&client, {
955
            let own_user = own_user.clone();
956
            move |builder| {
957
                let factory = EventFactory::new().room(room_id);
958
                builder.add_global_account_data(factory.push_rules(rules));
959
                builder.add_joined_room(
960
                    JoinedRoomBuilder::new(room_id).add_state_event(factory.member(&own_user)),
961
                );
962
            }
963
        })
964
        .await;
965
    mount_keyword_rule(&server, json!([])).await;
966
    Mock::given(method("PUT"))
967
        .and(path(
968
            "/_matrix/client/v3/pushrules/global/content/deploy/actions",
969
        ))
970
        .and(actions_are(json!([{"set_tweak": "highlight"}])))
971
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
972
        .expect(1)
973
        .mount(server.server())
974
        .await;
975
    SlidingFeed::new(room_id.to_owned(), Vec::new())
976
        .mount(&server)
977
        .await;
978
979
    let (engine, handle, mut events) = Engine::from_client(
980
        client,
981
        EngineConfig::default().with_search_backfill_pages_per_room(0),
982
    )
983
    .await
984
    .unwrap();
985
    let running = tokio::spawn(engine.run());
986
    handle.add_notification_keyword("deploy").await.unwrap();
987
    shut_down(&handle, &mut events, running).await;
988
    server.server().verify().await;
989
}
990
991
#[cfg(feature = "sqlite")]
992
#[tokio::test]
993
async fn sqlite_mentions_survive_engine_shutdown_and_session_resume() {
994
    static NEXT_TEMPORARY_PATH: AtomicUsize = AtomicUsize::new(0);
995
996
    let server = MatrixMockServer::new().await;
997
    server
998
        .mock_versions()
999
        .with_simplified_sliding_sync()
1000
        .ok()
1001
        .mount()
1002
        .await;
1003
    server.mock_login().ok().mount().await;
1004
1005
    let root = std::env::temp_dir().join(format!(
1006
        "macltok-mentions-inbox-{}-{}",
1007
        std::process::id(),
1008
        NEXT_TEMPORARY_PATH.fetch_add(1, Ordering::Relaxed)
1009
    ));
1010
    let store_path = root.join("store");
1011
    let sessions_path = root.join("records").join("session");
1012
    let sessions = Arc::new(EncryptedSessionStore::file(
1013
        &sessions_path,
1014
        StoreConfig::sqlite(&store_path, "mentions inbox test passphrase"),
1015
    ));
1016
    let room_id = room_id!("!mentions-resume:matrix.org");
1017
    let own_user = user_id!("@cheeky_monkey:matrix.org");
1018
    let feed = SlidingFeed::new(
1019
        room_id.to_owned(),
1020
        vec![message(
1021
            "$durable-mention",
1022
            "@alice:matrix.org",
1023
            7_000,
1024
            "persist this mention",
1025
            Some(own_user.as_str()),
1026
        )],
1027
    );
1028
    feed.set_required_state(
1029
        room_id,
1030
        vec![json!({
1031
            "type": "m.room.member",
1032
            "event_id": "$joined-member",
1033
            "sender": own_user,
1034
            "state_key": own_user,
1035
            "origin_server_ts": 1,
1036
            "content": { "membership": "join" },
1037
        })],
1038
    );
1039
    feed.mount(&server).await;
1040
1041
    let config = EngineConfig::default()
1042
        .with_initial_history_limit(0)
1043
        .with_search_backfill_pages_per_room(0);
1044
    let session = Session::login_password(
1045
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
1046
        sessions.clone(),
1047
    )
1048
    .await
1049
    .unwrap();
1050
    let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();
1051
    let running = tokio::spawn(engine.run());
1052
    let first = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
1053
    assert_eq!(
1054
        entry_ids(&first),
1055
        HashSet::from(["$durable-mention".to_owned()])
1056
    );
1057
    shut_down(&handle, &mut events, running).await;
1058
    drop(handle);
1059
1060
    let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {
1061
        panic!("the saved session did not resume");
1062
    };
1063
    let (engine, handle, mut events) = session.build(config).await.unwrap();
1064
    let running = tokio::spawn(engine.run());
1065
    receive_until(&mut events, |event| {
1066
        matches!(event, Event::Authenticated { .. })
1067
    })
1068
    .await;
1069
    let resumed = handle.mentions_page(None, 50).await.unwrap();
1070
    assert_eq!(
1071
        entry_ids(&resumed),
1072
        HashSet::from(["$durable-mention".to_owned()])
1073
    );
1074
    assert_eq!(resumed.unread_count, 1);
1075
    shut_down(&handle, &mut events, running).await;
1076
    drop(handle);
1077
1078
    sessions.discard().await.unwrap();
1079
    std::fs::remove_dir_all(root).unwrap();
1080
}
1081
1082
#[cfg(feature = "serde")]
1083
#[test]
1084
fn mentions_command_and_page_round_trip_through_serde() {
1085
    let command = MentionsCommand::ReadThrough(MentionsBoundary {
1086
        account: user_id!("@alice:example.org").to_owned(),
1087
        revision: 7,
1088
    });
1089
    let encoded_command = serde_json::to_value(&command).unwrap();
1090
    let decoded_command: MentionsCommand = serde_json::from_value(encoded_command).unwrap();
1091
    assert_eq!(decoded_command, command);
1092
1093
    let page = MentionsPage {
1094
        entries: Vec::new(),
1095
        next_cursor: Some("opaque cursor".to_owned()),
1096
        end_reached: false,
1097
        coverage: MentionCoverage::default(),
1098
        unread_count: 0,
1099
        revision: 7,
1100
        boundary: MentionsBoundary {
1101
            account: user_id!("@alice:example.org").to_owned(),
1102
            revision: 7,
1103
        },
1104
    };
1105
    let encoded_page = serde_json::to_value(&page).unwrap();
1106
    let decoded_page: MentionsPage = serde_json::from_value(encoded_page).unwrap();
1107
    assert_eq!(decoded_page, page);
1108
}