//! Integration tests for SqliteEmailRepository. mod common; use chrono::{Duration, Utc}; use goingson_core::{EmailRepository, NewEmail}; use goingson_db_sqlite::SqliteEmailRepository; #[tokio::test] async fn test_create_and_get_email() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: "Test Subject".to_string(), body: "Test email body content".to_string(), is_read: false, received_at: Some(Utc::now()), }; let created = repo.create(user_id, new_email).await.expect("Failed to create email"); assert_eq!(created.subject, "Test Subject"); assert_eq!(created.from, "sender@example.com"); assert!(!created.is_read); assert!(!created.is_archived); let fetched = repo.get_by_id(created.id, user_id).await.expect("Failed to get email"); assert!(fetched.is_some()); assert_eq!(fetched.unwrap().id, created.id); } #[tokio::test] async fn test_email_read_unread() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: "Read/Unread test".to_string(), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now()), }; let email = repo.create(user_id, new_email).await.expect("Failed to create"); assert!(!email.is_read); // Mark as read let marked_read = repo.mark_read(email.id, user_id).await.expect("Failed to mark read"); assert!(marked_read); // Verify it's read let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap(); assert!(fetched.is_read); // Mark as unread let marked_unread = repo.mark_unread(email.id, user_id).await.expect("Failed to mark unread"); assert!(marked_unread); // Verify it's unread let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap(); assert!(!fetched.is_read); } #[tokio::test] async fn test_email_archive() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: "Archive test".to_string(), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now()), }; let email = repo.create(user_id, new_email).await.expect("Failed to create"); assert!(!email.is_archived); // Archive let archived = repo.archive(email.id, user_id).await.expect("Failed to archive"); assert!(archived); // Verify it's archived let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap(); assert!(fetched.is_archived); // Should not appear in non-archived list let emails = repo.list_all(user_id, false).await.expect("Failed to list"); assert!(emails.iter().all(|e| e.id != email.id)); // Should appear in list with archived included let all_emails = repo.list_all(user_id, true).await.expect("Failed to list all"); assert!(all_emails.iter().any(|e| e.id == email.id)); // Unarchive let unarchived = repo.unarchive(email.id, user_id).await.expect("Failed to unarchive"); assert!(unarchived); // Verify it's unarchived let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get").unwrap(); assert!(!fetched.is_archived); } #[tokio::test] async fn test_email_snooze() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: "Snooze test".to_string(), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now()), }; let email = repo.create(user_id, new_email).await.expect("Failed to create"); let until = Utc::now() + Duration::hours(4); let snoozed = repo.snooze(email.id, user_id, until).await.expect("Failed to snooze"); assert!(snoozed.is_some()); assert!(snoozed.as_ref().unwrap().snoozed_until.is_some()); // Should appear in snoozed list let snoozed_emails = repo.list_snoozed(user_id).await.expect("Failed to list snoozed"); assert_eq!(snoozed_emails.len(), 1); // Unsnooze let unsnoozed = repo.unsnooze(email.id, user_id).await.expect("Failed to unsnooze"); assert!(unsnoozed.is_some()); assert!(unsnoozed.unwrap().snoozed_until.is_none()); } #[tokio::test] async fn test_email_waiting_for_response() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "me@example.com".to_string(), to_address: "them@example.com".to_string(), subject: "Waiting test".to_string(), body: "Test body".to_string(), is_read: true, received_at: Some(Utc::now()), }; let email = repo.create(user_id, new_email).await.expect("Failed to create"); let expected = Utc::now() + Duration::days(2); let waiting = repo.mark_waiting(email.id, user_id, Some(expected)).await.expect("Failed to mark waiting"); assert!(waiting.is_some()); assert!(waiting.as_ref().unwrap().waiting_for_response); // Should appear in waiting list let waiting_emails = repo.list_waiting(user_id).await.expect("Failed to list waiting"); assert_eq!(waiting_emails.len(), 1); // Clear waiting let cleared = repo.clear_waiting(email.id, user_id).await.expect("Failed to clear"); assert!(cleared.is_some()); assert!(!cleared.unwrap().waiting_for_response); } #[tokio::test] async fn test_delete_email() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: "Delete test".to_string(), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now()), }; let email = repo.create(user_id, new_email).await.expect("Failed to create"); let deleted = repo.delete(email.id, user_id).await.expect("Failed to delete"); assert!(deleted); let fetched = repo.get_by_id(email.id, user_id).await.expect("Failed to get"); assert!(fetched.is_none()); } #[tokio::test] async fn test_email_ordering_by_received_at() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); // Create emails at different times for i in 0..3 { let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: format!("Email {}", i), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now() - Duration::hours(i as i64)), }; repo.create(user_id, new_email).await.expect("Failed to create"); } let emails = repo.list_all(user_id, false).await.expect("Failed to list"); assert_eq!(emails.len(), 3); // Should be ordered by received_at descending (newest first) assert!(emails[0].received_at >= emails[1].received_at); assert!(emails[1].received_at >= emails[2].received_at); } #[tokio::test] async fn test_list_threaded_returns_latest_per_thread() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool.clone()); let mk = |subject: &str, read: bool, hours_ago: i64| NewEmail { project_id: None, from_address: "a@example.com".to_string(), to_address: "me@example.com".to_string(), subject: subject.to_string(), body: "body".to_string(), is_read: read, received_at: Some(Utc::now() - Duration::hours(hours_ago)), }; // Two emails that will share a thread (older read, newer unread) + a standalone. let older = repo.create(user_id, mk("Thread root", true, 2)).await.unwrap(); let newer = repo.create(user_id, mk("Thread reply", false, 1)).await.unwrap(); let _standalone = repo.create(user_id, mk("Lonely", true, 0)).await.unwrap(); // Group the pair into one thread keyed by the root id. sqlx::query("UPDATE emails SET thread_id = ? WHERE id IN (?, ?)") .bind(older.id.to_string()) .bind(older.id.to_string()) .bind(newer.id.to_string()) .execute(&pool) .await .unwrap(); let (threads, total) = repo .list_threaded(user_id, true, None, None, None, None) .await .expect("list_threaded"); assert_eq!(total, 2, "the grouped pair counts as one thread, plus the standalone"); let grouped = threads .iter() .find(|t| t.thread_count == 2) .expect("expected a 2-email thread"); // The window function must surface the newest message as the thread's face. assert_eq!(grouped.most_recent_email.id, newer.id); assert!(grouped.has_unread, "thread contains an unread email"); } #[tokio::test] async fn test_count_unread() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); // Create 3 emails, mark 1 as read for i in 0..3 { let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: format!("Email {}", i), body: "Test body".to_string(), is_read: i == 0, // First one is read received_at: Some(Utc::now()), }; repo.create(user_id, new_email).await.expect("Failed to create"); } let count = repo.count_unread(user_id).await.expect("Failed to count"); assert_eq!(count, 2); } #[tokio::test] async fn test_mark_all_read() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); // Create 3 unread emails for i in 0..3 { let new_email = NewEmail { project_id: None, from_address: "sender@example.com".to_string(), to_address: "recipient@example.com".to_string(), subject: format!("Email {}", i), body: "Test body".to_string(), is_read: false, received_at: Some(Utc::now()), }; repo.create(user_id, new_email).await.expect("Failed to create"); } let marked = repo.mark_all_read(user_id).await.expect("Failed to mark all read"); assert_eq!(marked, 3); let count = repo.count_unread(user_id).await.expect("Failed to count"); assert_eq!(count, 0); } /// The project-dashboard email list (`list_by_project`) renders only /// subject/from/date and opens the reader (re-fetching the body via /// `get_by_id`) on click, so it must return body-less rows -- otherwise every /// email body materializes into RAM. Mirrors `list_metadata`'s Perf S3 rule. #[tokio::test] async fn list_by_project_is_body_less() { use goingson_core::{NewProject, ProjectRepository}; use goingson_db_sqlite::SqliteProjectRepository; let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let projects = SqliteProjectRepository::new(pool.clone()); let emails = SqliteEmailRepository::new(pool); let project = projects .create( user_id, NewProject { name: "Dashboard project".into(), description: String::new(), project_type: Default::default(), status: Default::default(), }, ) .await .expect("create project"); emails .create( user_id, NewEmail { project_id: Some(project.id), from_address: "s@example.com".to_string(), to_address: "r@example.com".to_string(), subject: "Linked".to_string(), body: "a very long body that must not be loaded for a list view".to_string(), is_read: false, received_at: Some(Utc::now()), }, ) .await .expect("create email"); let listed = emails.list_by_project(user_id, project.id).await.expect("list_by_project"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].subject, "Linked", "metadata still returned"); assert!(listed[0].body.is_empty(), "body must not be materialized in a list view"); assert!(listed[0].html_body.is_none(), "html body must not be materialized"); assert!(listed[0].body_truncated, "reader must know to re-fetch the full body"); } /// `list_unlinked` feeds the "link email to project" picker (subject/from only) /// and must likewise stay body-less so an unbounded mailbox can't blow up RAM. #[tokio::test] async fn list_unlinked_is_body_less() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteEmailRepository::new(pool); repo.create( user_id, NewEmail { project_id: None, from_address: "s@example.com".to_string(), to_address: "r@example.com".to_string(), subject: "Unlinked".to_string(), body: "body that should never be loaded into the picker".to_string(), is_read: false, received_at: Some(Utc::now()), }, ) .await .expect("create email"); let listed = repo.list_unlinked(user_id).await.expect("list_unlinked"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].subject, "Unlinked"); assert!(listed[0].body.is_empty(), "body must not be materialized"); assert!(listed[0].body_truncated, "reader must know to re-fetch the full body"); }