1mod account;
2mod body;
3mod diagnostics;
4mod draft;
5mod event_log;
6mod label;
7mod message;
8mod pool;
9mod rules;
10mod search;
11mod semantic;
12mod snooze;
13mod sync_cursor;
14mod sync_log;
15mod sync_runtime_status;
16mod thread;
17
18pub use diagnostics::StoreRecordCounts;
19pub use event_log::{EventLogEntry, EventLogRefs};
20pub use pool::Store;
21pub use rules::{row_to_rule_json, row_to_rule_log_json, RuleLogInput, RuleRecordInput};
22pub use sync_log::{SyncLogEntry, SyncStatus};
23pub use sync_runtime_status::{SyncRuntimeStatus, SyncRuntimeStatusUpdate};
24
25#[cfg(test)]
26mod tests {
27 use super::*;
28 use chrono::TimeZone;
29 use mxr_core::*;
30
31 fn test_account() -> Account {
32 Account {
33 id: AccountId::new(),
34 name: "Test".to_string(),
35 email: "test@example.com".to_string(),
36 sync_backend: Some(BackendRef {
37 provider_kind: ProviderKind::Fake,
38 config_key: "fake".to_string(),
39 }),
40 send_backend: None,
41 enabled: true,
42 }
43 }
44
45 fn test_envelope(account_id: &AccountId) -> Envelope {
46 Envelope {
47 id: MessageId::new(),
48 account_id: account_id.clone(),
49 provider_id: "fake-1".to_string(),
50 thread_id: ThreadId::new(),
51 message_id_header: Some("<test@example.com>".to_string()),
52 in_reply_to: None,
53 references: vec![],
54 from: Address {
55 name: Some("Alice".to_string()),
56 email: "alice@example.com".to_string(),
57 },
58 to: vec![Address {
59 name: None,
60 email: "bob@example.com".to_string(),
61 }],
62 cc: vec![],
63 bcc: vec![],
64 subject: "Test subject".to_string(),
65 date: chrono::Utc::now(),
66 flags: MessageFlags::READ | MessageFlags::STARRED,
67 snippet: "Preview text".to_string(),
68 has_attachments: false,
69 size_bytes: 1024,
70 unsubscribe: UnsubscribeMethod::None,
71 label_provider_ids: vec![],
72 }
73 }
74
75 #[tokio::test]
76 async fn account_roundtrip() {
77 let store = Store::in_memory().await.unwrap();
78 let account = test_account();
79 store.insert_account(&account).await.unwrap();
80 let fetched = store.get_account(&account.id).await.unwrap().unwrap();
81 assert_eq!(fetched.name, account.name);
82 assert_eq!(fetched.email, account.email);
83 }
84
85 #[tokio::test]
86 async fn account_insert_upserts_existing_runtime_record() {
87 let store = Store::in_memory().await.unwrap();
88 let mut account = test_account();
89 store.insert_account(&account).await.unwrap();
90
91 account.name = "Updated".to_string();
92 account.email = "updated@example.com".to_string();
93 store.insert_account(&account).await.unwrap();
94
95 let fetched = store.get_account(&account.id).await.unwrap().unwrap();
96 assert_eq!(fetched.name, "Updated");
97 assert_eq!(fetched.email, "updated@example.com");
98 }
99
100 #[tokio::test]
101 async fn envelope_upsert_and_query() {
102 let store = Store::in_memory().await.unwrap();
103 let account = test_account();
104 store.insert_account(&account).await.unwrap();
105
106 let env = test_envelope(&account.id);
107 store.upsert_envelope(&env).await.unwrap();
108
109 let fetched = store.get_envelope(&env.id).await.unwrap().unwrap();
110 assert_eq!(fetched.id, env.id);
111 assert_eq!(fetched.subject, env.subject);
112 assert_eq!(fetched.from.email, env.from.email);
113 assert_eq!(fetched.flags, env.flags);
114
115 let list = store
116 .list_envelopes_by_account(&account.id, 100, 0)
117 .await
118 .unwrap();
119 assert_eq!(list.len(), 1);
120 }
121
122 #[tokio::test]
123 async fn list_envelopes_by_account_sinks_impossible_future_dates() {
124 let store = Store::in_memory().await.unwrap();
125 let account = test_account();
126 store.insert_account(&account).await.unwrap();
127
128 let mut poisoned = test_envelope(&account.id);
129 poisoned.provider_id = "poisoned-future".to_string();
130 poisoned.subject = "Poisoned future".to_string();
131 poisoned.date = chrono::Utc
132 .timestamp_opt(236_816_444_325, 0)
133 .single()
134 .unwrap();
135
136 let mut recent = test_envelope(&account.id);
137 recent.id = MessageId::new();
138 recent.provider_id = "real-recent".to_string();
139 recent.subject = "Real recent".to_string();
140 recent.date = chrono::Utc::now();
141
142 store.upsert_envelope(&poisoned).await.unwrap();
143 store.upsert_envelope(&recent).await.unwrap();
144
145 let listed = store
146 .list_envelopes_by_account(&account.id, 100, 0)
147 .await
148 .unwrap();
149
150 assert_eq!(listed[0].subject, "Real recent");
151 assert_eq!(listed[1].subject, "Poisoned future");
152 }
153
154 #[tokio::test]
155 async fn label_crud() {
156 let store = Store::in_memory().await.unwrap();
157 let account = test_account();
158 store.insert_account(&account).await.unwrap();
159
160 let label = Label {
161 id: LabelId::new(),
162 account_id: account.id.clone(),
163 name: "Inbox".to_string(),
164 kind: LabelKind::System,
165 color: None,
166 provider_id: "INBOX".to_string(),
167 unread_count: 5,
168 total_count: 20,
169 };
170 store.upsert_label(&label).await.unwrap();
171
172 let labels = store.list_labels_by_account(&account.id).await.unwrap();
173 assert_eq!(labels.len(), 1);
174 assert_eq!(labels[0].name, "Inbox");
175 assert_eq!(labels[0].unread_count, 5);
176
177 store.update_label_counts(&label.id, 3, 25).await.unwrap();
178 let labels = store.list_labels_by_account(&account.id).await.unwrap();
179 assert_eq!(labels[0].unread_count, 3);
180 assert_eq!(labels[0].total_count, 25);
181 }
182
183 #[tokio::test]
184 async fn body_cache() {
185 let store = Store::in_memory().await.unwrap();
186 let account = test_account();
187 store.insert_account(&account).await.unwrap();
188
189 let env = test_envelope(&account.id);
190 store.upsert_envelope(&env).await.unwrap();
191
192 let body = MessageBody {
193 message_id: env.id.clone(),
194 text_plain: Some("Hello world".to_string()),
195 text_html: Some("<p>Hello world</p>".to_string()),
196 attachments: vec![AttachmentMeta {
197 id: AttachmentId::new(),
198 message_id: env.id.clone(),
199 filename: "report.pdf".to_string(),
200 mime_type: "application/pdf".to_string(),
201 size_bytes: 50000,
202 local_path: None,
203 provider_id: "att-1".to_string(),
204 }],
205 fetched_at: chrono::Utc::now(),
206 metadata: MessageMetadata::default(),
207 };
208 store.insert_body(&body).await.unwrap();
209
210 let fetched = store.get_body(&env.id).await.unwrap().unwrap();
211 assert_eq!(fetched.text_plain, body.text_plain);
212 assert_eq!(fetched.attachments.len(), 1);
213 assert_eq!(fetched.attachments[0].filename, "report.pdf");
214 }
215
216 #[tokio::test]
217 async fn message_labels_junction() {
218 let store = Store::in_memory().await.unwrap();
219 let account = test_account();
220 store.insert_account(&account).await.unwrap();
221
222 let label = Label {
223 id: LabelId::new(),
224 account_id: account.id.clone(),
225 name: "Inbox".to_string(),
226 kind: LabelKind::System,
227 color: None,
228 provider_id: "INBOX".to_string(),
229 unread_count: 0,
230 total_count: 0,
231 };
232 store.upsert_label(&label).await.unwrap();
233
234 let env = test_envelope(&account.id);
235 store.upsert_envelope(&env).await.unwrap();
236 store
237 .set_message_labels(&env.id, std::slice::from_ref(&label.id))
238 .await
239 .unwrap();
240
241 let by_label = store
242 .list_envelopes_by_label(&label.id, 100, 0)
243 .await
244 .unwrap();
245 assert_eq!(by_label.len(), 1);
246 assert_eq!(by_label[0].id, env.id);
247 assert_eq!(by_label[0].label_provider_ids, vec!["INBOX".to_string()]);
248
249 let by_account = store
250 .list_envelopes_by_account(&account.id, 100, 0)
251 .await
252 .unwrap();
253 assert_eq!(by_account.len(), 1);
254 assert_eq!(by_account[0].label_provider_ids, vec!["INBOX".to_string()]);
255
256 let fetched = store.get_envelope(&env.id).await.unwrap().unwrap();
257 assert_eq!(fetched.label_provider_ids, vec!["INBOX".to_string()]);
258 }
259
260 #[tokio::test]
261 async fn thread_aggregation() {
262 let store = Store::in_memory().await.unwrap();
263 let account = test_account();
264 store.insert_account(&account).await.unwrap();
265
266 let thread_id = ThreadId::new();
267 for i in 0..3 {
268 let mut env = test_envelope(&account.id);
269 env.provider_id = format!("fake-thread-{}", i);
270 env.thread_id = thread_id.clone();
271 env.date = chrono::Utc::now() - chrono::Duration::hours(i);
272 if i == 0 {
273 env.flags = MessageFlags::empty(); }
275 store.upsert_envelope(&env).await.unwrap();
276 }
277
278 let thread = store.get_thread(&thread_id).await.unwrap().unwrap();
279 assert_eq!(thread.message_count, 3);
280 assert_eq!(thread.unread_count, 1);
281 }
282
283 #[tokio::test]
284 async fn thread_latest_date_ignores_impossible_future_dates() {
285 let store = Store::in_memory().await.unwrap();
286 let account = test_account();
287 store.insert_account(&account).await.unwrap();
288
289 let thread_id = ThreadId::new();
290
291 let mut poisoned = test_envelope(&account.id);
292 poisoned.provider_id = "poisoned-thread".to_string();
293 poisoned.thread_id = thread_id.clone();
294 poisoned.date = chrono::Utc
295 .timestamp_opt(236_816_444_325, 0)
296 .single()
297 .unwrap();
298
299 let mut recent = test_envelope(&account.id);
300 recent.id = MessageId::new();
301 recent.provider_id = "recent-thread".to_string();
302 recent.thread_id = thread_id.clone();
303 recent.subject = "Recent thread message".to_string();
304 recent.date = chrono::Utc::now();
305
306 store.upsert_envelope(&poisoned).await.unwrap();
307 store.upsert_envelope(&recent).await.unwrap();
308
309 let thread = store.get_thread(&thread_id).await.unwrap().unwrap();
310 assert_eq!(thread.latest_date.timestamp(), recent.date.timestamp());
311 }
312
313 #[tokio::test]
314 async fn list_subscriptions_groups_by_sender_and_skips_trash() {
315 let store = Store::in_memory().await.unwrap();
316 let account = test_account();
317 store.insert_account(&account).await.unwrap();
318
319 let mut latest = test_envelope(&account.id);
320 latest.from.name = Some("Readwise".into());
321 latest.from.email = "hello@readwise.io".into();
322 latest.subject = "Latest digest".into();
323 latest.unsubscribe = UnsubscribeMethod::HttpLink {
324 url: "https://example.com/unsub".into(),
325 };
326 latest.date = chrono::Utc::now();
327
328 let mut older = latest.clone();
329 older.id = MessageId::new();
330 older.provider_id = "fake-older".into();
331 older.subject = "Older digest".into();
332 older.date = latest.date - chrono::Duration::days(3);
333
334 let mut trashed = latest.clone();
335 trashed.id = MessageId::new();
336 trashed.provider_id = "fake-trash".into();
337 trashed.subject = "Trashed digest".into();
338 trashed.date = latest.date + chrono::Duration::hours(1);
339 trashed.flags.insert(MessageFlags::TRASH);
340
341 let mut no_unsub = test_envelope(&account.id);
342 no_unsub.from.email = "plain@example.com".into();
343 no_unsub.provider_id = "fake-none".into();
344 no_unsub.unsubscribe = UnsubscribeMethod::None;
345
346 store.upsert_envelope(&older).await.unwrap();
347 store.upsert_envelope(&latest).await.unwrap();
348 store.upsert_envelope(&trashed).await.unwrap();
349 store.upsert_envelope(&no_unsub).await.unwrap();
350
351 let subscriptions = store.list_subscriptions(10).await.unwrap();
352 assert_eq!(subscriptions.len(), 1);
353 assert_eq!(subscriptions[0].sender_email, "hello@readwise.io");
354 assert_eq!(subscriptions[0].message_count, 2);
355 assert_eq!(subscriptions[0].latest_subject, "Latest digest");
356 }
357
358 #[tokio::test]
359 async fn draft_crud() {
360 let store = Store::in_memory().await.unwrap();
361 let account = test_account();
362 store.insert_account(&account).await.unwrap();
363
364 let draft = Draft {
365 id: DraftId::new(),
366 account_id: account.id.clone(),
367 reply_headers: None,
368 to: vec![Address {
369 name: None,
370 email: "bob@example.com".to_string(),
371 }],
372 cc: vec![],
373 bcc: vec![],
374 subject: "Draft subject".to_string(),
375 body_markdown: "# Hello".to_string(),
376 attachments: vec![],
377 created_at: chrono::Utc::now(),
378 updated_at: chrono::Utc::now(),
379 };
380 store.insert_draft(&draft).await.unwrap();
381
382 let drafts = store.list_drafts(&account.id).await.unwrap();
383 assert_eq!(drafts.len(), 1);
384
385 store.delete_draft(&draft.id).await.unwrap();
386 let drafts = store.list_drafts(&account.id).await.unwrap();
387 assert_eq!(drafts.len(), 0);
388 }
389
390 #[tokio::test]
391 async fn snooze_lifecycle() {
392 let store = Store::in_memory().await.unwrap();
393 let account = test_account();
394 store.insert_account(&account).await.unwrap();
395
396 let env = test_envelope(&account.id);
397 store.upsert_envelope(&env).await.unwrap();
398
399 let snoozed = Snoozed {
400 message_id: env.id.clone(),
401 account_id: account.id.clone(),
402 snoozed_at: chrono::Utc::now(),
403 wake_at: chrono::Utc::now() - chrono::Duration::hours(1), original_labels: vec![],
405 };
406 store.insert_snooze(&snoozed).await.unwrap();
407
408 let due = store.get_due_snoozes(chrono::Utc::now()).await.unwrap();
409 assert_eq!(due.len(), 1);
410
411 store.remove_snooze(&env.id).await.unwrap();
412 let due = store.get_due_snoozes(chrono::Utc::now()).await.unwrap();
413 assert_eq!(due.len(), 0);
414 }
415
416 #[tokio::test]
417 async fn sync_log_lifecycle() {
418 let store = Store::in_memory().await.unwrap();
419 let account = test_account();
420 store.insert_account(&account).await.unwrap();
421
422 let log_id = store
423 .insert_sync_log(&account.id, &SyncStatus::Running)
424 .await
425 .unwrap();
426 assert!(log_id > 0);
427
428 store
429 .complete_sync_log(log_id, &SyncStatus::Success, 55, None)
430 .await
431 .unwrap();
432
433 let last = store.get_last_sync(&account.id).await.unwrap().unwrap();
434 assert_eq!(last.status, SyncStatus::Success);
435 assert_eq!(last.messages_synced, 55);
436 }
437
438 #[tokio::test]
439 async fn event_log_insert_and_query() {
440 let store = Store::in_memory().await.unwrap();
441 let account = test_account();
442 store.insert_account(&account).await.unwrap();
443 let env = test_envelope(&account.id);
444 let env_id = env.id.as_str();
445 store.upsert_envelope(&env).await.unwrap();
446
447 store
448 .insert_event("info", "sync", "Sync completed", Some(&account.id), None)
449 .await
450 .unwrap();
451 store
452 .insert_event(
453 "error",
454 "sync",
455 "Sync failed",
456 Some(&account.id),
457 Some("timeout"),
458 )
459 .await
460 .unwrap();
461 store
462 .insert_event("info", "rule", "Rule applied", None, None)
463 .await
464 .unwrap();
465 store
466 .insert_event_refs(
467 "info",
468 "mutation",
469 "Archived a message",
470 EventLogRefs {
471 account_id: Some(&account.id),
472 message_id: Some(env_id.as_str()),
473 rule_id: None,
474 },
475 Some("from=test@example.com"),
476 )
477 .await
478 .unwrap();
479
480 let all = store.list_events(10, None, None).await.unwrap();
481 assert_eq!(all.len(), 4);
482
483 let errors = store.list_events(10, Some("error"), None).await.unwrap();
484 assert_eq!(errors.len(), 1);
485 assert_eq!(errors[0].summary, "Sync failed");
486
487 let sync_events = store.list_events(10, None, Some("sync")).await.unwrap();
488 assert_eq!(sync_events.len(), 2);
489
490 let mutation_events = store.list_events(10, None, Some("mutation")).await.unwrap();
491 assert_eq!(mutation_events.len(), 1);
492 assert_eq!(
493 mutation_events[0].message_id.as_deref(),
494 Some(env_id.as_str())
495 );
496
497 let latest_sync = store
498 .latest_event_timestamp("sync", Some("Sync"))
499 .await
500 .unwrap();
501 assert!(latest_sync.is_some());
502 }
503
504 #[tokio::test]
505 async fn prune_events_before_removes_old_rows() {
506 let store = Store::in_memory().await.unwrap();
507 let account = test_account();
508 store.insert_account(&account).await.unwrap();
509
510 store
511 .insert_event("info", "sync", "recent", Some(&account.id), None)
512 .await
513 .unwrap();
514 sqlx::query("UPDATE event_log SET timestamp = ? WHERE summary = 'recent'")
515 .bind(chrono::Utc::now().timestamp())
516 .execute(store.writer())
517 .await
518 .unwrap();
519
520 store
521 .insert_event("info", "sync", "old", Some(&account.id), None)
522 .await
523 .unwrap();
524 sqlx::query("UPDATE event_log SET timestamp = ? WHERE summary = 'old'")
525 .bind((chrono::Utc::now() - chrono::Duration::days(120)).timestamp())
526 .execute(store.writer())
527 .await
528 .unwrap();
529
530 let removed = store
531 .prune_events_before((chrono::Utc::now() - chrono::Duration::days(90)).timestamp())
532 .await
533 .unwrap();
534 assert_eq!(removed, 1);
535
536 let events = store.list_events(10, None, None).await.unwrap();
537 assert_eq!(events.len(), 1);
538 assert_eq!(events[0].summary, "recent");
539 }
540
541 #[tokio::test]
542 async fn get_message_id_by_provider_id() {
543 let store = Store::in_memory().await.unwrap();
544 let account = test_account();
545 store.insert_account(&account).await.unwrap();
546
547 let env = test_envelope(&account.id);
548 store.upsert_envelope(&env).await.unwrap();
549
550 let found = store
551 .get_message_id_by_provider_id(&account.id, &env.provider_id)
552 .await
553 .unwrap();
554 assert_eq!(found, Some(env.id.clone()));
555
556 let not_found = store
557 .get_message_id_by_provider_id(&account.id, "nonexistent")
558 .await
559 .unwrap();
560 assert!(not_found.is_none());
561 }
562
563 #[tokio::test]
564 async fn recalculate_label_counts() {
565 let store = Store::in_memory().await.unwrap();
566 let account = test_account();
567 store.insert_account(&account).await.unwrap();
568
569 let label = Label {
570 id: LabelId::new(),
571 account_id: account.id.clone(),
572 name: "Inbox".to_string(),
573 kind: LabelKind::System,
574 color: None,
575 provider_id: "INBOX".to_string(),
576 unread_count: 0,
577 total_count: 0,
578 };
579 store.upsert_label(&label).await.unwrap();
580
581 for i in 0..3 {
583 let mut env = test_envelope(&account.id);
584 env.provider_id = format!("fake-label-{}", i);
585 if i < 2 {
586 env.flags = MessageFlags::READ;
587 } else {
588 env.flags = MessageFlags::empty();
589 }
590 store.upsert_envelope(&env).await.unwrap();
591 store
592 .set_message_labels(&env.id, std::slice::from_ref(&label.id))
593 .await
594 .unwrap();
595 }
596
597 store.recalculate_label_counts(&account.id).await.unwrap();
598
599 let labels = store.list_labels_by_account(&account.id).await.unwrap();
600 assert_eq!(labels[0].total_count, 3);
601 assert_eq!(labels[0].unread_count, 1);
602 }
603
604 #[tokio::test]
605 async fn replace_label_moves_message_associations_when_id_changes() {
606 let store = Store::in_memory().await.unwrap();
607 let account = test_account();
608 store.insert_account(&account).await.unwrap();
609
610 let original = Label {
611 id: LabelId::from_provider_id("imap", "Projects"),
612 account_id: account.id.clone(),
613 name: "Projects".to_string(),
614 kind: LabelKind::Folder,
615 color: None,
616 provider_id: "Projects".to_string(),
617 unread_count: 0,
618 total_count: 0,
619 };
620 store.upsert_label(&original).await.unwrap();
621
622 let env = test_envelope(&account.id);
623 store.upsert_envelope(&env).await.unwrap();
624 store
625 .set_message_labels(&env.id, std::slice::from_ref(&original.id))
626 .await
627 .unwrap();
628
629 let renamed = Label {
630 id: LabelId::from_provider_id("imap", "Client Work"),
631 account_id: account.id.clone(),
632 name: "Client Work".to_string(),
633 kind: LabelKind::Folder,
634 color: None,
635 provider_id: "Client Work".to_string(),
636 unread_count: 0,
637 total_count: 0,
638 };
639 store.replace_label(&original.id, &renamed).await.unwrap();
640
641 let labels = store.list_labels_by_account(&account.id).await.unwrap();
642 assert_eq!(labels.len(), 1);
643 assert_eq!(labels[0].name, "Client Work");
644 assert_eq!(labels[0].id, renamed.id);
645
646 let by_new_label = store
647 .list_envelopes_by_label(&renamed.id, 100, 0)
648 .await
649 .unwrap();
650 assert_eq!(by_new_label.len(), 1);
651 assert_eq!(by_new_label[0].id, env.id);
652 assert!(store
653 .list_envelopes_by_label(&original.id, 100, 0)
654 .await
655 .unwrap()
656 .is_empty());
657 }
658
659 #[tokio::test]
660 async fn rules_roundtrip_and_history() {
661 let store = Store::in_memory().await.unwrap();
662 let now = chrono::Utc::now();
663
664 store
665 .upsert_rule(crate::RuleRecordInput {
666 id: "rule-1",
667 name: "Archive newsletters",
668 enabled: true,
669 priority: 10,
670 conditions_json: r#"{"type":"field","field":"has_label","label":"newsletters"}"#,
671 actions_json: r#"[{"type":"archive"}]"#,
672 created_at: now,
673 updated_at: now,
674 })
675 .await
676 .unwrap();
677
678 let rules = store.list_rules().await.unwrap();
679 assert_eq!(rules.len(), 1);
680 let rule_json = crate::rules::row_to_rule_json(&rules[0]);
681 assert_eq!(rule_json["name"], "Archive newsletters");
682 assert_eq!(rule_json["priority"], 10);
683
684 store
685 .insert_rule_log(crate::RuleLogInput {
686 rule_id: "rule-1",
687 rule_name: "Archive newsletters",
688 message_id: "msg-1",
689 actions_applied_json: r#"["archive"]"#,
690 timestamp: now,
691 success: true,
692 error: None,
693 })
694 .await
695 .unwrap();
696
697 let logs = store.list_rule_logs(Some("rule-1"), 10).await.unwrap();
698 assert_eq!(logs.len(), 1);
699 let log_json = crate::rules::row_to_rule_log_json(&logs[0]);
700 assert_eq!(log_json["rule_name"], "Archive newsletters");
701 assert_eq!(log_json["message_id"], "msg-1");
702 }
703
704 #[tokio::test]
705 async fn get_saved_search_by_name() {
706 let store = Store::in_memory().await.unwrap();
707
708 let search = SavedSearch {
709 id: SavedSearchId::new(),
710 account_id: None,
711 name: "Unread".to_string(),
712 query: "is:unread".to_string(),
713 search_mode: SearchMode::Lexical,
714 sort: SortOrder::DateDesc,
715 icon: None,
716 position: 0,
717 created_at: chrono::Utc::now(),
718 };
719 store.insert_saved_search(&search).await.unwrap();
720
721 let found = store.get_saved_search_by_name("Unread").await.unwrap();
722 assert!(found.is_some());
723 assert_eq!(found.unwrap().query, "is:unread");
724
725 let not_found = store.get_saved_search_by_name("Nonexistent").await.unwrap();
726 assert!(not_found.is_none());
727 }
728
729 #[tokio::test]
730 async fn saved_search_crud() {
731 let store = Store::in_memory().await.unwrap();
732
733 let s1 = SavedSearch {
734 id: SavedSearchId::new(),
735 account_id: None,
736 name: "Unread".to_string(),
737 query: "is:unread".to_string(),
738 search_mode: SearchMode::Lexical,
739 sort: SortOrder::DateDesc,
740 icon: None,
741 position: 0,
742 created_at: chrono::Utc::now(),
743 };
744 let s2 = SavedSearch {
745 id: SavedSearchId::new(),
746 account_id: None,
747 name: "Starred".to_string(),
748 query: "is:starred".to_string(),
749 search_mode: SearchMode::Hybrid,
750 sort: SortOrder::DateDesc,
751 icon: Some("star".to_string()),
752 position: 1,
753 created_at: chrono::Utc::now(),
754 };
755
756 store.insert_saved_search(&s1).await.unwrap();
757 store.insert_saved_search(&s2).await.unwrap();
758
759 let all = store.list_saved_searches().await.unwrap();
761 assert_eq!(all.len(), 2);
762 assert_eq!(all[0].name, "Unread");
763 assert_eq!(all[1].name, "Starred");
764
765 let found = store.get_saved_search_by_name("Starred").await.unwrap();
767 assert!(found.is_some());
768 assert_eq!(found.unwrap().query, "is:starred");
769
770 store.delete_saved_search(&s1.id).await.unwrap();
772 let remaining = store.list_saved_searches().await.unwrap();
773 assert_eq!(remaining.len(), 1);
774 assert_eq!(remaining[0].name, "Starred");
775
776 let deleted = store.delete_saved_search_by_name("Starred").await.unwrap();
778 assert!(deleted);
779 let empty = store.list_saved_searches().await.unwrap();
780 assert!(empty.is_empty());
781 }
782
783 #[tokio::test]
784 async fn list_contacts_ordered_by_frequency() {
785 let store = Store::in_memory().await.unwrap();
786 let account = test_account();
787 store.insert_account(&account).await.unwrap();
788
789 for i in 0..3 {
791 let mut env = test_envelope(&account.id);
792 env.provider_id = format!("fake-alice-{}", i);
793 env.from = Address {
794 name: Some("Alice".to_string()),
795 email: "alice@example.com".to_string(),
796 };
797 store.upsert_envelope(&env).await.unwrap();
798 }
799 for i in 0..2 {
800 let mut env = test_envelope(&account.id);
801 env.provider_id = format!("fake-bob-{}", i);
802 env.from = Address {
803 name: Some("Bob".to_string()),
804 email: "bob@example.com".to_string(),
805 };
806 store.upsert_envelope(&env).await.unwrap();
807 }
808 {
809 let mut env = test_envelope(&account.id);
810 env.provider_id = "fake-carol-0".to_string();
811 env.from = Address {
812 name: Some("Carol".to_string()),
813 email: "carol@example.com".to_string(),
814 };
815 store.upsert_envelope(&env).await.unwrap();
816 }
817
818 let contacts = store.list_contacts(10).await.unwrap();
819 assert_eq!(contacts.len(), 3);
820 assert_eq!(contacts[0].1, "alice@example.com");
822 assert_eq!(contacts[1].1, "bob@example.com");
823 assert_eq!(contacts[2].1, "carol@example.com");
824 }
825
826 #[tokio::test]
827 async fn sync_cursor_persistence() {
828 let store = Store::in_memory().await.unwrap();
829 let account = test_account();
830 store.insert_account(&account).await.unwrap();
831
832 let cursor = store.get_sync_cursor(&account.id).await.unwrap();
833 assert!(cursor.is_none());
834
835 let new_cursor = SyncCursor::Gmail { history_id: 12345 };
836 store
837 .set_sync_cursor(&account.id, &new_cursor)
838 .await
839 .unwrap();
840
841 let fetched = store.get_sync_cursor(&account.id).await.unwrap().unwrap();
842 let json = serde_json::to_string(&fetched).unwrap();
843 assert!(json.contains("12345"));
844 }
845}