Skip to main content

max / goingson

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