Skip to main content

max / goingson

7.6 KB · 229 lines History Blame Raw
1 //! Integration tests for `goingson_core::email_sync::process_fetched_emails`.
2 //!
3 //! Exercises the shared dedup-save-clear-waiting loop against the real
4 //! SqliteEmailRepository (in-memory), which is higher fidelity than a hand mock:
5 //! it validates the synthetic-message-id path, cross-call deduplication, the
6 //! thread_id fallback chain, and waiting-status clearing against real SQL.
7
8 mod common;
9
10 use chrono::{Duration, Utc};
11 use goingson_core::email_sync::{FetchedEmail, process_fetched_emails};
12 use goingson_core::{EmailAccountId, EmailAccountRepository, EmailRepository, UserId};
13 use goingson_db_sqlite::{SqliteEmailAccountRepository, SqliteEmailRepository};
14 use sqlx::SqlitePool;
15
16 async fn make_account(pool: &SqlitePool, user_id: UserId) -> EmailAccountId {
17 let repo = SqliteEmailAccountRepository::new(pool.clone());
18 let account = repo
19 .create(
20 user_id,
21 "Test",
22 "me@example.com",
23 "imap.example.com",
24 993,
25 "smtp.example.com",
26 465,
27 "me@example.com",
28 "pw",
29 true,
30 Some("Archive"),
31 )
32 .await
33 .expect("create account");
34 account.id
35 }
36
37 /// A minimal fetched email with only the fields a test cares about set.
38 fn fetched(message_id: Option<&str>, from: &str, subject: &str) -> FetchedEmail {
39 FetchedEmail {
40 message_id: message_id.map(str::to_string),
41 in_reply_to: None,
42 references_root: None,
43 from: from.to_string(),
44 to: "me@example.com".to_string(),
45 subject: subject.to_string(),
46 body: "body".to_string(),
47 html_body: None,
48 is_read: false,
49 date: Utc::now(),
50 source_folder: "INBOX".to_string(),
51 imap_uid: None,
52 is_archived: false,
53 attachment_meta: None,
54 body_truncated: false,
55 jmap_id: None,
56 }
57 }
58
59 #[tokio::test]
60 async fn saves_new_emails_and_dedups_on_second_run() {
61 let pool = common::setup_test_db().await;
62 let user_id = common::create_test_user(&pool).await;
63 let account_id = make_account(&pool, user_id).await;
64 let repo = SqliteEmailRepository::new(pool.clone());
65
66 let batch = vec![
67 fetched(Some("m-1@example.com"), "a@example.com", "One"),
68 fetched(Some("m-2@example.com"), "b@example.com", "Two"),
69 ];
70 let r = process_fetched_emails(&repo, user_id, account_id, batch)
71 .await
72 .expect("process");
73 assert_eq!(r.emails_saved, 2);
74 assert_eq!(r.waiting_cleared, 0);
75
76 // Re-processing the same message IDs must dedup, nothing new saved.
77 let again = vec![
78 fetched(Some("m-1@example.com"), "a@example.com", "One"),
79 fetched(Some("m-2@example.com"), "b@example.com", "Two"),
80 fetched(Some("m-3@example.com"), "c@example.com", "Three"),
81 ];
82 let r2 = process_fetched_emails(&repo, user_id, account_id, again)
83 .await
84 .expect("process");
85 assert_eq!(
86 r2.emails_saved, 1,
87 "only the genuinely-new message is saved"
88 );
89 assert_eq!(repo.list_all(user_id, true).await.unwrap().len(), 3);
90 }
91
92 #[tokio::test]
93 async fn synthesizes_message_id_and_dedups_it_across_runs() {
94 let pool = common::setup_test_db().await;
95 let user_id = common::create_test_user(&pool).await;
96 let account_id = make_account(&pool, user_id).await;
97 let repo = SqliteEmailRepository::new(pool.clone());
98
99 // No message_id: process_fetched_emails hashes from/to/subject/date into a
100 // stable "synth-..." id. Fix the date so the hash is identical across runs.
101 let date = Utc::now();
102 let mut e1 = fetched(None, "sender@example.com", "No Message-ID");
103 e1.date = date;
104 let r = process_fetched_emails(&repo, user_id, account_id, vec![e1])
105 .await
106 .expect("process");
107 assert_eq!(r.emails_saved, 1);
108
109 let saved = repo.list_all(user_id, true).await.unwrap();
110 assert_eq!(saved.len(), 1);
111 let synth = saved[0].message_id.clone().expect("synthetic id assigned");
112 assert!(synth.starts_with("synth-"), "got: {synth}");
113
114 // Same content + date -> same synthetic id -> deduped on the next run.
115 let mut e1_again = fetched(None, "sender@example.com", "No Message-ID");
116 e1_again.date = date;
117 let r2 = process_fetched_emails(&repo, user_id, account_id, vec![e1_again])
118 .await
119 .expect("process");
120 assert_eq!(
121 r2.emails_saved, 0,
122 "identical content dedups via synthetic id"
123 );
124 }
125
126 #[tokio::test]
127 async fn thread_id_follows_reply_then_falls_back_to_message_id() {
128 let pool = common::setup_test_db().await;
129 let user_id = common::create_test_user(&pool).await;
130 let account_id = make_account(&pool, user_id).await;
131 let repo = SqliteEmailRepository::new(pool.clone());
132
133 // A thread-root email: thread_id falls back to its own message_id.
134 let root = fetched(Some("root@example.com"), "a@example.com", "Root");
135 // A reply: thread_id should be the in_reply_to it points at.
136 let mut reply = fetched(Some("reply@example.com"), "b@example.com", "Re: Root");
137 reply.in_reply_to = Some("root@example.com".to_string());
138
139 process_fetched_emails(&repo, user_id, account_id, vec![root, reply])
140 .await
141 .expect("process");
142
143 let root_saved = repo
144 .get_by_message_id(user_id, "root@example.com")
145 .await
146 .unwrap()
147 .unwrap();
148 assert_eq!(root_saved.thread_id.as_deref(), Some("root@example.com"));
149
150 let reply_saved = repo
151 .get_by_message_id(user_id, "reply@example.com")
152 .await
153 .unwrap()
154 .unwrap();
155 assert_eq!(
156 reply_saved.thread_id.as_deref(),
157 Some("root@example.com"),
158 "a reply threads under the message it answers"
159 );
160 }
161
162 #[tokio::test]
163 async fn clears_waiting_status_when_a_reply_arrives() {
164 let pool = common::setup_test_db().await;
165 let user_id = common::create_test_user(&pool).await;
166 let account_id = make_account(&pool, user_id).await;
167 let repo = SqliteEmailRepository::new(pool.clone());
168
169 // Save an outgoing message we're waiting on a reply to.
170 process_fetched_emails(
171 &repo,
172 user_id,
173 account_id,
174 vec![fetched(
175 Some("sent@example.com"),
176 "me@example.com",
177 "Question",
178 )],
179 )
180 .await
181 .expect("process");
182 let sent = repo
183 .get_by_message_id(user_id, "sent@example.com")
184 .await
185 .unwrap()
186 .unwrap();
187 repo.mark_waiting(sent.id, user_id, Some(Utc::now() + Duration::days(3)))
188 .await
189 .unwrap();
190
191 // A reply to it arrives: waiting status must clear.
192 let mut reply = fetched(
193 Some("their-reply@example.com"),
194 "them@example.com",
195 "Re: Question",
196 );
197 reply.in_reply_to = Some("sent@example.com".to_string());
198 let r = process_fetched_emails(&repo, user_id, account_id, vec![reply])
199 .await
200 .expect("process");
201
202 assert_eq!(r.emails_saved, 1);
203 assert_eq!(r.waiting_cleared, 1);
204 let sent_after = repo
205 .get_by_message_id(user_id, "sent@example.com")
206 .await
207 .unwrap()
208 .unwrap();
209 assert!(
210 !sent_after.waiting_for_response,
211 "reply clears the waiting flag"
212 );
213 }
214
215 #[tokio::test]
216 async fn empty_batch_is_a_noop() {
217 let pool = common::setup_test_db().await;
218 let user_id = common::create_test_user(&pool).await;
219 let account_id = make_account(&pool, user_id).await;
220 let repo = SqliteEmailRepository::new(pool.clone());
221
222 let r = process_fetched_emails(&repo, user_id, account_id, vec![])
223 .await
224 .expect("process");
225 assert_eq!(r.emails_saved, 0);
226 assert_eq!(r.waiting_cleared, 0);
227 assert!(repo.list_all(user_id, true).await.unwrap().is_empty());
228 }
229