atagen

macltok

public
Star0
Watch0
924b7c0115912af6bd8ab5191841544afcfda9c5
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/mentions/service_tests.rsRust634 lines21.0 KB
1
use std::{
2
    sync::{
3
        Arc,
4
        atomic::{AtomicUsize, Ordering},
5
    },
6
    time::Duration,
7
};
8
9
use matrix_sdk::ruma::events::room::MediaSource;
10
use matrix_sdk::{
11
    ruma::{event_id, events::Mentions, push::Ruleset, room_id, user_id},
12
    test_utils::mocks::MatrixMockServer,
13
};
14
use matrix_sdk_test::{JoinedRoomBuilder, event_factory::EventFactory};
15
use rusqlite::Connection;
16
use serde_json::json;
17
use wiremock::{
18
    Mock, ResponseTemplate,
19
    matchers::{method, path, query_param, query_param_is_missing},
20
};
21
22
use super::super::store::{Account, MentionsStore};
23
use super::*;
24
use crate::{
25
    MediaId, MediaKind, StoreConfig, TimelineContent, TimelineItem,
26
    normalise::{MediaRegistration, MediaSourceInfo},
27
};
28
29
static STORES: AtomicUsize = AtomicUsize::new(0);
30
const MAX_BYTES: usize = 64 * 1024 * 1024;
31
32
fn config() -> StoreConfig {
33
    let number = STORES.fetch_add(1, Ordering::Relaxed);
34
    StoreConfig::sqlite(
35
        std::env::temp_dir().join(format!(
36
            "macltok-mentions-service-{}-{number}",
37
            std::process::id()
38
        )),
39
        "service commit test passphrase",
40
    )
41
}
42
43
fn account(client: &Client) -> Account {
44
    Account {
45
        homeserver: client.homeserver().to_string(),
46
        user_id: client.user_id().unwrap().to_owned(),
47
    }
48
}
49
50
async fn joined_client(
51
    server: &MatrixMockServer,
52
    room_id: &ruma::RoomId,
53
) -> (Client, ruma::OwnedUserId) {
54
    let client = server.client_builder().build().await;
55
    let own_user = client.user_id().unwrap().to_owned();
56
    let room_id = room_id.to_owned();
57
    server
58
        .mock_sync()
59
        .ok_and_run(&client, {
60
            let own_user = own_user.clone();
61
            move |builder| {
62
                let factory = EventFactory::new().room(&room_id);
63
                builder.add_global_account_data(
64
                    factory.push_rules(Ruleset::server_default(&own_user)),
65
                );
66
                builder.add_joined_room(
67
                    JoinedRoomBuilder::new(&room_id).add_state_event(factory.member(&own_user)),
68
                );
69
            }
70
        })
71
        .await;
72
    (client, own_user)
73
}
74
75
fn mention_batch(
76
    room_id: &ruma::RoomId,
77
    own_user: &ruma::UserId,
78
    event: &str,
79
    previous_pos: Option<&str>,
80
    next_pos: &str,
81
) -> ResponseCommitBatch {
82
    let event = EventFactory::new()
83
        .room(room_id)
84
        .text_msg(event)
85
        .mentions(Mentions::with_user_ids([own_user.to_owned()]))
86
        .event_id(&ruma::EventId::parse(format!("${event}:localhost")).unwrap())
87
        .sender(user_id!("@bob:localhost"))
88
        .into_raw_sync();
89
    let raw_room = serde_json::from_value(json!({
90
        "timeline": [serde_json::to_value(event).unwrap()],
91
    }))
92
    .unwrap();
93
    ResponseCommitBatch {
94
        connection_id: "mentions-connection".to_owned(),
95
        previous_pos: previous_pos.map(str::to_owned),
96
        next_pos: next_pos.to_owned(),
97
        response: Default::default(),
98
        raw_rooms: [(room_id.to_owned(), raw_room)].into(),
99
    }
100
}
101
102
fn quiet_batch(previous_pos: &str, next_pos: &str) -> ResponseCommitBatch {
103
    ResponseCommitBatch {
104
        connection_id: "mentions-connection".to_owned(),
105
        previous_pos: Some(previous_pos.to_owned()),
106
        next_pos: next_pos.to_owned(),
107
        response: Default::default(),
108
        raw_rooms: Default::default(),
109
    }
110
}
111
112
async fn service(client: &Client, store: Option<Result<MentionsStore, Error>>) -> Service {
113
    let media = Arc::new(MediaService::new(
114
        client.clone(),
115
        EngineConfig::default().max_media_bytes,
116
        EngineConfig::default().max_context_media,
117
    ));
118
    let (_, mut service, _) = Service::new(client.clone(), media, store, EngineConfig::default())
119
        .await
120
        .unwrap();
121
    service.recovery_budget = 0;
122
    service.latest_scan = false;
123
    service
124
}
125
126
fn rows(connection: &Connection) -> BTreeMap<String, Vec<u8>> {
127
    let mut statement = connection
128
        .prepare("SELECT key, value FROM records WHERE key NOT IN ('!cipher', '!head')")
129
        .unwrap();
130
    statement
131
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
132
        .unwrap()
133
        .map(Result::unwrap)
134
        .collect()
135
}
136
137
fn discontinuities(service: &Service) -> usize {
138
    service
139
        .data
140
        .coverage
141
        .gaps
142
        .iter()
143
        .filter(|gap| gap.reason == MentionGapReason::SyncDiscontinuity)
144
        .count()
145
}
146
147
#[tokio::test]
148
async fn failed_write_acks_and_keeps_the_inbox_in_memory_until_a_later_write() {
149
    let server = MatrixMockServer::new().await;
150
    let room_id = room_id!("!mentions:localhost");
151
    let (client, own_user) = joined_client(&server, room_id).await;
152
    let config = config();
153
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
154
        .await
155
        .unwrap();
156
    let mut service = service(&client, Some(Ok(store))).await;
157
158
    let connection = Connection::open(config.mentions_location()).unwrap();
159
    connection
160
        .execute_batch(
161
            "CREATE TRIGGER fail_mentions_inbox_insert BEFORE INSERT ON records WHEN NEW.key NOT IN ('!cipher', '!head') BEGIN SELECT RAISE(ABORT, 'injected inbox write failure'); END;
162
             CREATE TRIGGER fail_mentions_inbox_update BEFORE UPDATE ON records WHEN OLD.key NOT IN ('!cipher', '!head') BEGIN SELECT RAISE(ABORT, 'injected inbox write failure'); END;",
163
        )
164
        .unwrap();
165
166
    service
167
        .batch(mention_batch(
168
            room_id,
169
            &own_user,
170
            "first",
171
            None,
172
            "position-1",
173
        ))
174
        .await;
175
    assert!(service.dirty);
176
    assert_eq!(service.data.entries.len(), 1);
177
    assert!(
178
        service
179
            .data
180
            .coverage
181
            .gaps
182
            .iter()
183
            .any(|gap| gap.reason == MentionGapReason::StoreFailed)
184
    );
185
    assert_eq!(service.updates.borrow().entries.len(), 1);
186
    assert!(rows(&connection).is_empty());
187
188
    connection
189
        .execute_batch(
190
            "DROP TRIGGER fail_mentions_inbox_insert;
191
             DROP TRIGGER fail_mentions_inbox_update;",
192
        )
193
        .unwrap();
194
    service.flush_after = 0;
195
    service.batch(quiet_batch("position-1", "position-2")).await;
196
    assert!(!service.dirty);
197
    drop(service);
198
199
    let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
200
        .await
201
        .unwrap();
202
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
203
    let sync: SyncState = reopened.read("sync").unwrap().unwrap();
204
    assert_eq!(persisted.entries.len(), 1);
205
    assert_eq!(sync.inbox_revision, persisted.revision);
206
    assert_eq!(sync.checkpoints["mentions-connection"].pos, "position-2");
207
    drop(reopened);
208
    drop(connection);
209
    config.delete().await.unwrap();
210
}
211
212
#[tokio::test]
213
async fn a_quiet_response_writes_only_the_checkpoint() {
214
    let server = MatrixMockServer::new().await;
215
    let room_id = room_id!("!mentions:localhost");
216
    let (client, own_user) = joined_client(&server, room_id).await;
217
    let config = config();
218
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
219
        .await
220
        .unwrap();
221
    let mut service = service(&client, Some(Ok(store))).await;
222
    service
223
        .batch(mention_batch(
224
            room_id,
225
            &own_user,
226
            "first",
227
            None,
228
            "position-1",
229
        ))
230
        .await;
231
    let connection = Connection::open(config.mentions_location()).unwrap();
232
    let before = rows(&connection);
233
    assert_eq!(before.len(), 2);
234
235
    service.batch(quiet_batch("position-1", "position-2")).await;
236
    let after = rows(&connection);
237
    assert_eq!(after.len(), 2);
238
    assert_eq!(
239
        before
240
            .iter()
241
            .filter(|(key, value)| after.get(*key) != Some(value))
242
            .count(),
243
        1
244
    );
245
    assert_eq!(service.data.revision, 1);
246
    assert_eq!(
247
        service.sync.checkpoints["mentions-connection"].pos,
248
        "position-2"
249
    );
250
    drop(service);
251
    drop(connection);
252
    config.delete().await.unwrap();
253
}
254
255
#[tokio::test]
256
async fn a_replay_from_the_previous_checkpoint_is_not_a_discontinuity() {
257
    let server = MatrixMockServer::new().await;
258
    let room_id = room_id!("!mentions:localhost");
259
    let (client, own_user) = joined_client(&server, room_id).await;
260
    let config = config();
261
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
262
        .await
263
        .unwrap();
264
    let mut first = service(&client, Some(Ok(store))).await;
265
    first
266
        .batch(mention_batch(room_id, &own_user, "one", None, "position-1"))
267
        .await;
268
    first
269
        .batch(mention_batch(
270
            room_id,
271
            &own_user,
272
            "two",
273
            Some("position-1"),
274
            "position-2",
275
        ))
276
        .await;
277
    drop(first);
278
279
    // the sdk never saved position-2, so it asks again from position-1
280
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
281
        .await
282
        .unwrap();
283
    let mut restarted = service(&client, Some(Ok(store))).await;
284
    restarted
285
        .batch(mention_batch(
286
            room_id,
287
            &own_user,
288
            "three",
289
            Some("position-1"),
290
            "position-3",
291
        ))
292
        .await;
293
    assert_eq!(discontinuities(&restarted), 0);
294
    assert_eq!(restarted.data.entries.len(), 3);
295
    assert_eq!(
296
        restarted.sync.checkpoints["mentions-connection"],
297
        Checkpoint {
298
            pos: "position-3".to_owned(),
299
            previous: Some("position-1".to_owned()),
300
        }
301
    );
302
303
    restarted
304
        .batch(quiet_batch("position-3", "position-4"))
305
        .await;
306
    assert_eq!(discontinuities(&restarted), 0);
307
    restarted
308
        .batch(quiet_batch("position-1", "position-5"))
309
        .await;
310
    assert_eq!(discontinuities(&restarted), 1);
311
    drop(restarted);
312
    config.delete().await.unwrap();
313
}
314
315
#[tokio::test]
316
async fn an_unreadable_store_resets_behind_a_gap() {
317
    let server = MatrixMockServer::new().await;
318
    let room_id = room_id!("!mentions:localhost");
319
    let (client, own_user) = joined_client(&server, room_id).await;
320
    let config = config();
321
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
322
        .await
323
        .unwrap();
324
    let mut first = service(&client, Some(Ok(store))).await;
325
    first
326
        .batch(mention_batch(room_id, &own_user, "one", None, "position-1"))
327
        .await;
328
    drop(first);
329
    let connection = Connection::open(config.mentions_location()).unwrap();
330
    connection
331
        .execute(
332
            "UPDATE records SET value = x'00' WHERE key NOT IN ('!cipher', '!head')",
333
            [],
334
        )
335
        .unwrap();
336
    drop(connection);
337
338
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
339
        .await
340
        .unwrap();
341
    let restarted = service(&client, Some(Ok(store))).await;
342
    assert!(restarted.data.entries.is_empty());
343
    assert!(
344
        restarted
345
            .data
346
            .coverage
347
            .gaps
348
            .iter()
349
            .any(|gap| gap.reason == MentionGapReason::StoreReset)
350
    );
351
    drop(restarted);
352
    config.delete().await.unwrap();
353
}
354
355
#[tokio::test]
356
async fn service_recovers_notification_history_into_durable_unread_inbox() {
357
    let server = MatrixMockServer::new().await;
358
    let room_id = room_id!("!mentions:localhost");
359
    let (client, own_user) = joined_client(&server, room_id).await;
360
    let first = EventFactory::new()
361
        .room(room_id)
362
        .text_msg("history mention one")
363
        .mentions(Mentions::with_user_ids([own_user.clone()]))
364
        .event_id(event_id!("$history-one:localhost"))
365
        .sender(user_id!("@bob:localhost"))
366
        .into_raw_sync();
367
    let second = EventFactory::new()
368
        .room(room_id)
369
        .text_msg("history mention two")
370
        .mentions(Mentions::with_user_ids([own_user]))
371
        .event_id(event_id!("$history-two:localhost"))
372
        .sender(user_id!("@bob:localhost"))
373
        .into_raw_sync();
374
    let first = serde_json::from_str::<serde_json::Value>(first.json().get()).unwrap();
375
    let second = serde_json::from_str::<serde_json::Value>(second.json().get()).unwrap();
376
    Mock::given(method("GET"))
377
        .and(path("/_matrix/client/v3/notifications"))
378
        .and(query_param_is_missing("from"))
379
        .and(query_param("limit", "50"))
380
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
381
            "notifications": [{
382
                "actions": ["notify"],
383
                "event": first,
384
                "read": true,
385
                "room_id": room_id.as_str(),
386
                "ts": 1,
387
            }],
388
            "next_token": "history-page-two",
389
        })))
390
        .mount(server.server())
391
        .await;
392
    Mock::given(method("GET"))
393
        .and(path("/_matrix/client/v3/notifications"))
394
        .and(query_param("from", "history-page-two"))
395
        .and(query_param("limit", "50"))
396
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
397
            "notifications": [{
398
                "actions": ["notify"],
399
                "event": second,
400
                "read": true,
401
                "room_id": room_id.as_str(),
402
                "ts": 2,
403
            }],
404
        })))
405
        .mount(server.server())
406
        .await;
407
408
    let config = config();
409
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
410
        .await
411
        .unwrap();
412
    let media = Arc::new(MediaService::new(
413
        client.clone(),
414
        EngineConfig::default().max_media_bytes,
415
        EngineConfig::default().max_context_media,
416
    ));
417
    let (handle, service, mut updates) = Service::new(
418
        client.clone(),
419
        media,
420
        Some(Ok(store)),
421
        EngineConfig::default(),
422
    )
423
    .await
424
    .unwrap();
425
    let service_task = tokio::spawn(service.run());
426
427
    let discovered = tokio::time::timeout(Duration::from_secs(10), async {
428
        loop {
429
            let page = updates.borrow_and_update().clone();
430
            if page.entries.len() == 2 && !page.coverage.recovering {
431
                break page;
432
            }
433
            updates.changed().await.unwrap();
434
        }
435
    })
436
    .await;
437
    handle.stop().await;
438
    service_task.await.unwrap();
439
    let discovered = discovered.expect("notification history should be recovered");
440
    assert_eq!(discovered.entries.len(), 2);
441
    assert!(discovered.entries.iter().all(|entry| !entry.read));
442
443
    let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
444
        .await
445
        .unwrap();
446
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
447
    let sync: SyncState = reopened.read("sync").unwrap().unwrap();
448
    assert_eq!(sync.inbox_revision, persisted.revision);
449
    assert_eq!(persisted.entries.len(), 2);
450
    assert!(persisted.entries.values().all(|entry| !entry.read));
451
    assert!(persisted.latest_cursor.is_none());
452
    assert!(persisted.history_cursor.is_none());
453
    drop(reopened);
454
455
    config.delete().await.unwrap();
456
}
457
458
fn message_body(item: &TimelineItem) -> Option<&str> {
459
    match item {
460
        TimelineItem::Event(event) => match &event.content {
461
            TimelineContent::Message(message) => Some(&message.body),
462
            _ => None,
463
        },
464
        _ => None,
465
    }
466
}
467
468
fn media_registration() -> MediaRegistration {
469
    MediaRegistration {
470
        id: MediaId::new("test-scope#image".to_owned()),
471
        kind: MediaKind::Image,
472
        full: MediaSourceInfo {
473
            source: MediaSource::Plain(ruma::mxc_uri!("mxc://mentions.example/image").to_owned()),
474
            mimetype: Some("image/png".to_owned()),
475
            declared_size: Some(1234),
476
        },
477
        thumbnail: None,
478
    }
479
}
480
481
#[tokio::test]
482
async fn live_context_survives_capture_then_edit_and_redaction() {
483
    let server = MatrixMockServer::new().await;
484
    let room_id = room_id!("!mentions:localhost");
485
    let (client, own_user) = joined_client(&server, room_id).await;
486
    let mention = EventFactory::new()
487
        .room(room_id)
488
        .text_msg("direct mention")
489
        .mentions(Mentions::with_user_ids([own_user]))
490
        .event_id(event_id!("$live-mention:localhost"))
491
        .sender(user_id!("@bob:localhost"))
492
        .into_raw_sync();
493
    let mut mention: serde_json::Value = serde_json::from_str(mention.json().get()).unwrap();
494
    mention["origin_server_ts"] = json!(10);
495
    let following = EventFactory::new()
496
        .room(room_id)
497
        .text_msg("following")
498
        .event_id(event_id!("$live-following:localhost"))
499
        .sender(user_id!("@bob:localhost"))
500
        .into_raw_sync();
501
    let mut following: serde_json::Value = serde_json::from_str(following.json().get()).unwrap();
502
    following["origin_server_ts"] = json!(11);
503
504
    let media = Arc::new(MediaService::new(
505
        client.clone(),
506
        EngineConfig::default().max_media_bytes,
507
        EngineConfig::default().max_context_media,
508
    ));
509
    let (_handle, mut service, _updates) =
510
        Service::new(client, media, None, EngineConfig::default())
511
            .await
512
            .unwrap();
513
    let room_id = room_id.to_owned();
514
    let mention_key = MentionKey {
515
        room_id: room_id.clone(),
516
        event_id: event_id!("$live-mention:localhost").to_owned(),
517
    };
518
    let following_id = format!("{room_id}\n$live-following:localhost");
519
    let mut data = Data::default();
520
521
    service.observe(&mut data, &room_id, mention).await;
522
    service.observe(&mut data, &room_id, following).await;
523
524
    let mention_identity = id(&mention_key);
525
    let entry = data.entries.get(&mention_identity).unwrap();
526
    let revision = entry.revision;
527
    let target = entry.event.clone();
528
    service.captured(
529
        &mut data,
530
        mention_identity.clone(),
531
        revision,
532
        Ok((
533
            crate::EventContext {
534
                events_before: Vec::new(),
535
                event: target,
536
                events_after: Vec::new(),
537
                start_token: None,
538
                end_token: None,
539
            },
540
            Vec::new(),
541
            Vec::new(),
542
        )),
543
    );
544
545
    assert_eq!(
546
        data.entries[&mention_identity].capture,
547
        MentionCaptureState::Partial
548
    );
549
    assert!(data.events.contains_key(&following_id));
550
    let (saved, _) = data.context(&mention_key).unwrap();
551
    let context = saved.context.unwrap();
552
    assert_eq!(context.events_after.len(), 1);
553
    assert_eq!(message_body(&context.events_after[0]), Some("following"));
554
555
    let edit = json!({
556
        "content": {
557
            "body": "* edited following",
558
            "m.new_content": {"body": "edited following", "msgtype": "m.text"},
559
            "m.relates_to": {"event_id": "$live-following:localhost", "rel_type": "m.replace"},
560
            "msgtype": "m.text"
561
        },
562
        "event_id": "$live-following-edit:localhost",
563
        "origin_server_ts": 12,
564
        "room_id": room_id.as_str(),
565
        "sender": "@bob:localhost",
566
        "type": "m.room.message"
567
    });
568
    service.observe(&mut data, &room_id, edit).await;
569
    let (saved, _) = data.context(&mention_key).unwrap();
570
    let context = saved.context.unwrap();
571
    assert_eq!(
572
        message_body(&context.events_after[0]),
573
        Some("edited following")
574
    );
575
576
    let saved_following = data.events.get_mut(&following_id).unwrap();
577
    assert!(saved_following.raw.is_some());
578
    saved_following.media.push(media_registration());
579
    assert_eq!(saved_following.media.len(), 1);
580
    let redaction = json!({
581
        "content": {},
582
        "event_id": "$live-following-redaction:localhost",
583
        "origin_server_ts": 13,
584
        "redacts": "$live-following:localhost",
585
        "room_id": room_id.as_str(),
586
        "sender": "@bob:localhost",
587
        "type": "m.room.redaction"
588
    });
589
    service.observe(&mut data, &room_id, redaction).await;
590
591
    let saved_following = data.events.get(&following_id).unwrap();
592
    assert!(matches!(
593
        &saved_following.item,
594
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
595
    ));
596
    assert!(saved_following.raw.is_none());
597
    assert!(saved_following.media.is_empty());
598
    let (saved, _) = data.context(&mention_key).unwrap();
599
    let context = saved.context.unwrap();
600
    assert!(matches!(
601
        &context.events_after[0],
602
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
603
    ));
604
    data.windows.remove(&mention_identity);
605
    service.data = data;
606
    let CommandOutput::MentionContext(context) = service
607
        .command(MentionsCommand::RetryContext(mention_key))
608
        .await
609
        .unwrap()
610
    else {
611
        panic!("retry should return saved context");
612
    };
613
    assert_eq!(context.state, MentionCaptureState::Pending);
614
    assert!(context.context.is_some());
615
}
616
617
#[tokio::test]
618
async fn explicit_recovery_rechecks_server_support() {
619
    let server = MatrixMockServer::new().await;
620
    let (client, _) = joined_client(&server, room_id!("!mentions:localhost")).await;
621
    let config = EngineConfig::default();
622
    let media = Arc::new(MediaService::new(
623
        client.clone(),
624
        config.max_media_bytes,
625
        config.max_context_media,
626
    ));
627
    let (_, mut service, _) = Service::new(client, media, None, config).await.unwrap();
628
    service.data.coverage.historical_supported = Some(false);
629
    service.recovery_budget = 0;
630
    service.command(MentionsCommand::Recover).await.unwrap();
631
    assert_eq!(service.data.coverage.historical_supported, None);
632
    assert!(service.data.coverage.recovering);
633
    assert_eq!(service.recovery_budget, 20);
634
}