atagen

macltok

public
Star0
Watch0
d1313f47f24eff9c7654a76b44fdf9e65ba6db5d
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/mentions/service_tests.rsRust593 lines20.6 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
#[tokio::test]
76
async fn failed_batch_keeps_inbox_and_checkpoint_at_the_same_commit_boundary() {
77
    let server = MatrixMockServer::new().await;
78
    let room_id = room_id!("!mentions:localhost");
79
    let (client, own_user) = joined_client(&server, room_id).await;
80
    let event = EventFactory::new()
81
        .room(room_id)
82
        .text_msg("commit boundary")
83
        .mentions(Mentions::with_user_ids([own_user.clone()]))
84
        .event_id(event_id!("$commit-boundary:localhost"))
85
        .sender(user_id!("@bob:localhost"))
86
        .into_raw_sync();
87
    let raw_room = serde_json::from_value(json!({
88
        "initial": true,
89
        "timeline": [serde_json::to_value(event).unwrap()],
90
    }))
91
    .unwrap();
92
    let batch = ResponseCommitBatch {
93
        connection_id: "mentions-connection".to_owned(),
94
        previous_pos: None,
95
        next_pos: "mentions-position-1".to_owned(),
96
        response: Default::default(),
97
        raw_rooms: [(room_id.to_owned(), raw_room)].into(),
98
    };
99
    let config = config();
100
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
101
        .await
102
        .unwrap();
103
    let media = Arc::new(MediaService::new(
104
        client.clone(),
105
        EngineConfig::default().max_media_bytes,
106
        EngineConfig::default().max_media_sources,
107
    ));
108
    let (_handle, mut service, updates) =
109
        Service::new(client, media, Some(store), EngineConfig::default())
110
            .await
111
            .unwrap();
112
113
    let connection = Connection::open(config.mentions_location()).unwrap();
114
    connection
115
        .execute_batch(
116
            "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;
117
             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;",
118
        )
119
        .unwrap();
120
121
    assert!(service.batch(batch.clone()).await.is_err());
122
    assert_eq!(service.data.revision, 0);
123
    assert!(service.data.entries.is_empty());
124
    assert!(service.data.checkpoints.is_empty());
125
    assert_eq!(updates.borrow().revision, 0);
126
    assert!(updates.borrow().entries.is_empty());
127
128
    let reopened = MentionsStore::open(&config, account(&service.client), MAX_BYTES)
129
        .await
130
        .unwrap();
131
    assert_eq!(reopened.revision(), 0);
132
    assert!(reopened.read::<Data>("inbox").unwrap().is_none());
133
    drop(reopened);
134
135
    connection
136
        .execute_batch(
137
            "DROP TRIGGER fail_mentions_inbox_insert;
138
             DROP TRIGGER fail_mentions_inbox_update;",
139
        )
140
        .unwrap();
141
    drop(connection);
142
143
    service.batch(batch.clone()).await.unwrap();
144
    assert_eq!(service.data.revision, 1);
145
    assert_eq!(service.data.entries.len(), 1);
146
    assert_eq!(
147
        service.data.checkpoints.get("mentions-connection"),
148
        Some(&batch.next_pos)
149
    );
150
    assert_eq!(updates.borrow().revision, 1);
151
    assert_eq!(updates.borrow().entries.len(), 1);
152
153
    let reopened = MentionsStore::open(&config, account(&service.client), MAX_BYTES)
154
        .await
155
        .unwrap();
156
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
157
    assert_eq!(reopened.revision(), 1);
158
    assert_eq!(persisted.revision, 1);
159
    assert_eq!(persisted.entries.len(), 1);
160
    assert_eq!(
161
        persisted.checkpoints.get("mentions-connection"),
162
        Some(&batch.next_pos)
163
    );
164
    drop(reopened);
165
166
    service.batch(batch).await.unwrap();
167
    assert_eq!(service.data.revision, 1);
168
    assert_eq!(service.store.as_ref().unwrap().revision(), 1);
169
170
    drop(service);
171
    config.delete().await.unwrap();
172
}
173
174
#[test]
175
fn repair_fields_default_when_loading_an_older_inbox() {
176
    let mut inbox = serde_json::to_value(Data::default()).unwrap();
177
    inbox.as_object_mut().unwrap().remove("repair_policy");
178
    inbox.as_object_mut().unwrap().remove("repair_coverage");
179
180
    let restored: Data = serde_json::from_value(inbox).unwrap();
181
    assert_eq!(restored.repair_policy, HistoryRepairPolicy::default());
182
    assert!(restored.repair_coverage.is_empty());
183
}
184
185
#[tokio::test]
186
async fn repair_policy_and_coverage_roundtrip_through_the_inbox() {
187
    let server = MatrixMockServer::new().await;
188
    let room_id = room_id!("!repair-coverage:localhost");
189
    let (client, _) = joined_client(&server, room_id).await;
190
    let config = config();
191
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
192
        .await
193
        .unwrap();
194
    let media = Arc::new(MediaService::new(
195
        client.clone(),
196
        EngineConfig::default().max_media_bytes,
197
        EngineConfig::default().max_media_sources,
198
    ));
199
    let (handle, mut service, _) =
200
        Service::new(client.clone(), media, Some(store), EngineConfig::default())
201
            .await
202
            .unwrap();
203
    service.recovery_budget = 0;
204
    service.latest_scan = false;
205
    let service_task = tokio::spawn(service.run());
206
207
    let default_policy = HistoryRepairPolicy::default();
208
    assert_eq!(handle.repair_policy().await.unwrap(), default_policy);
209
    let stored_policy = HistoryRepairPolicy::Bridge;
210
    handle
211
        .set_repair_policy(stored_policy.clone())
212
        .await
213
        .unwrap();
214
    handle.repair_resolved(room_id.to_owned()).await.unwrap();
215
    let coverage = handle.repair_coverage(room_id.to_owned()).await.unwrap();
216
    assert!(coverage.last_confirmed_ms.is_some());
217
    assert!(!coverage.reliable);
218
    assert_eq!(coverage.duration_ms(), None);
219
220
    handle.stop().await;
221
    service_task.await.unwrap();
222
    drop(handle);
223
224
    let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
225
        .await
226
        .unwrap();
227
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
228
    assert_eq!(persisted.repair_policy, stored_policy);
229
    let coverage = persisted.repair_coverage.get(room_id).unwrap();
230
    assert!(coverage.last_confirmed_ms.is_some());
231
    assert!(coverage.boundary_ms.is_none());
232
    assert!(!coverage.unresolved);
233
    assert!(!coverage.reliable);
234
    drop(reopened);
235
    config.delete().await.unwrap();
236
}
237
238
#[tokio::test]
239
async fn repair_limited_sync_freezes_the_last_room_checkpoint() {
240
    let server = MatrixMockServer::new().await;
241
    let room_id = room_id!("!repair-boundary:localhost");
242
    let (client, _) = joined_client(&server, room_id).await;
243
    let media = Arc::new(MediaService::new(
244
        client.clone(),
245
        EngineConfig::default().max_media_bytes,
246
        EngineConfig::default().max_media_sources,
247
    ));
248
    let (_handle, mut service, _) = Service::new(client, media, None, EngineConfig::default())
249
        .await
250
        .unwrap();
251
    service.data.checkpoints.insert(
252
        "mentions-connection".to_owned(),
253
        "position-before".to_owned(),
254
    );
255
    service.data.observed_rooms.insert(room_id.to_owned());
256
    service
257
        .data
258
        .repair_coverage
259
        .entry(room_id.to_owned())
260
        .or_default()
261
        .last_confirmed_ms = Some(1_000);
262
263
    let events = [
264
        EventFactory::new()
265
            .room(room_id)
266
            .text_msg("older event")
267
            .sender(user_id!("@sender:localhost"))
268
            .event_id(event_id!("$repair-older:localhost"))
269
            .into_raw_sync(),
270
        EventFactory::new()
271
            .room(room_id)
272
            .text_msg("latest event")
273
            .sender(user_id!("@sender:localhost"))
274
            .event_id(event_id!("$repair-latest:localhost"))
275
            .into_raw_sync(),
276
    ];
277
    let timeline: Vec<_> = events
278
        .iter()
279
        .map(|event| serde_json::to_value(event).unwrap())
280
        .collect();
281
    let raw_room = serde_json::from_value(json!({
282
        "limited": true,
283
        "prev_batch": "repair-gap-token",
284
        "timeline": timeline,
285
    }))
286
    .unwrap();
287
    let batch = ResponseCommitBatch {
288
        connection_id: "mentions-connection".to_owned(),
289
        previous_pos: Some("position-before".to_owned()),
290
        next_pos: "position-after".to_owned(),
291
        response: Default::default(),
292
        raw_rooms: [(room_id.to_owned(), raw_room)].into(),
293
    };
294
295
    service.batch(batch).await.unwrap();
296
297
    let coverage = service.data.repair_coverage.get(room_id).unwrap();
298
    assert_eq!(coverage.last_confirmed_ms, Some(1_000));
299
    assert!(
300
        coverage
301
            .boundary_ms
302
            .is_some_and(|boundary| boundary > 1_000)
303
    );
304
    assert!(coverage.unresolved);
305
    assert!(!coverage.reliable);
306
    assert_eq!(coverage.duration_ms(), None);
307
    assert_eq!(
308
        coverage.latest_anchor.as_deref(),
309
        Some(event_id!("$repair-latest:localhost"))
310
    );
311
}
312
313
#[tokio::test]
314
async fn service_recovers_notification_history_into_durable_unread_inbox() {
315
    let server = MatrixMockServer::new().await;
316
    let room_id = room_id!("!mentions:localhost");
317
    let (client, own_user) = joined_client(&server, room_id).await;
318
    let first = EventFactory::new()
319
        .room(room_id)
320
        .text_msg("history mention one")
321
        .mentions(Mentions::with_user_ids([own_user.clone()]))
322
        .event_id(event_id!("$history-one:localhost"))
323
        .sender(user_id!("@bob:localhost"))
324
        .into_raw_sync();
325
    let second = EventFactory::new()
326
        .room(room_id)
327
        .text_msg("history mention two")
328
        .mentions(Mentions::with_user_ids([own_user]))
329
        .event_id(event_id!("$history-two:localhost"))
330
        .sender(user_id!("@bob:localhost"))
331
        .into_raw_sync();
332
    let first = serde_json::from_str::<serde_json::Value>(first.json().get()).unwrap();
333
    let second = serde_json::from_str::<serde_json::Value>(second.json().get()).unwrap();
334
    Mock::given(method("GET"))
335
        .and(path("/_matrix/client/v3/notifications"))
336
        .and(query_param_is_missing("from"))
337
        .and(query_param("limit", "50"))
338
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
339
            "notifications": [{
340
                "actions": ["notify"],
341
                "event": first,
342
                "read": true,
343
                "room_id": room_id.as_str(),
344
                "ts": 1,
345
            }],
346
            "next_token": "history-page-two",
347
        })))
348
        .mount(server.server())
349
        .await;
350
    Mock::given(method("GET"))
351
        .and(path("/_matrix/client/v3/notifications"))
352
        .and(query_param("from", "history-page-two"))
353
        .and(query_param("limit", "50"))
354
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
355
            "notifications": [{
356
                "actions": ["notify"],
357
                "event": second,
358
                "read": true,
359
                "room_id": room_id.as_str(),
360
                "ts": 2,
361
            }],
362
        })))
363
        .mount(server.server())
364
        .await;
365
366
    let config = config();
367
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
368
        .await
369
        .unwrap();
370
    let media = Arc::new(MediaService::new(
371
        client.clone(),
372
        EngineConfig::default().max_media_bytes,
373
        EngineConfig::default().max_media_sources,
374
    ));
375
    let (handle, service, mut updates) =
376
        Service::new(client.clone(), media, Some(store), EngineConfig::default())
377
            .await
378
            .unwrap();
379
    let service_task = tokio::spawn(service.run());
380
381
    let discovered = tokio::time::timeout(Duration::from_secs(10), async {
382
        loop {
383
            let page = updates.borrow_and_update().clone();
384
            if page.entries.len() == 2 && !page.coverage.recovering {
385
                break page;
386
            }
387
            updates.changed().await.unwrap();
388
        }
389
    })
390
    .await;
391
    handle.stop().await;
392
    service_task.await.unwrap();
393
    let discovered = discovered.expect("notification history should be recovered");
394
    assert_eq!(discovered.entries.len(), 2);
395
    assert!(discovered.entries.iter().all(|entry| !entry.read));
396
397
    let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
398
        .await
399
        .unwrap();
400
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
401
    assert_eq!(reopened.revision(), persisted.revision);
402
    assert_eq!(persisted.entries.len(), 2);
403
    assert!(persisted.entries.values().all(|entry| !entry.read));
404
    assert!(persisted.latest_cursor.is_none());
405
    assert!(persisted.history_cursor.is_none());
406
    drop(reopened);
407
408
    config.delete().await.unwrap();
409
}
410
411
fn message_body(item: &TimelineItem) -> Option<&str> {
412
    match item {
413
        TimelineItem::Event(event) => match &event.content {
414
            TimelineContent::Message(message) => Some(&message.body),
415
            _ => None,
416
        },
417
        _ => None,
418
    }
419
}
420
421
fn media_registration() -> MediaRegistration {
422
    MediaRegistration {
423
        id: MediaId::new("test-scope#image".to_owned()),
424
        kind: MediaKind::Image,
425
        full: MediaSourceInfo {
426
            source: MediaSource::Plain(ruma::mxc_uri!("mxc://mentions.example/image").to_owned()),
427
            mimetype: Some("image/png".to_owned()),
428
            declared_size: Some(1234),
429
        },
430
        thumbnail: None,
431
    }
432
}
433
434
#[tokio::test]
435
async fn live_context_survives_capture_then_edit_and_redaction() {
436
    let server = MatrixMockServer::new().await;
437
    let room_id = room_id!("!mentions:localhost");
438
    let (client, own_user) = joined_client(&server, room_id).await;
439
    let mention = EventFactory::new()
440
        .room(room_id)
441
        .text_msg("direct mention")
442
        .mentions(Mentions::with_user_ids([own_user]))
443
        .event_id(event_id!("$live-mention:localhost"))
444
        .sender(user_id!("@bob:localhost"))
445
        .into_raw_sync();
446
    let mut mention: serde_json::Value = serde_json::from_str(mention.json().get()).unwrap();
447
    mention["origin_server_ts"] = json!(10);
448
    let following = EventFactory::new()
449
        .room(room_id)
450
        .text_msg("following")
451
        .event_id(event_id!("$live-following:localhost"))
452
        .sender(user_id!("@bob:localhost"))
453
        .into_raw_sync();
454
    let mut following: serde_json::Value = serde_json::from_str(following.json().get()).unwrap();
455
    following["origin_server_ts"] = json!(11);
456
457
    let media = Arc::new(MediaService::new(
458
        client.clone(),
459
        EngineConfig::default().max_media_bytes,
460
        EngineConfig::default().max_media_sources,
461
    ));
462
    let (_handle, mut service, _updates) =
463
        Service::new(client, media, None, EngineConfig::default())
464
            .await
465
            .unwrap();
466
    let room_id = room_id.to_owned();
467
    let mention_key = MentionKey {
468
        room_id: room_id.clone(),
469
        event_id: event_id!("$live-mention:localhost").to_owned(),
470
    };
471
    let following_id = format!("{room_id}\n$live-following:localhost");
472
    let mut data = Data::default();
473
474
    service.observe(&mut data, &room_id, mention).await.unwrap();
475
    service
476
        .observe(&mut data, &room_id, following)
477
        .await
478
        .unwrap();
479
480
    let mention_identity = id(&mention_key);
481
    let entry = data.entries.get(&mention_identity).unwrap();
482
    let revision = entry.revision;
483
    let target = entry.event.clone();
484
    service.captured(
485
        &mut data,
486
        mention_identity.clone(),
487
        revision,
488
        Ok((
489
            crate::EventContext {
490
                events_before: Vec::new(),
491
                event: Some(target),
492
                events_after: Vec::new(),
493
                start_token: None,
494
                end_token: None,
495
            },
496
            Vec::new(),
497
            Vec::new(),
498
        )),
499
    );
500
501
    assert_eq!(
502
        data.entries[&mention_identity].capture,
503
        MentionCaptureState::Partial
504
    );
505
    assert!(data.events.contains_key(&following_id));
506
    let (saved, _) = data.context(&mention_key).unwrap();
507
    let context = saved.context.unwrap();
508
    assert_eq!(context.events_after.len(), 1);
509
    assert_eq!(message_body(&context.events_after[0]), Some("following"));
510
511
    let edit = json!({
512
        "content": {
513
            "body": "* edited following",
514
            "m.new_content": {"body": "edited following", "msgtype": "m.text"},
515
            "m.relates_to": {"event_id": "$live-following:localhost", "rel_type": "m.replace"},
516
            "msgtype": "m.text"
517
        },
518
        "event_id": "$live-following-edit:localhost",
519
        "origin_server_ts": 12,
520
        "room_id": room_id.as_str(),
521
        "sender": "@bob:localhost",
522
        "type": "m.room.message"
523
    });
524
    service.observe(&mut data, &room_id, edit).await.unwrap();
525
    let (saved, _) = data.context(&mention_key).unwrap();
526
    let context = saved.context.unwrap();
527
    assert_eq!(
528
        message_body(&context.events_after[0]),
529
        Some("edited following")
530
    );
531
532
    let saved_following = data.events.get_mut(&following_id).unwrap();
533
    assert!(saved_following.raw.is_some());
534
    saved_following.media.push(media_registration());
535
    assert_eq!(saved_following.media.len(), 1);
536
    let redaction = json!({
537
        "content": {},
538
        "event_id": "$live-following-redaction:localhost",
539
        "origin_server_ts": 13,
540
        "redacts": "$live-following:localhost",
541
        "room_id": room_id.as_str(),
542
        "sender": "@bob:localhost",
543
        "type": "m.room.redaction"
544
    });
545
    service
546
        .observe(&mut data, &room_id, redaction)
547
        .await
548
        .unwrap();
549
550
    let saved_following = data.events.get(&following_id).unwrap();
551
    assert!(matches!(
552
        &saved_following.item,
553
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
554
    ));
555
    assert!(saved_following.raw.is_none());
556
    assert!(saved_following.media.is_empty());
557
    let (saved, _) = data.context(&mention_key).unwrap();
558
    let context = saved.context.unwrap();
559
    assert!(matches!(
560
        &context.events_after[0],
561
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
562
    ));
563
    data.windows.remove(&mention_identity);
564
    service.data = data;
565
    let CommandOutput::MentionContext(context) = service
566
        .command(MentionsCommand::RetryContext(mention_key))
567
        .await
568
        .unwrap()
569
    else {
570
        panic!("retry should return saved context");
571
    };
572
    assert_eq!(context.state, MentionCaptureState::Pending);
573
    assert!(context.context.unwrap().event.is_some());
574
}
575
576
#[tokio::test]
577
async fn explicit_recovery_rechecks_server_support() {
578
    let server = MatrixMockServer::new().await;
579
    let (client, _) = joined_client(&server, room_id!("!mentions:localhost")).await;
580
    let config = EngineConfig::default();
581
    let media = Arc::new(MediaService::new(
582
        client.clone(),
583
        config.max_media_bytes,
584
        config.max_media_sources,
585
    ));
586
    let (_, mut service, _) = Service::new(client, media, None, config).await.unwrap();
587
    service.data.coverage.historical_supported = Some(false);
588
    service.recovery_budget = 0;
589
    service.command(MentionsCommand::Recover).await.unwrap();
590
    assert_eq!(service.data.coverage.historical_supported, None);
591
    assert!(service.data.coverage.recovering);
592
    assert_eq!(service.recovery_budget, 20);
593
}