//! Integration tests for SqliteContactRepository. mod common; use goingson_core::{ ContactId, ContactRepository, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, UpdateContact, }; use goingson_db_sqlite::SqliteContactRepository; // ============ CRUD Tests ============ #[tokio::test] async fn create_and_get_contact() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let new = NewContact { display_name: "Alice Smith".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }; let created = repo.create(user_id, new).await.unwrap(); assert_eq!(created.display_name, "Alice Smith"); let fetched = repo.get_by_id(created.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.id, created.id); assert_eq!(fetched.display_name, "Alice Smith"); } #[tokio::test] async fn create_contact_with_optional_fields() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let new = NewContact { display_name: "Bob Jones".to_string(), nickname: Some("Bobby".to_string()), company: Some("Acme Corp".to_string()), title: Some("Engineer".to_string()), notes: "Met at conference".to_string(), tags: vec!["work".to_string(), "engineering".to_string()], birthday: Some(chrono::NaiveDate::from_ymd_opt(1990, 6, 15).unwrap()), timezone: Some("America/New_York".to_string()), is_implicit: false, }; let created = repo.create(user_id, new).await.unwrap(); assert_eq!(created.nickname.as_deref(), Some("Bobby")); assert_eq!(created.company.as_deref(), Some("Acme Corp")); assert_eq!(created.title.as_deref(), Some("Engineer")); assert_eq!(created.notes, "Met at conference"); assert_eq!(created.tags, vec!["work", "engineering"]); assert_eq!( created.birthday, Some(chrono::NaiveDate::from_ymd_opt(1990, 6, 15).unwrap()) ); assert_eq!(created.timezone.as_deref(), Some("America/New_York")); } #[tokio::test] async fn list_all_contacts() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); for name in ["Alice", "Bob"] { let new = NewContact { display_name: name.to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }; repo.create(user_id, new).await.unwrap(); } let contacts = repo.list_all(user_id).await.unwrap(); assert_eq!(contacts.len(), 2); // Sorted by display_name ASC assert_eq!(contacts[0].display_name, "Alice"); assert_eq!(contacts[1].display_name, "Bob"); } #[tokio::test] async fn update_contact() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let new = NewContact { display_name: "Original Name".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }; let created = repo.create(user_id, new).await.unwrap(); let update = UpdateContact { display_name: "Updated Name".to_string(), nickname: Some("Nick".to_string()), company: Some("New Co".to_string()), title: None, notes: "Updated notes".to_string(), tags: vec!["friend".to_string()], birthday: None, timezone: None, }; let updated = repo.update(created.id, user_id, update).await.unwrap().unwrap(); assert_eq!(updated.display_name, "Updated Name"); assert_eq!(updated.nickname.as_deref(), Some("Nick")); assert_eq!(updated.company.as_deref(), Some("New Co")); assert_eq!(updated.notes, "Updated notes"); } #[tokio::test] async fn update_nonexistent_returns_none() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let update = UpdateContact { display_name: "Ghost".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, }; let result = repo.update(ContactId::new(), user_id, update).await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn delete_contact() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let new = NewContact { display_name: "To Delete".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }; let created = repo.create(user_id, new).await.unwrap(); let deleted = repo.delete(created.id, user_id).await.unwrap(); assert!(deleted); let fetched = repo.get_by_id(created.id, user_id).await.unwrap(); assert!(fetched.is_none()); } #[tokio::test] async fn delete_cascades_sub_collections() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool.clone()); let new = NewContact { display_name: "Cascade Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }; let contact = repo.create(user_id, new).await.unwrap(); // Add sub-collections repo.add_email( contact.id, user_id, NewContactEmail { address: "cascade@example.com".to_string(), label: "work".to_string(), is_primary: true, }, ) .await .unwrap(); repo.add_phone( contact.id, user_id, NewContactPhone { number: "+1234567890".to_string(), label: "mobile".to_string(), is_primary: true, }, ) .await .unwrap(); // Delete the contact repo.delete(contact.id, user_id).await.unwrap(); // Verify sub-collections are gone (via raw SQL since we can't get_by_id anymore) let email_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM contact_emails WHERE contact_id = ?") .bind(contact.id.to_string()) .fetch_one(&pool) .await .unwrap(); assert_eq!(email_count.0, 0); let phone_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM contact_phones WHERE contact_id = ?") .bind(contact.id.to_string()) .fetch_one(&pool) .await .unwrap(); assert_eq!(phone_count.0, 0); } // ============ Sub-Collection Tests ============ #[tokio::test] async fn add_and_list_emails() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Email Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_email( contact.id, user_id, NewContactEmail { address: "work@example.com".to_string(), label: "work".to_string(), is_primary: true, }, ) .await .unwrap(); repo.add_email( contact.id, user_id, NewContactEmail { address: "personal@example.com".to_string(), label: "personal".to_string(), is_primary: false, }, ) .await .unwrap(); let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.emails.len(), 2); // Primary email first (ordered by is_primary DESC) assert_eq!(fetched.emails[0].address, "work@example.com"); assert!(fetched.emails[0].is_primary); } #[tokio::test] async fn remove_email() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Remove Email".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let email = repo .add_email( contact.id, user_id, NewContactEmail { address: "remove@example.com".to_string(), label: "work".to_string(), is_primary: false, }, ) .await .unwrap(); let removed = repo.remove_email(email.id, user_id).await.unwrap(); assert!(removed); let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap(); assert!(fetched.emails.is_empty()); } #[tokio::test] async fn add_and_list_phones() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Phone Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_phone( contact.id, user_id, NewContactPhone { number: "+1-555-0100".to_string(), label: "mobile".to_string(), is_primary: true, }, ) .await .unwrap(); let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.phones.len(), 1); assert_eq!(fetched.phones[0].number, "+1-555-0100"); } #[tokio::test] async fn add_and_list_social_handles() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Social Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_social_handle( contact.id, user_id, NewSocialHandle { platform: "github".to_string(), handle: "alice".to_string(), url: Some("https://github.com/alice".to_string()), }, ) .await .unwrap(); let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.social_handles.len(), 1); assert_eq!(fetched.social_handles[0].platform, "github"); assert_eq!(fetched.social_handles[0].handle, "alice"); } #[tokio::test] async fn add_and_list_custom_fields() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Custom Fields".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_custom_field( contact.id, user_id, NewContactCustomField { label: "Website".to_string(), value: "https://example.com".to_string(), url: Some("https://example.com".to_string()), }, ) .await .unwrap(); let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.custom_fields.len(), 1); assert_eq!(fetched.custom_fields[0].label, "Website"); assert_eq!(fetched.custom_fields[0].value, "https://example.com"); } #[tokio::test] async fn sub_collection_on_nonexistent_contact_errors() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let fake_id = ContactId::new(); let result = repo .add_email( fake_id, user_id, NewContactEmail { address: "ghost@example.com".to_string(), label: "work".to_string(), is_primary: false, }, ) .await; assert!(result.is_err()); } // ============ Filtering Tests ============ #[tokio::test] async fn list_by_tag() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); repo.create( user_id, NewContact { display_name: "Tagged".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec!["friend".to_string()], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.create( user_id, NewContact { display_name: "Untagged".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let result = repo.list_by_tag(user_id, "friend").await.unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].display_name, "Tagged"); } #[tokio::test] async fn list_filtered_by_search() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); repo.create( user_id, NewContact { display_name: "Alice Smith".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.create( user_id, NewContact { display_name: "Bob Jones".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let result = repo.list_filtered(user_id, Some("alice"), None, false).await.unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].display_name, "Alice Smith"); } #[tokio::test] async fn list_filtered_by_tag_and_search() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); repo.create( user_id, NewContact { display_name: "Alice Work".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec!["work".to_string()], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.create( user_id, NewContact { display_name: "Alice Personal".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec!["personal".to_string()], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let result = repo .list_filtered(user_id, Some("alice"), Some("work"), false) .await .unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].display_name, "Alice Work"); } #[tokio::test] async fn find_by_email() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Email Lookup".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_email( contact.id, user_id, NewContactEmail { address: "findme@example.com".to_string(), label: "work".to_string(), is_primary: true, }, ) .await .unwrap(); let found = repo .find_by_email(user_id, "findme@example.com") .await .unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().display_name, "Email Lookup"); } #[tokio::test] async fn find_by_email_case_insensitive() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Case Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); repo.add_email( contact.id, user_id, NewContactEmail { address: "alice@example.com".to_string(), label: "work".to_string(), is_primary: true, }, ) .await .unwrap(); let found = repo .find_by_email(user_id, "ALICE@EXAMPLE.COM") .await .unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().display_name, "Case Test"); } #[tokio::test] async fn update_contact_subcollections() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let contact = repo .create( user_id, NewContact { display_name: "Edit Test".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, }, ) .await .unwrap(); let email = repo.add_email(contact.id, user_id, NewContactEmail { address: "old@example.com".into(), label: "work".into(), is_primary: false, }).await.unwrap(); let updated = repo.update_email(email.id, user_id, NewContactEmail { address: "new@example.com".into(), label: "home".into(), is_primary: true, }).await.unwrap().expect("email should exist"); assert_eq!(updated.address, "new@example.com"); assert_eq!(updated.label, "home"); assert!(updated.is_primary); let phone = repo.add_phone(contact.id, user_id, NewContactPhone { number: "+1-555-0001".into(), label: "mobile".into(), is_primary: false, }).await.unwrap(); let updated = repo.update_phone(phone.id, user_id, NewContactPhone { number: "+1-555-0002".into(), label: "work".into(), is_primary: true, }).await.unwrap().expect("phone should exist"); assert_eq!(updated.number, "+1-555-0002"); assert!(updated.is_primary); let handle = repo.add_social_handle(contact.id, user_id, NewSocialHandle { platform: "github".into(), handle: "old".into(), url: None, }).await.unwrap(); let updated = repo.update_social_handle(handle.id, user_id, NewSocialHandle { platform: "mastodon".into(), handle: "new@example.social".into(), url: Some("https://example.social/@new".into()), }).await.unwrap().expect("handle should exist"); assert_eq!(updated.platform, "mastodon"); assert_eq!(updated.handle, "new@example.social"); assert_eq!(updated.url.as_deref(), Some("https://example.social/@new")); let field = repo.add_custom_field(contact.id, user_id, NewContactCustomField { label: "Website".into(), value: "old.example".into(), url: None, }).await.unwrap(); let updated = repo.update_custom_field(field.id, user_id, NewContactCustomField { label: "Homepage".into(), value: "new.example".into(), url: Some("https://new.example".into()), }).await.unwrap().expect("field should exist"); assert_eq!(updated.label, "Homepage"); assert_eq!(updated.url.as_deref(), Some("https://new.example")); } #[tokio::test] async fn update_missing_subcollection_returns_none() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteContactRepository::new(pool); let result = repo .update_email( goingson_core::ContactEmailId::new(), user_id, NewContactEmail { address: "x@example.com".into(), label: String::new(), is_primary: false, }, ) .await .unwrap(); assert!(result.is_none()); }