atagen

macltok

public
Star0
Watch0
21b9227729a49cccc12924f2ca63c0f1890c6b89
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
tests/mentions_inbox.rsRust1092 lines36.3 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().with_initial_history_limit(0),
338
    )
339
    .await
340
    .unwrap();
341
    let running = tokio::spawn(engine.run());
342
343
    let first = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
344
    assert_eq!(entry_ids(&first), HashSet::from(["$mention".to_owned()]));
345
    assert_eq!(first.unread_count, 1);
346
    let first_boundary = first.boundary.clone();
347
348
    wait_for_requests(&feed, 3).await;
349
    let replayed = handle.mentions_page(None, 50).await.unwrap();
350
    assert_eq!(entry_ids(&replayed), HashSet::from(["$mention".to_owned()]));
351
352
    let key = replayed.entries[0].key.clone();
353
    handle
354
        .set_mentions_read(vec![key.clone()], true)
355
        .await
356
        .unwrap();
357
    let read = handle.mentions_page(None, 50).await.unwrap();
358
    assert_eq!(read.unread_count, 0);
359
360
    feed.replace(
361
        room_id,
362
        [
363
            initial,
364
            vec![message(
365
                "$older-mention",
366
                sender.as_str(),
367
                1_000,
368
                "older direct mention",
369
                Some(own_user.as_str()),
370
            )],
371
        ]
372
        .concat(),
373
    );
374
    let updated = mentions_page(&mut events, room_id, |page| page.entries.len() == 2).await;
375
    assert_eq!(
376
        entry_ids(&updated),
377
        HashSet::from(["$mention".to_owned(), "$older-mention".to_owned(),])
378
    );
379
380
    handle
381
        .mark_mentions_read_through(first_boundary)
382
        .await
383
        .unwrap();
384
    let after_boundary = handle.mentions_page(None, 50).await.unwrap();
385
    let read_states: Vec<_> = after_boundary
386
        .entries
387
        .iter()
388
        .map(|entry| (entry.key.event_id.to_string(), entry.read))
389
        .collect();
390
    assert!(read_states.contains(&("$mention".to_owned(), true)));
391
    assert!(read_states.contains(&("$older-mention".to_owned(), false)));
392
    assert_eq!(after_boundary.unread_count, 1);
393
394
    shut_down(&handle, &mut events, running).await;
395
}
396
397
#[tokio::test]
398
async fn edits_can_remove_and_restore_a_mention_until_redaction_removes_its_target() {
399
    let server = MatrixMockServer::new().await;
400
    let room_id = room_id!("!mentions-edits:localhost");
401
    let (client, own_user) = joined_client(&server, room_id).await;
402
    let sender = user_id!("@alice:localhost");
403
    let original = message(
404
        "$edited-target",
405
        sender.as_str(),
406
        1_000,
407
        "original mention",
408
        Some(own_user.as_str()),
409
    );
410
    let removal = json!({
411
        "type": "m.room.message",
412
        "event_id": "$remove-edit",
413
        "sender": sender,
414
        "origin_server_ts": 2_000,
415
        "content": {
416
            "msgtype": "m.text",
417
            "body": "* edited without a mention",
418
            "m.relates_to": { "rel_type": "m.replace", "event_id": "$edited-target" },
419
            "m.new_content": { "msgtype": "m.text", "body": "edited without a mention" },
420
        },
421
    });
422
    let addition = json!({
423
        "type": "m.room.message",
424
        "event_id": "$add-edit",
425
        "sender": sender,
426
        "origin_server_ts": 3_000,
427
        "content": {
428
            "msgtype": "m.text",
429
            "body": "* edited to mention you",
430
            "m.relates_to": { "rel_type": "m.replace", "event_id": "$edited-target" },
431
            "m.new_content": {
432
                "msgtype": "m.text",
433
                "body": "edited to mention you",
434
                "m.mentions": { "user_ids": [own_user.as_str()] },
435
            },
436
        },
437
    });
438
    let redaction = json!({
439
        "type": "m.room.redaction",
440
        "event_id": "$redaction",
441
        "sender": sender,
442
        "origin_server_ts": 4_000,
443
        "redacts": "$edited-target",
444
        "content": {},
445
    });
446
    let feed = SlidingFeed::new(room_id.to_owned(), vec![original.clone()]);
447
    feed.mount(&server).await;
448
449
    let (engine, handle, mut events) = Engine::from_client(
450
        client,
451
        EngineConfig::default().with_initial_history_limit(0),
452
    )
453
    .await
454
    .unwrap();
455
    let running = tokio::spawn(engine.run());
456
457
    let discovered = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
458
    assert_eq!(
459
        entry_ids(&discovered),
460
        HashSet::from(["$edited-target".to_owned()])
461
    );
462
463
    feed.replace(room_id, vec![original.clone(), removal.clone()]);
464
    let removed = mentions_page(&mut events, room_id, |page| {
465
        page.revision > discovered.revision && page.entries.is_empty()
466
    })
467
    .await;
468
    assert!(removed.entries.is_empty());
469
    assert_eq!(
470
        handle
471
            .mention_context(room_id, ruma::event_id!("$edited-target"))
472
            .await
473
            .unwrap_err()
474
            .kind,
475
        macltok::ErrorKind::EventNotFound
476
    );
477
478
    feed.replace(room_id, vec![original.clone(), removal, addition]);
479
    let restored = mentions_page(&mut events, room_id, |page| {
480
        page.revision > removed.revision && entry_ids(page).contains("$edited-target")
481
    })
482
    .await;
483
    assert_eq!(
484
        entry_ids(&restored),
485
        HashSet::from(["$edited-target".to_owned()])
486
    );
487
488
    feed.replace(room_id, vec![original, redaction]);
489
    let redacted = mentions_page(&mut events, room_id, |page| {
490
        page.revision > restored.revision && page.entries.is_empty()
491
    })
492
    .await;
493
    assert!(redacted.entries.is_empty());
494
    assert_eq!(
495
        handle
496
            .mention_context(room_id, ruma::event_id!("$edited-target"))
497
            .await
498
            .unwrap_err()
499
            .kind,
500
        macltok::ErrorKind::EventNotFound
501
    );
502
503
    shut_down(&handle, &mut events, running).await;
504
}
505
506
#[tokio::test]
507
async fn room_mentions_require_power_while_muted_direct_mentions_remain_and_ignored_senders_drop() {
508
    let server = MatrixMockServer::new().await;
509
    let client = server.client_builder().build().await;
510
    let own_user = client.user_id().unwrap().to_owned();
511
    let room_mention_room = room_id!("!room-mention-policy:localhost");
512
    let muted_room = room_id!("!muted-mention-policy:localhost");
513
    let ignored_sender = user_id!("@ignored:localhost");
514
    let sender = user_id!("@alice:localhost");
515
    let mut rules = Ruleset::server_default(&own_user);
516
    rules.override_.shift_insert(
517
        1,
518
        NewConditionalPushRule::new(
519
            "mute-inbox-room".to_owned(),
520
            vec![PushCondition::EventMatch(EventMatchConditionData::new(
521
                "room_id".to_owned(),
522
                muted_room.to_string(),
523
            ))],
524
            Vec::new(),
525
        )
526
        .into(),
527
    );
528
    server
529
        .mock_sync()
530
        .ok_and_run(&client, {
531
            let own_user = own_user.clone();
532
            let ignored_sender = ignored_sender.to_owned();
533
            move |builder| {
534
                let factory = EventFactory::new();
535
                builder.add_global_account_data(factory.push_rules(rules));
536
                builder.add_global_account_data(factory.ignored_user_list([ignored_sender]));
537
                builder.add_joined_room(
538
                    JoinedRoomBuilder::new(room_mention_room)
539
                        .add_state_event(
540
                            EventFactory::new()
541
                                .room(room_mention_room)
542
                                .member(&own_user),
543
                        )
544
                        .add_state_event(
545
                            EventFactory::new()
546
                                .room(room_mention_room)
547
                                .default_power_levels()
548
                                .sender(&own_user),
549
                        ),
550
                );
551
                builder.add_joined_room(
552
                    JoinedRoomBuilder::new(muted_room)
553
                        .add_state_event(EventFactory::new().room(muted_room).member(&own_user))
554
                        .add_state_event(
555
                            EventFactory::new()
556
                                .room(muted_room)
557
                                .default_power_levels()
558
                                .sender(&own_user),
559
                        ),
560
                );
561
            }
562
        })
563
        .await;
564
565
    let feed = SlidingFeed::new(
566
        room_mention_room.to_owned(),
567
        vec![room_mention(
568
            "$unauthorized-room-mention",
569
            sender.as_str(),
570
            3_000,
571
        )],
572
    );
573
    feed.mount(&server).await;
574
    let muted_events = vec![message(
575
        "$muted-direct-mention",
576
        sender.as_str(),
577
        2_000,
578
        "muted direct mention",
579
        Some(own_user.as_str()),
580
    )];
581
    let ignored = message(
582
        "$ignored-direct-mention",
583
        ignored_sender.as_str(),
584
        1_000,
585
        "ignored direct mention",
586
        Some(own_user.as_str()),
587
    );
588
    let mut muted_events = muted_events;
589
    muted_events.push(ignored);
590
    feed.add_room(muted_room, muted_events);
591
592
    let (engine, handle, mut events) = Engine::from_client(
593
        client,
594
        EngineConfig::default().with_initial_history_limit(0),
595
    )
596
    .await
597
    .unwrap();
598
    let running = tokio::spawn(engine.run());
599
600
    let page = mentions_page(&mut events, room_mention_room, |page| {
601
        page.entries
602
            .iter()
603
            .any(|entry| entry.key.event_id.as_str() == "$muted-direct-mention")
604
    })
605
    .await;
606
    assert_eq!(
607
        entry_ids(&page),
608
        HashSet::from(["$muted-direct-mention".to_owned()])
609
    );
610
    assert!(page.entries[0].notification_suppressed);
611
    assert!(!entry_ids(&page).contains("$unauthorized-room-mention"));
612
    assert!(!entry_ids(&page).contains("$ignored-direct-mention"));
613
    let room_ids: HashSet<_> = page
614
        .entries
615
        .iter()
616
        .map(|entry| entry.key.room_id.clone())
617
        .collect();
618
    assert!(room_ids.contains(&muted_room.to_owned()));
619
620
    shut_down(&handle, &mut events, running).await;
621
}
622
623
#[tokio::test]
624
async fn event_context_uses_independent_direction_limits_and_saved_mention_context_is_offline() {
625
    let server = MatrixMockServer::new().await;
626
    let room_id = room_id!("!mentions-context:localhost");
627
    let (client, own_user) = joined_client(&server, room_id).await;
628
    let mention_id = event_id!("$mention-context");
629
    let context_id = event_id!("$manual-context");
630
    let mention_event = EventFactory::new()
631
        .room(room_id)
632
        .text_msg("mention with saved context")
633
        .mentions(Mentions::with_user_ids([own_user.clone()]))
634
        .event_id(mention_id)
635
        .sender(&ALICE)
636
        .into_event();
637
    let context_event = EventFactory::new()
638
        .room(room_id)
639
        .text_msg("manually fetched context")
640
        .event_id(context_id)
641
        .sender(&ALICE)
642
        .into_event();
643
644
    server
645
        .mock_room_event_context()
646
        .room(room_id.to_owned())
647
        .match_event_id()
648
        .ok(RoomContextResponseTemplate::new(mention_event.clone())
649
            .start("mention-before")
650
            .end("mention-after"))
651
        .expect(1)
652
        .mount()
653
        .await;
654
    server
655
        .mock_room_event_context()
656
        .room(room_id.to_owned())
657
        .match_event_id()
658
        .ok(RoomContextResponseTemplate::new(context_event)
659
            .start("manual-before")
660
            .end("manual-after"))
661
        .expect(1)
662
        .mount()
663
        .await;
664
    mount_messages_page(&server, room_id, "b", "mention-before", 10).await;
665
    mount_messages_page(&server, room_id, "f", "mention-after", 10).await;
666
    mount_messages_page(&server, room_id, "b", "manual-before", 2).await;
667
    mount_messages_page(&server, room_id, "f", "manual-after", 3).await;
668
669
    let feed = SlidingFeed::new(
670
        room_id.to_owned(),
671
        vec![message(
672
            mention_id.as_str(),
673
            ALICE.as_str(),
674
            5_000,
675
            "mention with saved context",
676
            Some(own_user.as_str()),
677
        )],
678
    );
679
    feed.mount(&server).await;
680
    let (engine, handle, mut events) = Engine::from_client(
681
        client,
682
        EngineConfig::default().with_initial_history_limit(0),
683
    )
684
    .await
685
    .unwrap();
686
    let running = tokio::spawn(engine.run());
687
688
    let captured = mentions_page_with_diagnostics(
689
        &mut events,
690
        room_id,
691
        |page| {
692
            page.entries.iter().any(|entry| {
693
                entry.key.event_id == mention_id
694
                    && matches!(&entry.capture, macltok::MentionCaptureState::Partial)
695
            })
696
        },
697
        Some(&server),
698
    )
699
    .await;
700
    let saved = handle.mention_context(room_id, mention_id).await.unwrap();
701
    assert!(saved.context.is_some());
702
    assert!(matches!(saved.state, macltok::MentionCaptureState::Partial));
703
704
    let before_saved_request_count = server
705
        .server()
706
        .received_requests()
707
        .await
708
        .unwrap()
709
        .iter()
710
        .filter(|request| request.url.path().contains("/context/$mention-context"))
711
        .count();
712
    let _ = handle.mention_context(room_id, mention_id).await.unwrap();
713
    let after_saved_request_count = server
714
        .server()
715
        .received_requests()
716
        .await
717
        .unwrap()
718
        .iter()
719
        .filter(|request| request.url.path().contains("/context/$mention-context"))
720
        .count();
721
    assert_eq!(after_saved_request_count, before_saved_request_count);
722
723
    let session = handle
724
        .event_context(room_id, context_id, 2, 3)
725
        .await
726
        .unwrap();
727
    let context = &session.context;
728
    assert_eq!(context.start_token.as_deref(), Some("manual-before-next"));
729
    assert_eq!(context.end_token.as_deref(), Some("manual-after-next"));
730
    handle
731
        .close_event_context(&session.context_id)
732
        .await
733
        .unwrap();
734
    let closed = handle
735
        .event_context_before(&session.context_id, "manual-before-next", 5)
736
        .await
737
        .unwrap_err();
738
    assert_eq!(closed.kind, macltok::ErrorKind::EventContextNotOpen);
739
740
    let requests = server.server().received_requests().await.unwrap();
741
    let context_limits: Vec<_> = requests
742
        .iter()
743
        .filter(|request| request.url.path().contains("/context/"))
744
        .map(|request| {
745
            request
746
                .url
747
                .query_pairs()
748
                .find(|(key, _)| key == "limit")
749
                .map(|(_, value)| value.into_owned())
750
                .unwrap()
751
        })
752
        .collect();
753
    assert_eq!(context_limits, ["0", "0"]);
754
    let queries: Vec<_> = requests
755
        .iter()
756
        .filter(|request| request.url.path().ends_with("/messages"))
757
        .map(|request| {
758
            let query: std::collections::HashMap<_, _> =
759
                request.url.query_pairs().into_owned().collect();
760
            (
761
                query.get("dir").cloned().unwrap(),
762
                query.get("from").cloned().unwrap(),
763
                query
764
                    .get("limit")
765
                    .cloned()
766
                    .unwrap_or_else(|| "10".to_owned()),
767
            )
768
        })
769
        .collect();
770
    assert!(queries.contains(&("b".to_owned(), "mention-before".to_owned(), "10".to_owned())));
771
    assert!(queries.contains(&("f".to_owned(), "mention-after".to_owned(), "10".to_owned())));
772
    assert!(queries.contains(&("b".to_owned(), "manual-before".to_owned(), "2".to_owned())));
773
    assert!(queries.contains(&("f".to_owned(), "manual-after".to_owned(), "3".to_owned())));
774
    assert_eq!(captured.unread_count, 1);
775
776
    shut_down(&handle, &mut events, running).await;
777
    server.server().verify().await;
778
}
779
780
#[tokio::test]
781
async fn highlighting_keywords_are_mentions_and_quiet_ones_are_not() {
782
    let server = MatrixMockServer::new().await;
783
    let client = server.client_builder().build().await;
784
    let own_user = client.user_id().unwrap().to_owned();
785
    let room_id = room_id!("!keyword-policy:localhost");
786
    let sender = user_id!("@alice:localhost");
787
    let mut rules = Ruleset::server_default(&own_user);
788
    let notify = [
789
        Action::Notify,
790
        Action::SetTweak(Tweak::Sound(SoundTweakValue::Default)),
791
    ];
792
    let mut loud = notify.to_vec();
793
    loud.push(Action::SetTweak(Tweak::Highlight(HighlightTweakValue::Yes)));
794
    rules
795
        .content
796
        .insert(NewPatternedPushRule::new("deploy".to_owned(), "deploy".to_owned(), loud).into());
797
    rules.content.insert(
798
        NewPatternedPushRule::new("lunch".to_owned(), "lunch".to_owned(), notify.to_vec()).into(),
799
    );
800
    server
801
        .mock_sync()
802
        .ok_and_run(&client, {
803
            let own_user = own_user.clone();
804
            move |builder| {
805
                let factory = EventFactory::new().room(room_id);
806
                builder.add_global_account_data(factory.push_rules(rules));
807
                builder.add_joined_room(
808
                    JoinedRoomBuilder::new(room_id).add_state_event(factory.member(&own_user)),
809
                );
810
            }
811
        })
812
        .await;
813
    let feed = SlidingFeed::new(
814
        room_id.to_owned(),
815
        vec![
816
            message(
817
                "$keyword",
818
                sender.as_str(),
819
                1_000,
820
                "the deploy is done",
821
                None,
822
            ),
823
            message("$quiet", sender.as_str(), 2_000, "lunch anyone", None),
824
            message("$plain", sender.as_str(), 3_000, "nothing to see", None),
825
            message(
826
                "$keyword-direct",
827
                sender.as_str(),
828
                4_000,
829
                "deploy now",
830
                Some(own_user.as_str()),
831
            ),
832
            message("$own-keyword", own_user.as_str(), 5_000, "my deploy", None),
833
        ],
834
    );
835
    feed.mount(&server).await;
836
837
    let (engine, handle, mut events) = Engine::from_client(
838
        client,
839
        EngineConfig::default().with_initial_history_limit(0),
840
    )
841
    .await
842
    .unwrap();
843
    let running = tokio::spawn(engine.run());
844
845
    let page = mentions_page(&mut events, room_id, |page| page.entries.len() >= 2).await;
846
    assert_eq!(
847
        entry_ids(&page),
848
        HashSet::from(["$keyword".to_owned(), "$keyword-direct".to_owned()])
849
    );
850
    let reasons: BTreeMap<_, _> = page
851
        .entries
852
        .iter()
853
        .map(|entry| (entry.key.event_id.to_string(), entry.reasons.clone()))
854
        .collect();
855
    assert_eq!(reasons["$keyword"], vec![MentionReason::Keyword]);
856
    assert_eq!(reasons["$keyword-direct"], vec![MentionReason::Direct]);
857
    assert_eq!(page.unread_count, 2);
858
    assert!(
859
        page.entries
860
            .iter()
861
            .all(|entry| !entry.notification_suppressed)
862
    );
863
864
    shut_down(&handle, &mut events, running).await;
865
}
866
867
fn actions_are(expected: Value) -> impl Fn(&Request) -> bool + Send + Sync + 'static {
868
    move |request: &Request| {
869
        serde_json::from_slice::<Value>(&request.body).is_ok_and(|body| body["actions"] == expected)
870
    }
871
}
872
873
async fn mount_keyword_rule(server: &MatrixMockServer, actions: Value) {
874
    Mock::given(method("GET"))
875
        .and(path("/_matrix/client/v3/pushrules/global/content/deploy"))
876
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
877
            "rule_id": "deploy",
878
            "pattern": "deploy",
879
            "default": false,
880
            "enabled": true,
881
            "actions": actions,
882
        })))
883
        .mount(server.server())
884
        .await;
885
}
886
887
#[tokio::test]
888
async fn adding_a_keyword_gives_its_rule_a_highlight() {
889
    let server = MatrixMockServer::new().await;
890
    let room_id = room_id!("!keyword-rule:localhost");
891
    let (client, _) = joined_client(&server, room_id).await;
892
    Mock::given(method("PUT"))
893
        .and(path("/_matrix/client/v3/pushrules/global/content/deploy"))
894
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
895
        .expect(1)
896
        .mount(server.server())
897
        .await;
898
    mount_keyword_rule(
899
        &server,
900
        json!(["notify", {"set_tweak": "sound", "value": "default"}]),
901
    )
902
    .await;
903
    Mock::given(method("PUT"))
904
        .and(path(
905
            "/_matrix/client/v3/pushrules/global/content/deploy/actions",
906
        ))
907
        .and(actions_are(json!([
908
            "notify",
909
            {"set_tweak": "sound", "value": "default"},
910
            {"set_tweak": "highlight"},
911
        ])))
912
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
913
        .expect(1)
914
        .mount(server.server())
915
        .await;
916
    SlidingFeed::new(room_id.to_owned(), Vec::new())
917
        .mount(&server)
918
        .await;
919
920
    let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
921
        .await
922
        .unwrap();
923
    let running = tokio::spawn(engine.run());
924
    handle.add_notification_keyword("deploy").await.unwrap();
925
    shut_down(&handle, &mut events, running).await;
926
    server.server().verify().await;
927
}
928
929
#[tokio::test]
930
async fn a_quiet_keyword_rule_only_gains_the_highlight() {
931
    let server = MatrixMockServer::new().await;
932
    let client = server.client_builder().build().await;
933
    let own_user = client.user_id().unwrap().to_owned();
934
    let room_id = room_id!("!quiet-keyword-rule:localhost");
935
    let mut rules = Ruleset::server_default(&own_user);
936
    rules.content.insert(
937
        NewPatternedPushRule::new("deploy".to_owned(), "deploy".to_owned(), Vec::new()).into(),
938
    );
939
    server
940
        .mock_sync()
941
        .ok_and_run(&client, {
942
            let own_user = own_user.clone();
943
            move |builder| {
944
                let factory = EventFactory::new().room(room_id);
945
                builder.add_global_account_data(factory.push_rules(rules));
946
                builder.add_joined_room(
947
                    JoinedRoomBuilder::new(room_id).add_state_event(factory.member(&own_user)),
948
                );
949
            }
950
        })
951
        .await;
952
    mount_keyword_rule(&server, json!([])).await;
953
    Mock::given(method("PUT"))
954
        .and(path(
955
            "/_matrix/client/v3/pushrules/global/content/deploy/actions",
956
        ))
957
        .and(actions_are(
958
            json!([{"set_tweak": "highlight"}]),
959
        ))
960
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
961
        .expect(1)
962
        .mount(server.server())
963
        .await;
964
    SlidingFeed::new(room_id.to_owned(), Vec::new())
965
        .mount(&server)
966
        .await;
967
968
    let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
969
        .await
970
        .unwrap();
971
    let running = tokio::spawn(engine.run());
972
    handle.add_notification_keyword("deploy").await.unwrap();
973
    shut_down(&handle, &mut events, running).await;
974
    server.server().verify().await;
975
}
976
977
#[cfg(feature = "sqlite")]
978
#[tokio::test]
979
async fn sqlite_mentions_survive_engine_shutdown_and_session_resume() {
980
    static NEXT_TEMPORARY_PATH: AtomicUsize = AtomicUsize::new(0);
981
982
    let server = MatrixMockServer::new().await;
983
    server
984
        .mock_versions()
985
        .with_simplified_sliding_sync()
986
        .ok()
987
        .mount()
988
        .await;
989
    server.mock_login().ok().mount().await;
990
991
    let root = std::env::temp_dir().join(format!(
992
        "macltok-mentions-inbox-{}-{}",
993
        std::process::id(),
994
        NEXT_TEMPORARY_PATH.fetch_add(1, Ordering::Relaxed)
995
    ));
996
    let store_path = root.join("store");
997
    let sessions_path = root.join("records").join("session");
998
    let sessions = Arc::new(EncryptedSessionStore::file(
999
        &sessions_path,
1000
        StoreConfig::sqlite(&store_path, "mentions inbox test passphrase"),
1001
    ));
1002
    let room_id = room_id!("!mentions-resume:matrix.org");
1003
    let own_user = user_id!("@cheeky_monkey:matrix.org");
1004
    let feed = SlidingFeed::new(
1005
        room_id.to_owned(),
1006
        vec![message(
1007
            "$durable-mention",
1008
            "@alice:matrix.org",
1009
            7_000,
1010
            "persist this mention",
1011
            Some(own_user.as_str()),
1012
        )],
1013
    );
1014
    feed.set_required_state(
1015
        room_id,
1016
        vec![json!({
1017
            "type": "m.room.member",
1018
            "event_id": "$joined-member",
1019
            "sender": own_user,
1020
            "state_key": own_user,
1021
            "origin_server_ts": 1,
1022
            "content": { "membership": "join" },
1023
        })],
1024
    );
1025
    feed.mount(&server).await;
1026
1027
    let config = EngineConfig::default().with_initial_history_limit(0);
1028
    let session = Session::login_password(
1029
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
1030
        sessions.clone(),
1031
    )
1032
    .await
1033
    .unwrap();
1034
    let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();
1035
    let running = tokio::spawn(engine.run());
1036
    let first = mentions_page(&mut events, room_id, |page| !page.entries.is_empty()).await;
1037
    assert_eq!(
1038
        entry_ids(&first),
1039
        HashSet::from(["$durable-mention".to_owned()])
1040
    );
1041
    shut_down(&handle, &mut events, running).await;
1042
    drop(handle);
1043
1044
    let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {
1045
        panic!("the saved session did not resume");
1046
    };
1047
    let (engine, handle, mut events) = session.build(config).await.unwrap();
1048
    let running = tokio::spawn(engine.run());
1049
    receive_until(&mut events, |event| {
1050
        matches!(event, Event::Authenticated { .. })
1051
    })
1052
    .await;
1053
    let resumed = handle.mentions_page(None, 50).await.unwrap();
1054
    assert_eq!(
1055
        entry_ids(&resumed),
1056
        HashSet::from(["$durable-mention".to_owned()])
1057
    );
1058
    assert_eq!(resumed.unread_count, 1);
1059
    shut_down(&handle, &mut events, running).await;
1060
    drop(handle);
1061
1062
    sessions.discard().await.unwrap();
1063
    std::fs::remove_dir_all(root).unwrap();
1064
}
1065
1066
#[cfg(feature = "serde")]
1067
#[test]
1068
fn mentions_command_and_page_round_trip_through_serde() {
1069
    let command = MentionsCommand::ReadThrough(MentionsBoundary {
1070
        account: user_id!("@alice:example.org").to_owned(),
1071
        revision: 7,
1072
    });
1073
    let encoded_command = serde_json::to_value(&command).unwrap();
1074
    let decoded_command: MentionsCommand = serde_json::from_value(encoded_command).unwrap();
1075
    assert_eq!(decoded_command, command);
1076
1077
    let page = MentionsPage {
1078
        entries: Vec::new(),
1079
        next_cursor: Some("opaque cursor".to_owned()),
1080
        end_reached: false,
1081
        coverage: MentionCoverage::default(),
1082
        unread_count: 0,
1083
        revision: 7,
1084
        boundary: MentionsBoundary {
1085
            account: user_id!("@alice:example.org").to_owned(),
1086
            revision: 7,
1087
        },
1088
    };
1089
    let encoded_page = serde_json::to_value(&page).unwrap();
1090
    let decoded_page: MentionsPage = serde_json::from_value(encoded_page).unwrap();
1091
    assert_eq!(decoded_page, page);
1092
}