atagen

macltok

public
Star0
Watch0
ae941fa10a10b5ece2098aef89e79951fb7785c3
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/mentions/state_tests.rsRust440 lines14.1 KB
1
use ruma::{event_id, user_id};
2
3
use super::state::{Data, SavedEvent, Window, id};
4
use crate::{
5
    MentionCaptureState, MentionEntry, MentionKey, MentionReason, Message, MessageKind, SendState,
6
    Sender, TimelineContent, TimelineEvent, TimelineItem, TimelineItemId,
7
};
8
9
fn key(room: &str, event: &str) -> MentionKey {
10
    MentionKey {
11
        room_id: format!("!{room}:mentions.example").parse().unwrap(),
12
        event_id: format!("${event}:mentions.example").parse().unwrap(),
13
    }
14
}
15
16
fn entry(key: MentionKey, timestamp_ms: u64, discovery_revision: u64, read: bool) -> MentionEntry {
17
    MentionEntry {
18
        key,
19
        timestamp_ms,
20
        discovered_at_ms: timestamp_ms,
21
        discovery_revision,
22
        revision: discovery_revision,
23
        event: TimelineItem::DayDivider { timestamp_ms },
24
        reasons: vec![MentionReason::Direct],
25
        read,
26
        capture: MentionCaptureState::Ready,
27
        room_name: None,
28
        notification_suppressed: false,
29
    }
30
}
31
32
fn message_event(event_id: &str, timestamp_ms: u64, body: &str) -> TimelineItem {
33
    let event_id: ruma::OwnedEventId = event_id.parse().unwrap();
34
    TimelineItem::Event(Box::new(TimelineEvent {
35
        item_id: TimelineItemId::new(format!("item-{event_id}")),
36
        event_id: Some(event_id),
37
        transaction_id: None,
38
        sender: Sender {
39
            user_id: user_id!("@bob:mentions.example").to_owned(),
40
            display_name: Some("Bob".to_owned()),
41
            avatar: None,
42
            name_ambiguous: false,
43
        },
44
        timestamp_ms,
45
        own: false,
46
        send_state: SendState::Sent,
47
        content: TimelineContent::Message(Message {
48
            kind: MessageKind::Text,
49
            body: body.to_owned(),
50
            geo_uri: None,
51
            formatted_body: None,
52
            mentions: None,
53
            edited: false,
54
            media: None,
55
            link_previews: Vec::new(),
56
        }),
57
        reply_to: None,
58
        thread_root: None,
59
        thread_summary: None,
60
        reactions: Vec::new(),
61
        read_by: Vec::new(),
62
        editable: false,
63
        highlight: false,
64
    }))
65
}
66
67
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
68
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
69
async fn pagination_keeps_its_discovery_boundary_and_stable_ties() {
70
    let account = user_id!("@alice:mentions.example").to_owned();
71
    let ordered = vec![
72
        key("room", "latest"),
73
        key("room", "tie-z"),
74
        key("room", "tie-b"),
75
        key("room", "tie-a"),
76
        key("room", "oldest"),
77
    ];
78
    let mut data = Data {
79
        revision: 1,
80
        ..Data::default()
81
    };
82
    for (mention, timestamp) in [
83
        (ordered[0].clone(), 300),
84
        (ordered[1].clone(), 200),
85
        (ordered[2].clone(), 200),
86
        (ordered[3].clone(), 200),
87
        (ordered[4].clone(), 100),
88
    ] {
89
        data.entries
90
            .insert(id(&mention), entry(mention, timestamp, 1, false));
91
    }
92
93
    let first = data.page(&account, None, 2).unwrap();
94
    assert_eq!(
95
        first
96
            .entries
97
            .iter()
98
            .map(|entry| entry.key.clone())
99
            .collect::<Vec<_>>(),
100
        ordered[..2].to_vec()
101
    );
102
    let mut cursor = first.next_cursor.unwrap();
103
104
    data.revision = 2;
105
    let later = key("room", "discovered-later");
106
    data.entries.insert(id(&later), entry(later, 50, 2, false));
107
108
    let mut paged = first
109
        .entries
110
        .into_iter()
111
        .map(|entry| entry.key)
112
        .collect::<Vec<_>>();
113
    loop {
114
        let page = data.page(&account, Some(&cursor), 2).unwrap();
115
        paged.extend(page.entries.into_iter().map(|entry| entry.key));
116
        match page.next_cursor {
117
            Some(next) => cursor = next,
118
            None => break,
119
        }
120
    }
121
    assert_eq!(paged, ordered);
122
}
123
124
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
125
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
126
async fn pagination_rejects_a_forged_account_and_a_future_revision() {
127
    let account = user_id!("@alice:mentions.example").to_owned();
128
    let mut data = Data {
129
        revision: 1,
130
        ..Data::default()
131
    };
132
    for event in ["one", "two"] {
133
        let mention = key("room", event);
134
        data.entries
135
            .insert(id(&mention), entry(mention, 10, 1, false));
136
    }
137
    let cursor = data.page(&account, None, 1).unwrap().next_cursor.unwrap();
138
139
    let mut forged_account: serde_json::Value = serde_json::from_str(&cursor).unwrap();
140
    forged_account["account"] = serde_json::Value::String("@mallory:mentions.example".to_owned());
141
    assert!(
142
        data.page(&account, Some(&forged_account.to_string()), 1)
143
            .is_err()
144
    );
145
146
    let mut future_revision: serde_json::Value = serde_json::from_str(&cursor).unwrap();
147
    future_revision["revision"] = serde_json::json!(data.revision + 1);
148
    assert!(
149
        data.page(&account, Some(&future_revision.to_string()), 1)
150
            .is_err()
151
    );
152
}
153
154
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
155
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
156
async fn retention_discards_read_context_first_and_keeps_shared_events_until_unused() {
157
    let read_key = key("room", "read-mention");
158
    let unread_key = key("room", "unread-mention");
159
    let shared_key = key("room", "shared-context");
160
    let read_id = id(&read_key);
161
    let unread_id = id(&unread_key);
162
    let shared_id = id(&shared_key);
163
    let mut data = Data {
164
        revision: 3,
165
        ..Data::default()
166
    };
167
    data.entries
168
        .insert(read_id.clone(), entry(read_key, 10, 1, true));
169
    data.entries
170
        .insert(unread_id.clone(), entry(unread_key, 20, 2, false));
171
    data.events.insert(
172
        shared_id.clone(),
173
        SavedEvent {
174
            item: message_event("$shared-context:mentions.example", 15, "shared"),
175
            media: Vec::new(),
176
            raw: None,
177
        },
178
    );
179
    data.windows.insert(
180
        read_id.clone(),
181
        Window {
182
            before: vec![shared_id.clone()],
183
            target: None,
184
            after: Vec::new(),
185
            start: None,
186
            end: None,
187
        },
188
    );
189
    data.windows.insert(
190
        unread_id.clone(),
191
        Window {
192
            before: vec![shared_id.clone()],
193
            target: None,
194
            after: Vec::new(),
195
            start: None,
196
            end: None,
197
        },
198
    );
199
200
    assert!(data.evict_one().is_some());
201
    assert!(!data.windows.contains_key(&read_id));
202
    assert!(data.windows.contains_key(&unread_id));
203
    assert_eq!(
204
        data.entries[&read_id].capture,
205
        MentionCaptureState::Unavailable
206
    );
207
    assert!(!data.entries[&unread_id].read);
208
    assert!(data.events.contains_key(&shared_id));
209
210
    assert!(data.evict_one().is_some());
211
    assert!(data.windows.is_empty());
212
    assert!(data.entries.contains_key(&read_id));
213
    assert!(data.entries.contains_key(&unread_id));
214
    assert!(!data.events.contains_key(&shared_id));
215
}
216
217
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
218
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
219
async fn context_uses_the_redacted_saved_event_instead_of_the_entry_preview() {
220
    let mention = key("room", "redacted-target");
221
    let mention_id = id(&mention);
222
    let original = message_event(
223
        "$redacted-target:mentions.example",
224
        42,
225
        "private original body",
226
    );
227
    let mut redacted = original.clone();
228
    if let TimelineItem::Event(event) = &mut redacted {
229
        event.content = TimelineContent::Redacted;
230
        event.reply_to = None;
231
        event.reactions.clear();
232
    }
233
    let mut data = Data::default();
234
    data.entries.insert(
235
        mention_id.clone(),
236
        MentionEntry {
237
            event: original,
238
            ..entry(mention.clone(), 42, 1, false)
239
        },
240
    );
241
    data.events.insert(
242
        mention_id.clone(),
243
        SavedEvent {
244
            item: redacted,
245
            media: Vec::new(),
246
            raw: None,
247
        },
248
    );
249
    data.windows.insert(
250
        mention_id,
251
        Window {
252
            before: Vec::new(),
253
            target: Some(id(&mention)),
254
            after: Vec::new(),
255
            start: Some("start".to_owned()),
256
            end: Some("end".to_owned()),
257
        },
258
    );
259
260
    let (context, media) = data.context(&mention).unwrap();
261
    let saved = context.context.unwrap().event;
262
    let TimelineItem::Event(saved) = saved else {
263
        panic!("the saved target should remain a timeline event");
264
    };
265
    assert_eq!(saved.content, TimelineContent::Redacted);
266
    assert_eq!(
267
        saved.event_id.as_deref(),
268
        Some(event_id!("$redacted-target:mentions.example"))
269
    );
270
    assert!(media.is_empty());
271
}
272
273
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
274
mod persistence {
275
    use std::sync::atomic::{AtomicUsize, Ordering};
276
277
    use ruma::{events::room::MediaSource, user_id};
278
    use serde_json::json;
279
280
    use super::*;
281
    use crate::{
282
        MediaId, MediaKind, StoreConfig,
283
        mentions::store::{Account, MentionsStore, Write},
284
        normalise::{MediaRegistration, MediaSourceInfo},
285
    };
286
287
    static STORES: AtomicUsize = AtomicUsize::new(0);
288
    const MAX_BYTES: usize = 64 * 1024 * 1024;
289
290
    fn account() -> Account {
291
        Account {
292
            homeserver: "https://mentions.example/".to_owned(),
293
            user_id: user_id!("@alice:mentions.example").to_owned(),
294
        }
295
    }
296
297
    fn config() -> StoreConfig {
298
        let number = STORES.fetch_add(1, Ordering::Relaxed);
299
        #[cfg(feature = "sqlite")]
300
        {
301
            StoreConfig::sqlite(
302
                std::env::temp_dir().join(format!(
303
                    "macltok-mentions-state-{}-{number}",
304
                    std::process::id()
305
                )),
306
                "state test passphrase",
307
            )
308
        }
309
        #[cfg(feature = "indexeddb")]
310
        {
311
            StoreConfig::indexeddb(
312
                format!("macltok-mentions-state-{}-{number}", js_sys::Date::now()),
313
                "state test passphrase",
314
            )
315
        }
316
    }
317
318
    fn media_registration() -> MediaRegistration {
319
        MediaRegistration {
320
            id: MediaId::new("test-scope#image".to_owned()),
321
            kind: MediaKind::Image,
322
            full: MediaSourceInfo {
323
                source: MediaSource::Plain(
324
                    ruma::mxc_uri!("mxc://mentions.example/image").to_owned(),
325
                ),
326
                mimetype: Some("image/png".to_owned()),
327
                declared_size: Some(1234),
328
            },
329
            thumbnail: None,
330
        }
331
    }
332
333
    fn saved_data() -> Data {
334
        let mention = key("persisted-room", "persisted-mention");
335
        let mention_id = id(&mention);
336
        let context_item =
337
            message_event("$persisted-mention:mentions.example", 42, "saved message");
338
        let mut data = Data {
339
            revision: 1,
340
            ..Data::default()
341
        };
342
        data.entries.insert(
343
            mention_id.clone(),
344
            MentionEntry {
345
                event: context_item.clone(),
346
                ..entry(mention.clone(), 42, 1, true)
347
            },
348
        );
349
        data.events.insert(
350
            mention_id.clone(),
351
            SavedEvent {
352
                item: context_item,
353
                media: vec![media_registration()],
354
                raw: Some(
355
                    json!({"event_id": "$persisted-mention:mentions.example", "body": "saved raw"}),
356
                ),
357
            },
358
        );
359
        data.windows.insert(
360
            mention_id.clone(),
361
            Window {
362
                before: Vec::new(),
363
                target: Some(mention_id),
364
                after: Vec::new(),
365
                start: Some("start-token".to_owned()),
366
                end: Some("end-token".to_owned()),
367
            },
368
        );
369
        data.history_cursor = Some("history-position".to_owned());
370
        data
371
    }
372
373
    async fn open(config: &StoreConfig) -> MentionsStore {
374
        MentionsStore::open(config, account(), MAX_BYTES)
375
            .await
376
            .unwrap()
377
    }
378
379
    #[cfg_attr(not(target_family = "wasm"), tokio::test)]
380
    #[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
381
    async fn encrypted_store_roundtrips_inbox_context_and_read_state() {
382
        let config = config();
383
        let data = saved_data();
384
        let mut store = open(&config).await;
385
        assert_eq!(
386
            store
387
                .commit(
388
                    0,
389
                    vec![Write::Set(
390
                        "inbox".to_owned(),
391
                        serde_json::value::to_raw_value(&data).unwrap(),
392
                    )],
393
                )
394
                .await
395
                .unwrap(),
396
            1
397
        );
398
        drop(store);
399
400
        let mut restored = open(&config).await;
401
        let mut restored_data: Data = restored.read("inbox").unwrap().unwrap();
402
        assert_eq!(restored.revision(), restored_data.revision);
403
        assert!(restored_data.entries.values().next().unwrap().read);
404
        let mention = restored_data.entries.values().next().unwrap().key.clone();
405
        let (context, media) = restored_data.context(&mention).unwrap();
406
        assert_eq!(
407
            context.context.unwrap().start_token.as_deref(),
408
            Some("start-token")
409
        );
410
        assert_eq!(media.len(), 1);
411
        assert_eq!(media[0].id.as_str(), "test-scope#image");
412
        assert_eq!(media[0].full.mimetype.as_deref(), Some("image/png"));
413
        let event = restored_data.events.values().next().unwrap();
414
        assert_eq!(event.raw.as_ref().unwrap()["body"], "saved raw");
415
416
        restored_data.revision = 2;
417
        let updated_entry = restored_data.entries.values_mut().next().unwrap();
418
        updated_entry.read = false;
419
        updated_entry.revision = 2;
420
        restored
421
            .commit(
422
                1,
423
                vec![Write::Set(
424
                    "inbox".to_owned(),
425
                    serde_json::value::to_raw_value(&restored_data).unwrap(),
426
                )],
427
            )
428
            .await
429
            .unwrap();
430
        drop(restored);
431
432
        let restored = open(&config).await;
433
        let restored_data: Data = restored.read("inbox").unwrap().unwrap();
434
        assert_eq!(restored.revision(), 2);
435
        assert_eq!(restored_data.revision, 2);
436
        assert!(!restored_data.entries.values().next().unwrap().read);
437
        drop(restored);
438
        config.delete().await.unwrap();
439
    }
440
}