//! Integration tests for `goingson_core::email_sync::process_fetched_emails`. //! //! Exercises the shared dedup-save-clear-waiting loop against the real //! SqliteEmailRepository (in-memory), which is higher fidelity than a hand mock: //! it validates the synthetic-message-id path, cross-call deduplication, the //! thread_id fallback chain, and waiting-status clearing against real SQL. mod common; use chrono::{Duration, Utc}; use goingson_core::email_sync::{FetchedEmail, process_fetched_emails}; use goingson_core::{EmailAccountId, EmailAccountRepository, EmailRepository, UserId}; use goingson_db_sqlite::{SqliteEmailAccountRepository, SqliteEmailRepository}; use sqlx::SqlitePool; async fn make_account(pool: &SqlitePool, user_id: UserId) -> EmailAccountId { let repo = SqliteEmailAccountRepository::new(pool.clone()); let account = repo .create( user_id, "Test", "me@example.com", "imap.example.com", 993, "smtp.example.com", 465, "me@example.com", "pw", true, Some("Archive"), ) .await .expect("create account"); account.id } /// A minimal fetched email with only the fields a test cares about set. fn fetched(message_id: Option<&str>, from: &str, subject: &str) -> FetchedEmail { FetchedEmail { message_id: message_id.map(str::to_string), in_reply_to: None, references_root: None, from: from.to_string(), to: "me@example.com".to_string(), subject: subject.to_string(), body: "body".to_string(), html_body: None, is_read: false, date: Utc::now(), source_folder: "INBOX".to_string(), imap_uid: None, is_archived: false, attachment_meta: None, body_truncated: false, jmap_id: None, } } #[tokio::test] async fn saves_new_emails_and_dedups_on_second_run() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let account_id = make_account(&pool, user_id).await; let repo = SqliteEmailRepository::new(pool.clone()); let batch = vec![ fetched(Some("m-1@example.com"), "a@example.com", "One"), fetched(Some("m-2@example.com"), "b@example.com", "Two"), ]; let r = process_fetched_emails(&repo, user_id, account_id, batch) .await .expect("process"); assert_eq!(r.emails_saved, 2); assert_eq!(r.waiting_cleared, 0); // Re-processing the same message IDs must dedup, nothing new saved. let again = vec![ fetched(Some("m-1@example.com"), "a@example.com", "One"), fetched(Some("m-2@example.com"), "b@example.com", "Two"), fetched(Some("m-3@example.com"), "c@example.com", "Three"), ]; let r2 = process_fetched_emails(&repo, user_id, account_id, again) .await .expect("process"); assert_eq!( r2.emails_saved, 1, "only the genuinely-new message is saved" ); assert_eq!(repo.list_all(user_id, true).await.unwrap().len(), 3); } #[tokio::test] async fn synthesizes_message_id_and_dedups_it_across_runs() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let account_id = make_account(&pool, user_id).await; let repo = SqliteEmailRepository::new(pool.clone()); // No message_id: process_fetched_emails hashes from/to/subject/date into a // stable "synth-..." id. Fix the date so the hash is identical across runs. let date = Utc::now(); let mut e1 = fetched(None, "sender@example.com", "No Message-ID"); e1.date = date; let r = process_fetched_emails(&repo, user_id, account_id, vec![e1]) .await .expect("process"); assert_eq!(r.emails_saved, 1); let saved = repo.list_all(user_id, true).await.unwrap(); assert_eq!(saved.len(), 1); let synth = saved[0].message_id.clone().expect("synthetic id assigned"); assert!(synth.starts_with("synth-"), "got: {synth}"); // Same content + date -> same synthetic id -> deduped on the next run. let mut e1_again = fetched(None, "sender@example.com", "No Message-ID"); e1_again.date = date; let r2 = process_fetched_emails(&repo, user_id, account_id, vec![e1_again]) .await .expect("process"); assert_eq!( r2.emails_saved, 0, "identical content dedups via synthetic id" ); } #[tokio::test] async fn thread_id_follows_reply_then_falls_back_to_message_id() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let account_id = make_account(&pool, user_id).await; let repo = SqliteEmailRepository::new(pool.clone()); // A thread-root email: thread_id falls back to its own message_id. let root = fetched(Some("root@example.com"), "a@example.com", "Root"); // A reply: thread_id should be the in_reply_to it points at. let mut reply = fetched(Some("reply@example.com"), "b@example.com", "Re: Root"); reply.in_reply_to = Some("root@example.com".to_string()); process_fetched_emails(&repo, user_id, account_id, vec![root, reply]) .await .expect("process"); let root_saved = repo .get_by_message_id(user_id, "root@example.com") .await .unwrap() .unwrap(); assert_eq!(root_saved.thread_id.as_deref(), Some("root@example.com")); let reply_saved = repo .get_by_message_id(user_id, "reply@example.com") .await .unwrap() .unwrap(); assert_eq!( reply_saved.thread_id.as_deref(), Some("root@example.com"), "a reply threads under the message it answers" ); } #[tokio::test] async fn clears_waiting_status_when_a_reply_arrives() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let account_id = make_account(&pool, user_id).await; let repo = SqliteEmailRepository::new(pool.clone()); // Save an outgoing message we're waiting on a reply to. process_fetched_emails( &repo, user_id, account_id, vec![fetched( Some("sent@example.com"), "me@example.com", "Question", )], ) .await .expect("process"); let sent = repo .get_by_message_id(user_id, "sent@example.com") .await .unwrap() .unwrap(); repo.mark_waiting(sent.id, user_id, Some(Utc::now() + Duration::days(3))) .await .unwrap(); // A reply to it arrives: waiting status must clear. let mut reply = fetched( Some("their-reply@example.com"), "them@example.com", "Re: Question", ); reply.in_reply_to = Some("sent@example.com".to_string()); let r = process_fetched_emails(&repo, user_id, account_id, vec![reply]) .await .expect("process"); assert_eq!(r.emails_saved, 1); assert_eq!(r.waiting_cleared, 1); let sent_after = repo .get_by_message_id(user_id, "sent@example.com") .await .unwrap() .unwrap(); assert!( !sent_after.waiting_for_response, "reply clears the waiting flag" ); } #[tokio::test] async fn empty_batch_is_a_noop() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let account_id = make_account(&pool, user_id).await; let repo = SqliteEmailRepository::new(pool.clone()); let r = process_fetched_emails(&repo, user_id, account_id, vec![]) .await .expect("process"); assert_eq!(r.emails_saved, 0); assert_eq!(r.waiting_cleared, 0); assert!(repo.list_all(user_id, true).await.unwrap().is_empty()); }