Skip to main content

max / goingson

27.2 KB · 926 lines History Blame Raw
1 //! Integration tests for SqliteContactRepository.
2
3 mod common;
4
5 use goingson_core::{
6 ContactId, ContactRepository, NewContact, NewContactCustomField, NewContactEmail,
7 NewContactPhone, NewSocialHandle, UpdateContact,
8 };
9 use goingson_db_sqlite::SqliteContactRepository;
10
11 /// Deleting a contact hard-deletes it, cascading to `contact_emails`
12 /// (ON DELETE CASCADE) -- and those cascaded child deletes MUST be logged in
13 /// `sync_changelog`, or the delete would replicate as parent-only and every
14 /// child email would orphan on remote devices forever.
15 ///
16 /// SQLite (3.46 here) fires `AFTER DELETE` triggers for rows removed by an FK
17 /// cascade regardless of the `recursive_triggers` pragma -- so the changelog
18 /// triggers already log the cascade and there is no orphan bug. (This directly
19 /// refutes the 2026-07-06 fuzz finding, which claimed the cascade skipped the
20 /// child triggers unless `recursive_triggers` was ON; verified empirically --
21 /// the cascade fires the trigger with the pragma OFF.) This test guards that
22 /// property so a future change that swaps the FK cascade for a trigger-bypassing
23 /// code-path delete can't silently reintroduce the orphan risk.
24 #[tokio::test]
25 async fn cascade_child_delete_is_logged_to_changelog() {
26 let pool = common::setup_test_db().await;
27 let user_id = common::create_test_user(&pool).await;
28 let repo = SqliteContactRepository::new(pool.clone());
29
30 let contact = repo
31 .create(
32 user_id,
33 NewContact {
34 display_name: "Cascade Target".to_string(),
35 nickname: None,
36 company: None,
37 title: None,
38 notes: String::new(),
39 tags: vec![],
40 birthday: None,
41 timezone: None,
42 is_implicit: false,
43 },
44 )
45 .await
46 .expect("create contact");
47
48 let email = repo
49 .add_email(
50 contact.id,
51 user_id,
52 NewContactEmail {
53 address: "child@example.com".to_string(),
54 label: "home".to_string(),
55 is_primary: true,
56 },
57 )
58 .await
59 .expect("add email");
60
61 // Discard the insert changelog entries so we assert purely on the cascade.
62 sqlx::query("DELETE FROM sync_changelog")
63 .execute(&pool)
64 .await
65 .expect("clear changelog");
66
67 let deleted = repo.delete(contact.id, user_id).await.expect("delete contact");
68 assert!(deleted, "parent contact should delete");
69
70 let child_delete_logged: i64 = sqlx::query_scalar(
71 "SELECT COUNT(*) FROM sync_changelog \
72 WHERE table_name = 'contact_emails' AND op = 'DELETE' AND row_id = ?",
73 )
74 .bind(email.id.to_string())
75 .fetch_one(&pool)
76 .await
77 .expect("query changelog");
78
79 assert_eq!(
80 child_delete_logged, 1,
81 "cascaded child delete must be logged to sync_changelog"
82 );
83 }
84
85 // ============ CRUD Tests ============
86
87 #[tokio::test]
88 async fn create_and_get_contact() {
89 let pool = common::setup_test_db().await;
90 let user_id = common::create_test_user(&pool).await;
91 let repo = SqliteContactRepository::new(pool);
92
93 let new = NewContact {
94 display_name: "Alice Smith".to_string(),
95 nickname: None,
96 company: None,
97 title: None,
98 notes: String::new(),
99 tags: vec![],
100 birthday: None,
101 timezone: None,
102 is_implicit: false,
103 };
104
105 let created = repo.create(user_id, new).await.unwrap();
106 assert_eq!(created.display_name, "Alice Smith");
107
108 let fetched = repo.get_by_id(created.id, user_id).await.unwrap().unwrap();
109 assert_eq!(fetched.id, created.id);
110 assert_eq!(fetched.display_name, "Alice Smith");
111 }
112
113 #[tokio::test]
114 async fn create_contact_with_optional_fields() {
115 let pool = common::setup_test_db().await;
116 let user_id = common::create_test_user(&pool).await;
117 let repo = SqliteContactRepository::new(pool);
118
119 let new = NewContact {
120 display_name: "Bob Jones".to_string(),
121 nickname: Some("Bobby".to_string()),
122 company: Some("Acme Corp".to_string()),
123 title: Some("Engineer".to_string()),
124 notes: "Met at conference".to_string(),
125 tags: vec!["work".to_string(), "engineering".to_string()],
126 birthday: Some(chrono::NaiveDate::from_ymd_opt(1990, 6, 15).unwrap()),
127 timezone: Some("America/New_York".to_string()),
128 is_implicit: false,
129 };
130
131 let created = repo.create(user_id, new).await.unwrap();
132 assert_eq!(created.nickname.as_deref(), Some("Bobby"));
133 assert_eq!(created.company.as_deref(), Some("Acme Corp"));
134 assert_eq!(created.title.as_deref(), Some("Engineer"));
135 assert_eq!(created.notes, "Met at conference");
136 assert_eq!(created.tags, vec!["work", "engineering"]);
137 assert_eq!(
138 created.birthday,
139 Some(chrono::NaiveDate::from_ymd_opt(1990, 6, 15).unwrap())
140 );
141 assert_eq!(created.timezone.as_deref(), Some("America/New_York"));
142 }
143
144 #[tokio::test]
145 async fn list_all_contacts() {
146 let pool = common::setup_test_db().await;
147 let user_id = common::create_test_user(&pool).await;
148 let repo = SqliteContactRepository::new(pool);
149
150 for name in ["Alice", "Bob"] {
151 let new = NewContact {
152 display_name: name.to_string(),
153 nickname: None,
154 company: None,
155 title: None,
156 notes: String::new(),
157 tags: vec![],
158 birthday: None,
159 timezone: None,
160 is_implicit: false,
161 };
162 repo.create(user_id, new).await.unwrap();
163 }
164
165 let contacts = repo.list_all(user_id).await.unwrap();
166 assert_eq!(contacts.len(), 2);
167 // Sorted by display_name ASC
168 assert_eq!(contacts[0].display_name, "Alice");
169 assert_eq!(contacts[1].display_name, "Bob");
170 }
171
172 #[tokio::test]
173 async fn update_contact() {
174 let pool = common::setup_test_db().await;
175 let user_id = common::create_test_user(&pool).await;
176 let repo = SqliteContactRepository::new(pool);
177
178 let new = NewContact {
179 display_name: "Original Name".to_string(),
180 nickname: None,
181 company: None,
182 title: None,
183 notes: String::new(),
184 tags: vec![],
185 birthday: None,
186 timezone: None,
187 is_implicit: false,
188 };
189
190 let created = repo.create(user_id, new).await.unwrap();
191
192 let update = UpdateContact {
193 display_name: "Updated Name".to_string(),
194 nickname: Some("Nick".to_string()),
195 company: Some("New Co".to_string()),
196 title: None,
197 notes: "Updated notes".to_string(),
198 tags: vec!["friend".to_string()],
199 birthday: None,
200 timezone: None,
201 };
202
203 let updated = repo.update(created.id, user_id, update).await.unwrap().unwrap();
204 assert_eq!(updated.display_name, "Updated Name");
205 assert_eq!(updated.nickname.as_deref(), Some("Nick"));
206 assert_eq!(updated.company.as_deref(), Some("New Co"));
207 assert_eq!(updated.notes, "Updated notes");
208 }
209
210 #[tokio::test]
211 async fn update_nonexistent_returns_none() {
212 let pool = common::setup_test_db().await;
213 let user_id = common::create_test_user(&pool).await;
214 let repo = SqliteContactRepository::new(pool);
215
216 let update = UpdateContact {
217 display_name: "Ghost".to_string(),
218 nickname: None,
219 company: None,
220 title: None,
221 notes: String::new(),
222 tags: vec![],
223 birthday: None,
224 timezone: None,
225 };
226
227 let result = repo.update(ContactId::new(), user_id, update).await.unwrap();
228 assert!(result.is_none());
229 }
230
231 #[tokio::test]
232 async fn delete_contact() {
233 let pool = common::setup_test_db().await;
234 let user_id = common::create_test_user(&pool).await;
235 let repo = SqliteContactRepository::new(pool);
236
237 let new = NewContact {
238 display_name: "To Delete".to_string(),
239 nickname: None,
240 company: None,
241 title: None,
242 notes: String::new(),
243 tags: vec![],
244 birthday: None,
245 timezone: None,
246 is_implicit: false,
247 };
248
249 let created = repo.create(user_id, new).await.unwrap();
250 let deleted = repo.delete(created.id, user_id).await.unwrap();
251 assert!(deleted);
252
253 let fetched = repo.get_by_id(created.id, user_id).await.unwrap();
254 assert!(fetched.is_none());
255 }
256
257 #[tokio::test]
258 async fn delete_cascades_sub_collections() {
259 let pool = common::setup_test_db().await;
260 let user_id = common::create_test_user(&pool).await;
261 let repo = SqliteContactRepository::new(pool.clone());
262
263 let new = NewContact {
264 display_name: "Cascade Test".to_string(),
265 nickname: None,
266 company: None,
267 title: None,
268 notes: String::new(),
269 tags: vec![],
270 birthday: None,
271 timezone: None,
272 is_implicit: false,
273 };
274
275 let contact = repo.create(user_id, new).await.unwrap();
276
277 // Add sub-collections
278 repo.add_email(
279 contact.id,
280 user_id,
281 NewContactEmail {
282 address: "cascade@example.com".to_string(),
283 label: "work".to_string(),
284 is_primary: true,
285 },
286 )
287 .await
288 .unwrap();
289
290 repo.add_phone(
291 contact.id,
292 user_id,
293 NewContactPhone {
294 number: "+1234567890".to_string(),
295 label: "mobile".to_string(),
296 is_primary: true,
297 },
298 )
299 .await
300 .unwrap();
301
302 // Delete the contact
303 repo.delete(contact.id, user_id).await.unwrap();
304
305 // Verify sub-collections are gone (via raw SQL since we can't get_by_id anymore)
306 let email_count: (i64,) =
307 sqlx::query_as("SELECT COUNT(*) FROM contact_emails WHERE contact_id = ?")
308 .bind(contact.id.to_string())
309 .fetch_one(&pool)
310 .await
311 .unwrap();
312 assert_eq!(email_count.0, 0);
313
314 let phone_count: (i64,) =
315 sqlx::query_as("SELECT COUNT(*) FROM contact_phones WHERE contact_id = ?")
316 .bind(contact.id.to_string())
317 .fetch_one(&pool)
318 .await
319 .unwrap();
320 assert_eq!(phone_count.0, 0);
321 }
322
323 // ============ Sub-Collection Tests ============
324
325 #[tokio::test]
326 async fn add_and_list_emails() {
327 let pool = common::setup_test_db().await;
328 let user_id = common::create_test_user(&pool).await;
329 let repo = SqliteContactRepository::new(pool);
330
331 let contact = repo
332 .create(
333 user_id,
334 NewContact {
335 display_name: "Email Test".to_string(),
336 nickname: None,
337 company: None,
338 title: None,
339 notes: String::new(),
340 tags: vec![],
341 birthday: None,
342 timezone: None,
343 is_implicit: false,
344 },
345 )
346 .await
347 .unwrap();
348
349 repo.add_email(
350 contact.id,
351 user_id,
352 NewContactEmail {
353 address: "work@example.com".to_string(),
354 label: "work".to_string(),
355 is_primary: true,
356 },
357 )
358 .await
359 .unwrap();
360
361 repo.add_email(
362 contact.id,
363 user_id,
364 NewContactEmail {
365 address: "personal@example.com".to_string(),
366 label: "personal".to_string(),
367 is_primary: false,
368 },
369 )
370 .await
371 .unwrap();
372
373 let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap();
374 assert_eq!(fetched.emails.len(), 2);
375 // Primary email first (ordered by is_primary DESC)
376 assert_eq!(fetched.emails[0].address, "work@example.com");
377 assert!(fetched.emails[0].is_primary);
378 }
379
380 #[tokio::test]
381 async fn remove_email() {
382 let pool = common::setup_test_db().await;
383 let user_id = common::create_test_user(&pool).await;
384 let repo = SqliteContactRepository::new(pool);
385
386 let contact = repo
387 .create(
388 user_id,
389 NewContact {
390 display_name: "Remove Email".to_string(),
391 nickname: None,
392 company: None,
393 title: None,
394 notes: String::new(),
395 tags: vec![],
396 birthday: None,
397 timezone: None,
398 is_implicit: false,
399 },
400 )
401 .await
402 .unwrap();
403
404 let email = repo
405 .add_email(
406 contact.id,
407 user_id,
408 NewContactEmail {
409 address: "remove@example.com".to_string(),
410 label: "work".to_string(),
411 is_primary: false,
412 },
413 )
414 .await
415 .unwrap();
416
417 let removed = repo.remove_email(email.id, user_id).await.unwrap();
418 assert!(removed);
419
420 let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap();
421 assert!(fetched.emails.is_empty());
422 }
423
424 #[tokio::test]
425 async fn add_and_list_phones() {
426 let pool = common::setup_test_db().await;
427 let user_id = common::create_test_user(&pool).await;
428 let repo = SqliteContactRepository::new(pool);
429
430 let contact = repo
431 .create(
432 user_id,
433 NewContact {
434 display_name: "Phone Test".to_string(),
435 nickname: None,
436 company: None,
437 title: None,
438 notes: String::new(),
439 tags: vec![],
440 birthday: None,
441 timezone: None,
442 is_implicit: false,
443 },
444 )
445 .await
446 .unwrap();
447
448 repo.add_phone(
449 contact.id,
450 user_id,
451 NewContactPhone {
452 number: "+1-555-0100".to_string(),
453 label: "mobile".to_string(),
454 is_primary: true,
455 },
456 )
457 .await
458 .unwrap();
459
460 let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap();
461 assert_eq!(fetched.phones.len(), 1);
462 assert_eq!(fetched.phones[0].number, "+1-555-0100");
463 }
464
465 #[tokio::test]
466 async fn add_and_list_social_handles() {
467 let pool = common::setup_test_db().await;
468 let user_id = common::create_test_user(&pool).await;
469 let repo = SqliteContactRepository::new(pool);
470
471 let contact = repo
472 .create(
473 user_id,
474 NewContact {
475 display_name: "Social Test".to_string(),
476 nickname: None,
477 company: None,
478 title: None,
479 notes: String::new(),
480 tags: vec![],
481 birthday: None,
482 timezone: None,
483 is_implicit: false,
484 },
485 )
486 .await
487 .unwrap();
488
489 repo.add_social_handle(
490 contact.id,
491 user_id,
492 NewSocialHandle {
493 platform: "github".to_string(),
494 handle: "alice".to_string(),
495 url: Some("https://github.com/alice".to_string()),
496 },
497 )
498 .await
499 .unwrap();
500
501 let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap();
502 assert_eq!(fetched.social_handles.len(), 1);
503 assert_eq!(fetched.social_handles[0].platform, "github");
504 assert_eq!(fetched.social_handles[0].handle, "alice");
505 }
506
507 #[tokio::test]
508 async fn add_and_list_custom_fields() {
509 let pool = common::setup_test_db().await;
510 let user_id = common::create_test_user(&pool).await;
511 let repo = SqliteContactRepository::new(pool);
512
513 let contact = repo
514 .create(
515 user_id,
516 NewContact {
517 display_name: "Custom Fields".to_string(),
518 nickname: None,
519 company: None,
520 title: None,
521 notes: String::new(),
522 tags: vec![],
523 birthday: None,
524 timezone: None,
525 is_implicit: false,
526 },
527 )
528 .await
529 .unwrap();
530
531 repo.add_custom_field(
532 contact.id,
533 user_id,
534 NewContactCustomField {
535 label: "Website".to_string(),
536 value: "https://example.com".to_string(),
537 url: Some("https://example.com".to_string()),
538 },
539 )
540 .await
541 .unwrap();
542
543 let fetched = repo.get_by_id(contact.id, user_id).await.unwrap().unwrap();
544 assert_eq!(fetched.custom_fields.len(), 1);
545 assert_eq!(fetched.custom_fields[0].label, "Website");
546 assert_eq!(fetched.custom_fields[0].value, "https://example.com");
547 }
548
549 #[tokio::test]
550 async fn sub_collection_on_nonexistent_contact_errors() {
551 let pool = common::setup_test_db().await;
552 let user_id = common::create_test_user(&pool).await;
553 let repo = SqliteContactRepository::new(pool);
554
555 let fake_id = ContactId::new();
556 let result = repo
557 .add_email(
558 fake_id,
559 user_id,
560 NewContactEmail {
561 address: "ghost@example.com".to_string(),
562 label: "work".to_string(),
563 is_primary: false,
564 },
565 )
566 .await;
567
568 assert!(result.is_err());
569 }
570
571 // ============ Filtering Tests ============
572
573 #[tokio::test]
574 async fn list_by_tag() {
575 let pool = common::setup_test_db().await;
576 let user_id = common::create_test_user(&pool).await;
577 let repo = SqliteContactRepository::new(pool);
578
579 repo.create(
580 user_id,
581 NewContact {
582 display_name: "Tagged".to_string(),
583 nickname: None,
584 company: None,
585 title: None,
586 notes: String::new(),
587 tags: vec!["friend".to_string()],
588 birthday: None,
589 timezone: None,
590 is_implicit: false,
591 },
592 )
593 .await
594 .unwrap();
595
596 repo.create(
597 user_id,
598 NewContact {
599 display_name: "Untagged".to_string(),
600 nickname: None,
601 company: None,
602 title: None,
603 notes: String::new(),
604 tags: vec![],
605 birthday: None,
606 timezone: None,
607 is_implicit: false,
608 },
609 )
610 .await
611 .unwrap();
612
613 let result = repo.list_by_tag(user_id, "friend").await.unwrap();
614 assert_eq!(result.len(), 1);
615 assert_eq!(result[0].display_name, "Tagged");
616 }
617
618 #[tokio::test]
619 async fn list_filtered_by_search() {
620 let pool = common::setup_test_db().await;
621 let user_id = common::create_test_user(&pool).await;
622 let repo = SqliteContactRepository::new(pool);
623
624 repo.create(
625 user_id,
626 NewContact {
627 display_name: "Alice Smith".to_string(),
628 nickname: None,
629 company: None,
630 title: None,
631 notes: String::new(),
632 tags: vec![],
633 birthday: None,
634 timezone: None,
635 is_implicit: false,
636 },
637 )
638 .await
639 .unwrap();
640
641 repo.create(
642 user_id,
643 NewContact {
644 display_name: "Bob Jones".to_string(),
645 nickname: None,
646 company: None,
647 title: None,
648 notes: String::new(),
649 tags: vec![],
650 birthday: None,
651 timezone: None,
652 is_implicit: false,
653 },
654 )
655 .await
656 .unwrap();
657
658 let result = repo.list_filtered(user_id, Some("alice"), None, false).await.unwrap();
659 assert_eq!(result.len(), 1);
660 assert_eq!(result[0].display_name, "Alice Smith");
661 }
662
663 #[tokio::test]
664 async fn list_filtered_by_tag_and_search() {
665 let pool = common::setup_test_db().await;
666 let user_id = common::create_test_user(&pool).await;
667 let repo = SqliteContactRepository::new(pool);
668
669 repo.create(
670 user_id,
671 NewContact {
672 display_name: "Alice Work".to_string(),
673 nickname: None,
674 company: None,
675 title: None,
676 notes: String::new(),
677 tags: vec!["work".to_string()],
678 birthday: None,
679 timezone: None,
680 is_implicit: false,
681 },
682 )
683 .await
684 .unwrap();
685
686 repo.create(
687 user_id,
688 NewContact {
689 display_name: "Alice Personal".to_string(),
690 nickname: None,
691 company: None,
692 title: None,
693 notes: String::new(),
694 tags: vec!["personal".to_string()],
695 birthday: None,
696 timezone: None,
697 is_implicit: false,
698 },
699 )
700 .await
701 .unwrap();
702
703 let result = repo
704 .list_filtered(user_id, Some("alice"), Some("work"), false)
705 .await
706 .unwrap();
707 assert_eq!(result.len(), 1);
708 assert_eq!(result[0].display_name, "Alice Work");
709 }
710
711 #[tokio::test]
712 async fn find_by_email() {
713 let pool = common::setup_test_db().await;
714 let user_id = common::create_test_user(&pool).await;
715 let repo = SqliteContactRepository::new(pool);
716
717 let contact = repo
718 .create(
719 user_id,
720 NewContact {
721 display_name: "Email Lookup".to_string(),
722 nickname: None,
723 company: None,
724 title: None,
725 notes: String::new(),
726 tags: vec![],
727 birthday: None,
728 timezone: None,
729 is_implicit: false,
730 },
731 )
732 .await
733 .unwrap();
734
735 repo.add_email(
736 contact.id,
737 user_id,
738 NewContactEmail {
739 address: "findme@example.com".to_string(),
740 label: "work".to_string(),
741 is_primary: true,
742 },
743 )
744 .await
745 .unwrap();
746
747 let found = repo
748 .find_by_email(user_id, "findme@example.com")
749 .await
750 .unwrap();
751 assert!(found.is_some());
752 assert_eq!(found.unwrap().display_name, "Email Lookup");
753 }
754
755 #[tokio::test]
756 async fn find_by_email_case_insensitive() {
757 let pool = common::setup_test_db().await;
758 let user_id = common::create_test_user(&pool).await;
759 let repo = SqliteContactRepository::new(pool);
760
761 let contact = repo
762 .create(
763 user_id,
764 NewContact {
765 display_name: "Case Test".to_string(),
766 nickname: None,
767 company: None,
768 title: None,
769 notes: String::new(),
770 tags: vec![],
771 birthday: None,
772 timezone: None,
773 is_implicit: false,
774 },
775 )
776 .await
777 .unwrap();
778
779 repo.add_email(
780 contact.id,
781 user_id,
782 NewContactEmail {
783 address: "alice@example.com".to_string(),
784 label: "work".to_string(),
785 is_primary: true,
786 },
787 )
788 .await
789 .unwrap();
790
791 let found = repo
792 .find_by_email(user_id, "ALICE@EXAMPLE.COM")
793 .await
794 .unwrap();
795 assert!(found.is_some());
796 assert_eq!(found.unwrap().display_name, "Case Test");
797 }
798
799 #[tokio::test]
800 async fn update_contact_subcollections() {
801 let pool = common::setup_test_db().await;
802 let user_id = common::create_test_user(&pool).await;
803 let repo = SqliteContactRepository::new(pool);
804
805 let contact = repo
806 .create(
807 user_id,
808 NewContact {
809 display_name: "Edit Test".to_string(),
810 nickname: None,
811 company: None,
812 title: None,
813 notes: String::new(),
814 tags: vec![],
815 birthday: None,
816 timezone: None,
817 is_implicit: false,
818 },
819 )
820 .await
821 .unwrap();
822
823 let email = repo.add_email(contact.id, user_id, NewContactEmail {
824 address: "old@example.com".into(), label: "work".into(), is_primary: false,
825 }).await.unwrap();
826 let updated = repo.update_email(email.id, user_id, NewContactEmail {
827 address: "new@example.com".into(), label: "home".into(), is_primary: true,
828 }).await.unwrap().expect("email should exist");
829 assert_eq!(updated.address, "new@example.com");
830 assert_eq!(updated.label, "home");
831 assert!(updated.is_primary);
832
833 let phone = repo.add_phone(contact.id, user_id, NewContactPhone {
834 number: "+1-555-0001".into(), label: "mobile".into(), is_primary: false,
835 }).await.unwrap();
836 let updated = repo.update_phone(phone.id, user_id, NewContactPhone {
837 number: "+1-555-0002".into(), label: "work".into(), is_primary: true,
838 }).await.unwrap().expect("phone should exist");
839 assert_eq!(updated.number, "+1-555-0002");
840 assert!(updated.is_primary);
841
842 let handle = repo.add_social_handle(contact.id, user_id, NewSocialHandle {
843 platform: "github".into(), handle: "old".into(), url: None,
844 }).await.unwrap();
845 let updated = repo.update_social_handle(handle.id, user_id, NewSocialHandle {
846 platform: "mastodon".into(), handle: "new@example.social".into(),
847 url: Some("https://example.social/@new".into()),
848 }).await.unwrap().expect("handle should exist");
849 assert_eq!(updated.platform, "mastodon");
850 assert_eq!(updated.handle, "new@example.social");
851 assert_eq!(updated.url.as_deref(), Some("https://example.social/@new"));
852
853 let field = repo.add_custom_field(contact.id, user_id, NewContactCustomField {
854 label: "Website".into(), value: "old.example".into(), url: None,
855 }).await.unwrap();
856 let updated = repo.update_custom_field(field.id, user_id, NewContactCustomField {
857 label: "Homepage".into(), value: "new.example".into(),
858 url: Some("https://new.example".into()),
859 }).await.unwrap().expect("field should exist");
860 assert_eq!(updated.label, "Homepage");
861 assert_eq!(updated.url.as_deref(), Some("https://new.example"));
862 }
863
864 #[tokio::test]
865 async fn update_missing_subcollection_returns_none() {
866 let pool = common::setup_test_db().await;
867 let user_id = common::create_test_user(&pool).await;
868 let repo = SqliteContactRepository::new(pool);
869
870 let result = repo
871 .update_email(
872 goingson_core::ContactEmailId::new(),
873 user_id,
874 NewContactEmail {
875 address: "x@example.com".into(),
876 label: String::new(),
877 is_primary: false,
878 },
879 )
880 .await
881 .unwrap();
882 assert!(result.is_none());
883 }
884
885 #[tokio::test]
886 async fn list_email_directory_flattens_addresses_and_honors_implicit() {
887 let pool = common::setup_test_db().await;
888 let user_id = common::create_test_user(&pool).await;
889 let repo = SqliteContactRepository::new(pool);
890
891 let mk = |name: &str, implicit: bool| NewContact {
892 display_name: name.to_string(),
893 nickname: None,
894 company: None,
895 title: None,
896 notes: String::new(),
897 tags: vec![],
898 birthday: None,
899 timezone: None,
900 is_implicit: implicit,
901 };
902
903 let alice = repo.create(user_id, mk("Alice", false)).await.unwrap();
904 repo.add_email(alice.id, user_id, NewContactEmail {
905 address: "alice@work.com".to_string(), label: "work".to_string(), is_primary: true,
906 }).await.unwrap();
907 repo.add_email(alice.id, user_id, NewContactEmail {
908 address: "alice@home.com".to_string(), label: "home".to_string(), is_primary: false,
909 }).await.unwrap();
910
911 let ghost = repo.create(user_id, mk("Ghost", true)).await.unwrap();
912 repo.add_email(ghost.id, user_id, NewContactEmail {
913 address: "ghost@example.com".to_string(), label: "other".to_string(), is_primary: true,
914 }).await.unwrap();
915
916 // Implicit excluded: only Alice's two addresses.
917 let explicit = repo.list_email_directory(user_id, false).await.unwrap();
918 assert_eq!(explicit.len(), 2, "two addresses for the one explicit contact");
919 assert!(explicit.iter().all(|e| e.name == "Alice" && !e.is_implicit));
920
921 // Implicit included: Alice's two + Ghost's one.
922 let all = repo.list_email_directory(user_id, true).await.unwrap();
923 assert_eq!(all.len(), 3);
924 assert!(all.iter().any(|e| e.email == "ghost@example.com" && e.is_implicit));
925 }
926