Skip to main content

max / goingson

Testing: cover oauth token_manager and email_sync process loop - token_manager: 7 unit tests for the refresh-decision logic — needs_refresh by expiry/5-min-buffer/no-expiry-per-auth-type, provider_id mapping for every auth type, refresh_if_needed no-op when the token is still valid and its rejection of non-OAuth accounts, and get_valid_token's keychain->DB fallback plus its no-token error. All network-free (gated by needs_refresh / an empty keychain), so they run in CI without a Secret Service. - email_sync: an integration test against the real SqliteEmailRepository (in-memory) rather than a 37-method hand mock — covers new-save + cross-call dedup, synthetic-message-id generation and its dedup, the thread_id fallback chain, waiting-status clearing on reply, and the empty-batch no-op. Suite: 797 pass (+12); clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:23 UTC
Commit: 9bf412bedb0a4581a4739009e29a391a80ff1bc0
Parent: 0c1dd2d
2 files changed, +370 insertions, -0 deletions
@@ -0,0 +1,211 @@
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::{process_fetched_emails, FetchedEmail};
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!(r2.emails_saved, 1, "only the genuinely-new message is saved");
86 + assert_eq!(repo.list_all(user_id, true).await.unwrap().len(), 3);
87 + }
88 +
89 + #[tokio::test]
90 + async fn synthesizes_message_id_and_dedups_it_across_runs() {
91 + let pool = common::setup_test_db().await;
92 + let user_id = common::create_test_user(&pool).await;
93 + let account_id = make_account(&pool, user_id).await;
94 + let repo = SqliteEmailRepository::new(pool.clone());
95 +
96 + // No message_id: process_fetched_emails hashes from/to/subject/date into a
97 + // stable "synth-..." id. Fix the date so the hash is identical across runs.
98 + let date = Utc::now();
99 + let mut e1 = fetched(None, "sender@example.com", "No Message-ID");
100 + e1.date = date;
101 + let r = process_fetched_emails(&repo, user_id, account_id, vec![e1])
102 + .await
103 + .expect("process");
104 + assert_eq!(r.emails_saved, 1);
105 +
106 + let saved = repo.list_all(user_id, true).await.unwrap();
107 + assert_eq!(saved.len(), 1);
108 + let synth = saved[0].message_id.clone().expect("synthetic id assigned");
109 + assert!(synth.starts_with("synth-"), "got: {synth}");
110 +
111 + // Same content + date -> same synthetic id -> deduped on the next run.
112 + let mut e1_again = fetched(None, "sender@example.com", "No Message-ID");
113 + e1_again.date = date;
114 + let r2 = process_fetched_emails(&repo, user_id, account_id, vec![e1_again])
115 + .await
116 + .expect("process");
117 + assert_eq!(r2.emails_saved, 0, "identical content dedups via synthetic id");
118 + }
119 +
120 + #[tokio::test]
121 + async fn thread_id_follows_reply_then_falls_back_to_message_id() {
122 + let pool = common::setup_test_db().await;
123 + let user_id = common::create_test_user(&pool).await;
124 + let account_id = make_account(&pool, user_id).await;
125 + let repo = SqliteEmailRepository::new(pool.clone());
126 +
127 + // A thread-root email: thread_id falls back to its own message_id.
128 + let root = fetched(Some("root@example.com"), "a@example.com", "Root");
129 + // A reply: thread_id should be the in_reply_to it points at.
130 + let mut reply = fetched(Some("reply@example.com"), "b@example.com", "Re: Root");
131 + reply.in_reply_to = Some("root@example.com".to_string());
132 +
133 + process_fetched_emails(&repo, user_id, account_id, vec![root, reply])
134 + .await
135 + .expect("process");
136 +
137 + let root_saved = repo
138 + .get_by_message_id(user_id, "root@example.com")
139 + .await
140 + .unwrap()
141 + .unwrap();
142 + assert_eq!(root_saved.thread_id.as_deref(), Some("root@example.com"));
143 +
144 + let reply_saved = repo
145 + .get_by_message_id(user_id, "reply@example.com")
146 + .await
147 + .unwrap()
148 + .unwrap();
149 + assert_eq!(
150 + reply_saved.thread_id.as_deref(),
151 + Some("root@example.com"),
152 + "a reply threads under the message it answers"
153 + );
154 + }
155 +
156 + #[tokio::test]
157 + async fn clears_waiting_status_when_a_reply_arrives() {
158 + let pool = common::setup_test_db().await;
159 + let user_id = common::create_test_user(&pool).await;
160 + let account_id = make_account(&pool, user_id).await;
161 + let repo = SqliteEmailRepository::new(pool.clone());
162 +
163 + // Save an outgoing message we're waiting on a reply to.
164 + process_fetched_emails(
165 + &repo,
166 + user_id,
167 + account_id,
168 + vec![fetched(Some("sent@example.com"), "me@example.com", "Question")],
169 + )
170 + .await
171 + .expect("process");
172 + let sent = repo
173 + .get_by_message_id(user_id, "sent@example.com")
174 + .await
175 + .unwrap()
176 + .unwrap();
177 + repo.mark_waiting(sent.id, user_id, Some(Utc::now() + Duration::days(3)))
178 + .await
179 + .unwrap();
180 +
181 + // A reply to it arrives: waiting status must clear.
182 + let mut reply = fetched(Some("their-reply@example.com"), "them@example.com", "Re: Question");
183 + reply.in_reply_to = Some("sent@example.com".to_string());
184 + let r = process_fetched_emails(&repo, user_id, account_id, vec![reply])
185 + .await
186 + .expect("process");
187 +
188 + assert_eq!(r.emails_saved, 1);
189 + assert_eq!(r.waiting_cleared, 1);
190 + let sent_after = repo
191 + .get_by_message_id(user_id, "sent@example.com")
192 + .await
193 + .unwrap()
194 + .unwrap();
195 + assert!(!sent_after.waiting_for_response, "reply clears the waiting flag");
196 + }
197 +
198 + #[tokio::test]
199 + async fn empty_batch_is_a_noop() {
200 + let pool = common::setup_test_db().await;
201 + let user_id = common::create_test_user(&pool).await;
202 + let account_id = make_account(&pool, user_id).await;
203 + let repo = SqliteEmailRepository::new(pool.clone());
204 +
205 + let r = process_fetched_emails(&repo, user_id, account_id, vec![])
206 + .await
207 + .expect("process");
208 + assert_eq!(r.emails_saved, 0);
209 + assert_eq!(r.waiting_cleared, 0);
210 + assert!(repo.list_all(user_id, true).await.unwrap().is_empty());
211 + }
@@ -181,3 +181,162 @@ impl TokenRefreshResult {
181 181 matches!(self, TokenRefreshResult::Refreshed { .. })
182 182 }
183 183 }
184 +
185 + #[cfg(test)]
186 + mod tests {
187 + use super::*;
188 + use goingson_core::{EmailAccountId, UserId};
189 +
190 + /// Build an EmailAccount with the auth/token fields under test and inert
191 + /// defaults for everything else. `expires_at`/`access_token` drive the
192 + /// refresh-decision and token-source paths.
193 + fn account(
194 + auth_type: EmailAuthType,
195 + expires_at: Option<chrono::DateTime<Utc>>,
196 + access_token: Option<&str>,
197 + ) -> EmailAccount {
198 + EmailAccount {
199 + id: EmailAccountId::new(),
200 + user_id: UserId::from_uuid(uuid::Uuid::from_u128(1)),
201 + account_name: "Test".to_string(),
202 + email_address: "me@example.com".to_string(),
203 + imap_server: String::new(),
204 + imap_port: 0,
205 + smtp_server: String::new(),
206 + smtp_port: 0,
207 + username: String::new(),
208 + password: String::new(),
209 + use_tls: true,
210 + last_sync_at: None,
211 + created_at: Utc::now(),
212 + archive_folder_name: None,
213 + auth_type,
214 + oauth2_access_token: access_token.map(str::to_string),
215 + oauth2_refresh_token: None,
216 + oauth2_token_expires_at: expires_at,
217 + jmap_session_url: None,
218 + jmap_account_id: None,
219 + sync_interval_minutes: None,
220 + email_signature: None,
221 + notify_new_emails: false,
222 + }
223 + }
224 +
225 + #[test]
226 + fn needs_refresh_by_expiry() {
227 + // Comfortably in the future -> no refresh needed.
228 + let future = account(
229 + EmailAuthType::OAuth2Fastmail,
230 + Some(Utc::now() + Duration::hours(1)),
231 + Some("tok"),
232 + );
233 + assert!(!TokenManager::needs_refresh(&future));
234 +
235 + // Already expired -> needs refresh.
236 + let past = account(
237 + EmailAuthType::OAuth2Fastmail,
238 + Some(Utc::now() - Duration::hours(1)),
239 + Some("tok"),
240 + );
241 + assert!(TokenManager::needs_refresh(&past));
242 +
243 + // Inside the 5-minute buffer -> needs refresh (refresh before it lapses).
244 + let soon = account(
245 + EmailAuthType::OAuth2Fastmail,
246 + Some(Utc::now() + Duration::minutes(2)),
247 + Some("tok"),
248 + );
249 + assert!(TokenManager::needs_refresh(&soon));
250 + }
251 +
252 + #[test]
253 + fn needs_refresh_with_no_expiry_depends_on_auth_type() {
254 + // OAuth account with no recorded expiry is assumed to need a refresh.
255 + let oauth = account(EmailAuthType::OAuth2Google, None, None);
256 + assert!(TokenManager::needs_refresh(&oauth));
257 +
258 + // A password account never needs an OAuth refresh.
259 + let password = account(EmailAuthType::Password, None, None);
260 + assert!(!TokenManager::needs_refresh(&password));
261 + }
262 +
263 + #[test]
264 + fn provider_id_maps_every_auth_type() {
265 + assert_eq!(
266 + TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Fastmail),
267 + Some("fastmail")
268 + );
269 + assert_eq!(
270 + TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Google),
271 + Some("google")
272 + );
273 + assert_eq!(
274 + TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Microsoft),
275 + Some("microsoft")
276 + );
277 + assert_eq!(
278 + TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Yahoo),
279 + Some("yahoo")
280 + );
281 + assert_eq!(
282 + TokenManager::provider_id_for_auth_type(&EmailAuthType::Password),
283 + None
284 + );
285 + }
286 +
287 + #[tokio::test]
288 + async fn refresh_if_needed_is_noop_when_token_still_valid() {
289 + // Future expiry -> no network call, returns Ok(None).
290 + let tm = TokenManager::from_env();
291 + let acct = account(
292 + EmailAuthType::OAuth2Fastmail,
293 + Some(Utc::now() + Duration::hours(1)),
294 + Some("tok"),
295 + );
296 + let result = tm.refresh_if_needed(&acct).await.expect("no error");
297 + assert!(result.is_none(), "a still-valid token must not refresh");
298 + }
299 +
300 + #[tokio::test]
301 + async fn refresh_if_needed_rejects_non_oauth_account() {
302 + // A password account whose (spurious) expiry has lapsed reaches the
303 + // provider lookup, which must reject it rather than hit the network.
304 + let tm = TokenManager::from_env();
305 + let acct = account(
306 + EmailAuthType::Password,
307 + Some(Utc::now() - Duration::hours(1)),
308 + None,
309 + );
310 + let err = tm.refresh_if_needed(&acct).await.expect_err("should reject");
311 + assert!(err.contains("OAuth"), "got: {err}");
312 + }
313 +
314 + #[tokio::test]
315 + async fn get_valid_token_returns_db_token_when_no_refresh_needed() {
316 + // No keychain entry exists for this fresh random id, so get_valid_token
317 + // falls back to the DB access-token column without any network call.
318 + let tm = TokenManager::from_env();
319 + let acct = account(
320 + EmailAuthType::OAuth2Fastmail,
321 + Some(Utc::now() + Duration::hours(1)),
322 + Some("db-access-token"),
323 + );
324 + match tm.get_valid_token(&acct).await.expect("token") {
325 + TokenRefreshResult::Valid(t) => assert_eq!(t, "db-access-token"),
326 + other => panic!("expected Valid, got {other:?}"),
327 + }
328 + }
329 +
330 + #[tokio::test]
331 + async fn get_valid_token_errors_when_no_token_anywhere() {
332 + // Valid (future) expiry so no refresh is attempted, keychain empty, and
333 + // no DB token -> a clean error rather than a network call.
334 + let tm = TokenManager::from_env();
335 + let acct = account(
336 + EmailAuthType::OAuth2Fastmail,
337 + Some(Utc::now() + Duration::hours(1)),
338 + None,
339 + );
340 + assert!(tm.get_valid_token(&acct).await.is_err());
341 + }
342 + }