atagen

macltok

public
Star0
Watch0
cbb715e119632f60c9b3ec1bc8803f1f33e39dce
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/mentions/service_tests.rsRust454 lines15.8 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
#[tokio::test]
175
async fn service_recovers_notification_history_into_durable_unread_inbox() {
176
    let server = MatrixMockServer::new().await;
177
    let room_id = room_id!("!mentions:localhost");
178
    let (client, own_user) = joined_client(&server, room_id).await;
179
    let first = EventFactory::new()
180
        .room(room_id)
181
        .text_msg("history mention one")
182
        .mentions(Mentions::with_user_ids([own_user.clone()]))
183
        .event_id(event_id!("$history-one:localhost"))
184
        .sender(user_id!("@bob:localhost"))
185
        .into_raw_sync();
186
    let second = EventFactory::new()
187
        .room(room_id)
188
        .text_msg("history mention two")
189
        .mentions(Mentions::with_user_ids([own_user]))
190
        .event_id(event_id!("$history-two:localhost"))
191
        .sender(user_id!("@bob:localhost"))
192
        .into_raw_sync();
193
    let first = serde_json::from_str::<serde_json::Value>(first.json().get()).unwrap();
194
    let second = serde_json::from_str::<serde_json::Value>(second.json().get()).unwrap();
195
    Mock::given(method("GET"))
196
        .and(path("/_matrix/client/v3/notifications"))
197
        .and(query_param_is_missing("from"))
198
        .and(query_param("limit", "50"))
199
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
200
            "notifications": [{
201
                "actions": ["notify"],
202
                "event": first,
203
                "read": true,
204
                "room_id": room_id.as_str(),
205
                "ts": 1,
206
            }],
207
            "next_token": "history-page-two",
208
        })))
209
        .mount(server.server())
210
        .await;
211
    Mock::given(method("GET"))
212
        .and(path("/_matrix/client/v3/notifications"))
213
        .and(query_param("from", "history-page-two"))
214
        .and(query_param("limit", "50"))
215
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
216
            "notifications": [{
217
                "actions": ["notify"],
218
                "event": second,
219
                "read": true,
220
                "room_id": room_id.as_str(),
221
                "ts": 2,
222
            }],
223
        })))
224
        .mount(server.server())
225
        .await;
226
227
    let config = config();
228
    let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
229
        .await
230
        .unwrap();
231
    let media = Arc::new(MediaService::new(
232
        client.clone(),
233
        EngineConfig::default().max_media_bytes,
234
        EngineConfig::default().max_media_sources,
235
    ));
236
    let (handle, service, mut updates) =
237
        Service::new(client.clone(), media, Some(store), EngineConfig::default())
238
            .await
239
            .unwrap();
240
    let service_task = tokio::spawn(service.run());
241
242
    let discovered = tokio::time::timeout(Duration::from_secs(10), async {
243
        loop {
244
            let page = updates.borrow_and_update().clone();
245
            if page.entries.len() == 2 && !page.coverage.recovering {
246
                break page;
247
            }
248
            updates.changed().await.unwrap();
249
        }
250
    })
251
    .await;
252
    handle.stop().await;
253
    service_task.await.unwrap();
254
    let discovered = discovered.expect("notification history should be recovered");
255
    assert_eq!(discovered.entries.len(), 2);
256
    assert!(discovered.entries.iter().all(|entry| !entry.read));
257
258
    let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
259
        .await
260
        .unwrap();
261
    let persisted: Data = reopened.read("inbox").unwrap().unwrap();
262
    assert_eq!(reopened.revision(), persisted.revision);
263
    assert_eq!(persisted.entries.len(), 2);
264
    assert!(persisted.entries.values().all(|entry| !entry.read));
265
    assert!(persisted.latest_cursor.is_none());
266
    assert!(persisted.history_cursor.is_none());
267
    drop(reopened);
268
269
    config.delete().await.unwrap();
270
}
271
272
fn message_body(item: &TimelineItem) -> Option<&str> {
273
    match item {
274
        TimelineItem::Event(event) => match &event.content {
275
            TimelineContent::Message(message) => Some(&message.body),
276
            _ => None,
277
        },
278
        _ => None,
279
    }
280
}
281
282
fn media_registration() -> MediaRegistration {
283
    MediaRegistration {
284
        id: MediaId::new("test-scope#image".to_owned()),
285
        kind: MediaKind::Image,
286
        full: MediaSourceInfo {
287
            source: MediaSource::Plain(ruma::mxc_uri!("mxc://mentions.example/image").to_owned()),
288
            mimetype: Some("image/png".to_owned()),
289
            declared_size: Some(1234),
290
        },
291
        thumbnail: None,
292
    }
293
}
294
295
#[tokio::test]
296
async fn live_context_survives_capture_then_edit_and_redaction() {
297
    let server = MatrixMockServer::new().await;
298
    let room_id = room_id!("!mentions:localhost");
299
    let (client, own_user) = joined_client(&server, room_id).await;
300
    let mention = EventFactory::new()
301
        .room(room_id)
302
        .text_msg("direct mention")
303
        .mentions(Mentions::with_user_ids([own_user]))
304
        .event_id(event_id!("$live-mention:localhost"))
305
        .sender(user_id!("@bob:localhost"))
306
        .into_raw_sync();
307
    let mut mention: serde_json::Value = serde_json::from_str(mention.json().get()).unwrap();
308
    mention["origin_server_ts"] = json!(10);
309
    let following = EventFactory::new()
310
        .room(room_id)
311
        .text_msg("following")
312
        .event_id(event_id!("$live-following:localhost"))
313
        .sender(user_id!("@bob:localhost"))
314
        .into_raw_sync();
315
    let mut following: serde_json::Value = serde_json::from_str(following.json().get()).unwrap();
316
    following["origin_server_ts"] = json!(11);
317
318
    let media = Arc::new(MediaService::new(
319
        client.clone(),
320
        EngineConfig::default().max_media_bytes,
321
        EngineConfig::default().max_media_sources,
322
    ));
323
    let (_handle, mut service, _updates) =
324
        Service::new(client, media, None, EngineConfig::default())
325
            .await
326
            .unwrap();
327
    let room_id = room_id.to_owned();
328
    let mention_key = MentionKey {
329
        room_id: room_id.clone(),
330
        event_id: event_id!("$live-mention:localhost").to_owned(),
331
    };
332
    let following_id = format!("{room_id}\n$live-following:localhost");
333
    let mut data = Data::default();
334
335
    service.observe(&mut data, &room_id, mention).await.unwrap();
336
    service
337
        .observe(&mut data, &room_id, following)
338
        .await
339
        .unwrap();
340
341
    let mention_identity = id(&mention_key);
342
    let entry = data.entries.get(&mention_identity).unwrap();
343
    let revision = entry.revision;
344
    let target = entry.event.clone();
345
    service.captured(
346
        &mut data,
347
        mention_identity.clone(),
348
        revision,
349
        Ok((
350
            crate::EventContext {
351
                events_before: Vec::new(),
352
                event: Some(target),
353
                events_after: Vec::new(),
354
                start_token: None,
355
                end_token: None,
356
            },
357
            Vec::new(),
358
            Vec::new(),
359
        )),
360
    );
361
362
    assert_eq!(
363
        data.entries[&mention_identity].capture,
364
        MentionCaptureState::Partial
365
    );
366
    assert!(data.events.contains_key(&following_id));
367
    let (saved, _) = data.context(&mention_key).unwrap();
368
    let context = saved.context.unwrap();
369
    assert_eq!(context.events_after.len(), 1);
370
    assert_eq!(message_body(&context.events_after[0]), Some("following"));
371
372
    let edit = json!({
373
        "content": {
374
            "body": "* edited following",
375
            "m.new_content": {"body": "edited following", "msgtype": "m.text"},
376
            "m.relates_to": {"event_id": "$live-following:localhost", "rel_type": "m.replace"},
377
            "msgtype": "m.text"
378
        },
379
        "event_id": "$live-following-edit:localhost",
380
        "origin_server_ts": 12,
381
        "room_id": room_id.as_str(),
382
        "sender": "@bob:localhost",
383
        "type": "m.room.message"
384
    });
385
    service.observe(&mut data, &room_id, edit).await.unwrap();
386
    let (saved, _) = data.context(&mention_key).unwrap();
387
    let context = saved.context.unwrap();
388
    assert_eq!(
389
        message_body(&context.events_after[0]),
390
        Some("edited following")
391
    );
392
393
    let saved_following = data.events.get_mut(&following_id).unwrap();
394
    assert!(saved_following.raw.is_some());
395
    saved_following.media.push(media_registration());
396
    assert_eq!(saved_following.media.len(), 1);
397
    let redaction = json!({
398
        "content": {},
399
        "event_id": "$live-following-redaction:localhost",
400
        "origin_server_ts": 13,
401
        "redacts": "$live-following:localhost",
402
        "room_id": room_id.as_str(),
403
        "sender": "@bob:localhost",
404
        "type": "m.room.redaction"
405
    });
406
    service
407
        .observe(&mut data, &room_id, redaction)
408
        .await
409
        .unwrap();
410
411
    let saved_following = data.events.get(&following_id).unwrap();
412
    assert!(matches!(
413
        &saved_following.item,
414
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
415
    ));
416
    assert!(saved_following.raw.is_none());
417
    assert!(saved_following.media.is_empty());
418
    let (saved, _) = data.context(&mention_key).unwrap();
419
    let context = saved.context.unwrap();
420
    assert!(matches!(
421
        &context.events_after[0],
422
        TimelineItem::Event(event) if event.content == TimelineContent::Redacted
423
    ));
424
    data.windows.remove(&mention_identity);
425
    service.data = data;
426
    let CommandOutput::MentionContext(context) = service
427
        .command(MentionsCommand::RetryContext(mention_key))
428
        .await
429
        .unwrap()
430
    else {
431
        panic!("retry should return saved context");
432
    };
433
    assert_eq!(context.state, MentionCaptureState::Pending);
434
    assert!(context.context.unwrap().event.is_some());
435
}
436
437
#[tokio::test]
438
async fn explicit_recovery_rechecks_server_support() {
439
    let server = MatrixMockServer::new().await;
440
    let (client, _) = joined_client(&server, room_id!("!mentions:localhost")).await;
441
    let config = EngineConfig::default();
442
    let media = Arc::new(MediaService::new(
443
        client.clone(),
444
        config.max_media_bytes,
445
        config.max_media_sources,
446
    ));
447
    let (_, mut service, _) = Service::new(client, media, None, config).await.unwrap();
448
    service.data.coverage.historical_supported = Some(false);
449
    service.recovery_budget = 0;
450
    service.command(MentionsCommand::Recover).await.unwrap();
451
    assert_eq!(service.data.coverage.historical_supported, None);
452
    assert!(service.data.coverage.recovering);
453
    assert_eq!(service.recovery_budget, 20);
454
}