Skip to main content

max / goingson

GO-33-1: cover models/task.rs and the two untested repos (+41 tests) Close the achievable slice of the standing testing gap: - core/models/task.rs: 28 pure-logic unit tests (0 before) covering the TaskStatus/Priority/TaskSortColumn enums and the Task display/derivation methods (due_formatted, urgency_class, is_overdue, time_progress, snooze/ waiting/focus flags, subtask progress) plus the NewTaskBuilder - db-sqlite: new integration tests for the two repos that had none — daily_note_repo (upsert conflict-updates-in-place preserving id/created_at, ordering, per-user scoping) and sync_account_repo (defaults, null email, update/delete hit+miss, per-user ordering, cross-user access denied) Network-client tests (imap/smtp/jmap/oauth) stay deferred by decision — no HTTP-mock seam. Suite 869 -> 910, clippy clean on all targets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 15:10 UTC
Commit: d557c46d6420c6a277a944a9e816a15f27b4adc9
Parent: 205bc48
3 files changed, +584 insertions, -0 deletions
@@ -724,3 +724,357 @@ pub struct UpdateTask {
724 724 /// Estimated duration in minutes.
725 725 pub estimated_minutes: Option<i32>,
726 726 }
727 +
728 + #[cfg(test)]
729 + mod tests {
730 + use super::*;
731 + use crate::id_types::{SubtaskId, TaskId};
732 + use crate::models::shared::{CssClass, DbValue, Recurrence};
733 + use chrono::{Duration, Utc};
734 + use std::str::FromStr;
735 +
736 + /// A baseline task with everything empty/defaulted; mutate fields per test.
737 + fn task() -> Task {
738 + Task {
739 + id: TaskId::new(),
740 + project_id: None,
741 + project_name: None,
742 + milestone_id: None,
743 + contact_id: None,
744 + contact_name: None,
745 + description: "Test task".to_string(),
746 + status: TaskStatus::Pending,
747 + priority: Priority::Medium,
748 + due: None,
749 + tags: Vec::new(),
750 + urgency: 0.0,
751 + recurrence: Recurrence::None,
752 + recurrence_rule: None,
753 + recurrence_parent_id: None,
754 + source_email_id: None,
755 + snoozed_until: None,
756 + waiting_for_response: false,
757 + waiting_since: None,
758 + expected_response_date: None,
759 + scheduled_start: None,
760 + scheduled_duration: None,
761 + annotations: Vec::new(),
762 + subtasks: Vec::new(),
763 + estimated_minutes: None,
764 + actual_minutes: 0,
765 + active_session: None,
766 + created_at: Utc::now(),
767 + completed_at: None,
768 + is_focus: false,
769 + focus_set_at: None,
770 + }
771 + }
772 +
773 + fn subtask(is_completed: bool) -> Subtask {
774 + Subtask {
775 + id: SubtaskId::new(),
776 + task_id: TaskId::new(),
777 + text: "sub".to_string(),
778 + linked_task_id: None,
779 + is_completed,
780 + position: 0,
781 + }
782 + }
783 +
784 + // ---- TaskStatus ----
785 +
786 + #[test]
787 + fn task_status_as_str_and_css_and_db() {
788 + assert_eq!(TaskStatus::Started.as_str(), "Started");
789 + assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
790 + assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
791 + assert_eq!(TaskStatus::default(), TaskStatus::Pending);
792 + }
793 +
794 + #[test]
795 + fn task_status_from_str() {
796 + assert_eq!(TaskStatus::from_str("Completed").unwrap(), TaskStatus::Completed);
797 + assert!(TaskStatus::from_str("nonsense").is_err());
798 + }
799 +
800 + // ---- Priority ----
801 +
802 + #[test]
803 + fn priority_as_str_is_short_form() {
804 + assert_eq!(Priority::High.as_str(), "H");
805 + assert_eq!(Priority::Medium.as_str(), "M");
806 + assert_eq!(Priority::Low.as_str(), "L");
807 + }
808 +
809 + #[test]
810 + fn priority_from_str_or_default_accepts_variants() {
811 + for s in ["High", "H", "high", "h"] {
812 + assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
813 + }
814 + for s in ["Low", "L", "low", "l"] {
815 + assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
816 + }
817 + for s in ["Medium", "M", "Med", "med", "m"] {
818 + assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
819 + }
820 + }
821 +
822 + #[test]
823 + fn priority_from_str_or_default_falls_back_to_medium() {
824 + assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
825 + assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
826 + assert_eq!(Priority::default(), Priority::Medium);
827 + }
828 +
829 + #[test]
830 + fn priority_db_value_is_long_form() {
831 + assert_eq!(Priority::High.db_value(), "High");
832 + assert_eq!(Priority::Low.css_class(), "priority-low");
833 + }
834 +
835 + // ---- TaskSortColumn ----
836 +
837 + #[test]
838 + fn sort_column_parses_case_insensitively() {
839 + assert_eq!(TaskSortColumn::from_str_or_default("DUE"), TaskSortColumn::Due);
840 + assert_eq!(TaskSortColumn::from_str_or_default("Project"), TaskSortColumn::Project);
841 + assert_eq!(TaskSortColumn::from_str_or_default("priority"), TaskSortColumn::Priority);
842 + // unknown falls back to the default (Urgency)
843 + assert_eq!(TaskSortColumn::from_str_or_default("xyz"), TaskSortColumn::Urgency);
844 + assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
845 + }
846 +
847 + // ---- due_formatted ----
848 +
849 + #[test]
850 + fn due_formatted_none_is_dash() {
851 + assert_eq!(task().due_formatted(), "-");
852 + }
853 +
854 + #[test]
855 + fn due_formatted_relative_buckets() {
856 + let mut t = task();
857 +
858 + t.due = Some(Utc::now());
859 + assert_eq!(t.due_formatted(), "today");
860 +
861 + t.due = Some(Utc::now() + Duration::days(1));
862 + assert_eq!(t.due_formatted(), "tomorrow");
863 +
864 + t.due = Some(Utc::now() + Duration::days(3));
865 + assert_eq!(t.due_formatted(), "+3d");
866 +
867 + t.due = Some(Utc::now() - Duration::days(2));
868 + assert_eq!(t.due_formatted(), "2d ago");
869 + }
870 +
871 + #[test]
872 + fn due_formatted_far_future_is_iso_date() {
873 + let mut t = task();
874 + let far = Utc::now() + Duration::days(30);
875 + t.due = Some(far);
876 + assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
877 + }
878 +
879 + // ---- overdue / urgency_class ----
880 +
881 + #[test]
882 + fn is_overdue_reads_due_vs_now() {
883 + let mut t = task();
884 + assert!(!t.is_overdue(), "no due date is never overdue");
885 + t.due = Some(Utc::now() - Duration::hours(1));
886 + assert!(t.is_overdue());
887 + t.due = Some(Utc::now() + Duration::hours(1));
888 + assert!(!t.is_overdue());
889 + }
890 +
891 + #[test]
892 + fn urgency_class_thresholds() {
893 + let mut t = task();
894 + t.urgency = 9.0;
895 + assert_eq!(t.urgency_class(), "urgency-high");
896 + t.urgency = 5.0;
897 + assert_eq!(t.urgency_class(), "urgency-medium");
898 + t.urgency = 4.9;
899 + assert_eq!(t.urgency_class(), "urgency-low");
900 + }
901 +
902 + #[test]
903 + fn urgency_class_overdue_wins_over_score() {
904 + let mut t = task();
905 + t.urgency = 9.9; // would be "high"
906 + t.due = Some(Utc::now() - Duration::days(1));
907 + assert_eq!(t.urgency_class(), "urgency-overdue");
908 + }
909 +
910 + #[test]
911 + fn urgency_formatted_one_decimal() {
912 + let mut t = task();
913 + t.urgency = 8.34;
914 + assert_eq!(t.urgency_formatted(), "8.3");
915 + t.urgency = 0.0;
916 + assert_eq!(t.urgency_formatted(), "0.0");
917 + }
918 +
919 + #[test]
920 + fn due_timestamp_defaults_to_zero() {
921 + let mut t = task();
922 + assert_eq!(t.due_timestamp(), 0);
923 + let d = Utc::now();
924 + t.due = Some(d);
925 + assert_eq!(t.due_timestamp(), d.timestamp());
926 + }
927 +
928 + // ---- subtasks / annotations ----
929 +
930 + #[test]
931 + fn subtask_counts_and_progress() {
932 + let mut t = task();
933 + assert!(!t.has_subtasks());
934 + assert_eq!(t.subtasks_progress(), "0/0");
935 + t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
936 + assert!(t.has_subtasks());
937 + assert_eq!(t.subtask_count(), 3);
938 + assert_eq!(t.subtasks_completed(), 2);
939 + assert_eq!(t.subtasks_progress(), "2/3");
940 + }
941 +
942 + #[test]
943 + fn project_name_fallbacks() {
944 + let mut t = task();
945 + assert_eq!(t.project_name_or_dash(), "-");
946 + assert_eq!(t.project_name_or_empty(), "");
947 + t.project_name = Some("Website".to_string());
948 + assert_eq!(t.project_name_or_dash(), "Website");
949 + assert_eq!(t.project_name_or_empty(), "Website");
950 + }
951 +
952 + #[test]
953 + fn recurrence_and_source_flags() {
954 + let mut t = task();
955 + assert!(!t.has_recurrence());
956 + assert!(t.effective_recurrence_rule().is_none());
957 + t.recurrence = Recurrence::Weekly;
958 + assert!(t.has_recurrence());
959 + assert!(!t.has_source_email());
960 + }
961 +
962 + // ---- snooze / waiting / focus ----
963 +
964 + #[test]
965 + fn is_snoozed_only_when_future() {
966 + let mut t = task();
967 + assert!(!t.is_snoozed());
968 + t.snoozed_until = Some(Utc::now() + Duration::hours(1));
969 + assert!(t.is_snoozed());
970 + t.snoozed_until = Some(Utc::now() - Duration::hours(1));
971 + assert!(!t.is_snoozed());
972 + }
973 +
974 + #[test]
975 + fn response_overdue_requires_waiting_and_past_date() {
976 + let mut t = task();
977 + assert!(!t.is_response_overdue());
978 + // past expected date but not waiting -> false
979 + t.expected_response_date = Some(Utc::now() - Duration::days(1));
980 + assert!(!t.is_response_overdue());
981 + // waiting + past -> true
982 + t.waiting_for_response = true;
983 + assert!(t.is_waiting());
984 + assert!(t.is_response_overdue());
985 + // waiting but future -> false
986 + t.expected_response_date = Some(Utc::now() + Duration::days(1));
987 + assert!(!t.is_response_overdue());
988 + }
989 +
990 + #[test]
991 + fn is_focused_reads_flag() {
992 + let mut t = task();
993 + assert!(!t.is_focused());
994 + t.is_focus = true;
995 + assert!(t.is_focused());
996 + }
997 +
998 + // ---- time progress ----
999 +
1000 + #[test]
1001 + fn time_progress_none_without_estimate() {
1002 + assert_eq!(task().time_progress(), None);
1003 + }
1004 +
1005 + #[test]
1006 + fn time_progress_percentage_and_clamp() {
1007 + let mut t = task();
1008 + t.estimated_minutes = Some(100);
1009 + t.actual_minutes = 50;
1010 + assert_eq!(t.time_progress(), Some(50));
1011 + // clamps at 100 even when over
1012 + t.actual_minutes = 250;
1013 + assert_eq!(t.time_progress(), Some(100));
1014 + // zero estimate is treated as 0%, never divides by zero
1015 + t.estimated_minutes = Some(0);
1016 + assert_eq!(t.time_progress(), Some(0));
1017 + }
1018 +
1019 + #[test]
1020 + fn is_over_estimate_rules() {
1021 + let mut t = task();
1022 + assert!(!t.is_over_estimate(), "no estimate -> not over");
1023 + t.estimated_minutes = Some(60);
1024 + t.actual_minutes = 61;
1025 + assert!(t.is_over_estimate());
1026 + t.actual_minutes = 60;
1027 + assert!(!t.is_over_estimate(), "equal is not over");
1028 + t.estimated_minutes = Some(0);
1029 + t.actual_minutes = 5;
1030 + assert!(!t.is_over_estimate(), "zero estimate is never over");
1031 + }
1032 +
1033 + #[test]
1034 + fn has_active_timer_reads_session() {
1035 + assert!(!task().has_active_timer());
1036 + }
1037 +
1038 + // ---- NewTaskBuilder ----
1039 +
1040 + #[test]
1041 + fn builder_defaults() {
1042 + let nt = NewTask::builder("Write tests").build();
1043 + assert_eq!(nt.description, "Write tests");
1044 + assert_eq!(nt.priority, Priority::Medium);
1045 + assert_eq!(nt.urgency, 0.0);
1046 + assert_eq!(nt.recurrence, Recurrence::None);
1047 + assert!(nt.tags.is_empty());
1048 + assert!(nt.due.is_none());
1049 + assert!(nt.estimated_minutes.is_none());
1050 + }
1051 +
1052 + #[test]
1053 + fn builder_sets_fields() {
1054 + let due = Utc::now();
1055 + let nt = NewTask::builder("Fix bug")
1056 + .priority(Priority::High)
1057 + .due(due)
1058 + .tag("urgent")
1059 + .tag("backend")
1060 + .urgency(8.0)
1061 + .estimated_minutes(45)
1062 + .recurrence(Recurrence::Daily)
1063 + .build();
1064 + assert_eq!(nt.priority, Priority::High);
1065 + assert_eq!(nt.due, Some(due));
1066 + assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
1067 + assert_eq!(nt.urgency, 8.0);
1068 + assert_eq!(nt.estimated_minutes, Some(45));
1069 + assert_eq!(nt.recurrence, Recurrence::Daily);
1070 + }
1071 +
1072 + #[test]
1073 + fn builder_tags_replaces_accumulated() {
1074 + let nt = NewTask::builder("t")
1075 + .tag("a")
1076 + .tags(vec!["x".to_string(), "y".to_string()])
1077 + .build();
1078 + assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
1079 + }
1080 + }
@@ -0,0 +1,97 @@
1 + //! Integration tests for SqliteDailyNoteRepository.
2 +
3 + mod common;
4 +
5 + use chrono::NaiveDate;
6 + use goingson_core::DailyNoteRepository;
7 + use goingson_db_sqlite::SqliteDailyNoteRepository;
8 +
9 + fn date(y: i32, m: u32, d: u32) -> NaiveDate {
10 + NaiveDate::from_ymd_opt(y, m, d).unwrap()
11 + }
12 +
13 + #[tokio::test]
14 + async fn upsert_then_get_by_date_roundtrips() {
15 + let pool = common::setup_test_db().await;
16 + let user_id = common::create_test_user(&pool).await;
17 + let repo = SqliteDailyNoteRepository::new(pool.clone());
18 + let d = date(2026, 3, 15);
19 +
20 + let created = repo
21 + .upsert(user_id, d, "shipped the audit", "less context switching", false)
22 + .await
23 + .unwrap();
24 + assert_eq!(created.note_date, d);
25 + assert_eq!(created.went_well, "shipped the audit");
26 + assert_eq!(created.could_improve, "less context switching");
27 + assert!(!created.is_reviewed);
28 + assert!(created.reviewed_at.is_none());
29 +
30 + let fetched = repo.get_by_date(user_id, d).await.unwrap().unwrap();
31 + assert_eq!(fetched.id, created.id);
32 + assert_eq!(fetched.went_well, "shipped the audit");
33 + }
34 +
35 + #[tokio::test]
36 + async fn get_by_date_missing_is_none() {
37 + let pool = common::setup_test_db().await;
38 + let user_id = common::create_test_user(&pool).await;
39 + let repo = SqliteDailyNoteRepository::new(pool.clone());
40 +
41 + assert!(repo.get_by_date(user_id, date(2026, 1, 1)).await.unwrap().is_none());
42 + }
43 +
44 + #[tokio::test]
45 + async fn upsert_conflict_updates_in_place_and_preserves_id_and_created_at() {
46 + let pool = common::setup_test_db().await;
47 + let user_id = common::create_test_user(&pool).await;
48 + let repo = SqliteDailyNoteRepository::new(pool.clone());
49 + let d = date(2026, 5, 1);
50 +
51 + let first = repo.upsert(user_id, d, "a", "b", false).await.unwrap();
52 + let second = repo.upsert(user_id, d, "updated", "improved", true).await.unwrap();
53 +
54 + // Same (user, date) folds into an update, not a second row.
55 + assert_eq!(second.id, first.id, "id preserved on conflict path");
56 + assert_eq!(second.created_at, first.created_at, "created_at preserved");
57 + assert_eq!(second.went_well, "updated");
58 + assert_eq!(second.could_improve, "improved");
59 + assert!(second.is_reviewed);
60 + assert!(second.reviewed_at.is_some(), "reviewed_at set when is_reviewed");
61 +
62 + // Exactly one row exists for this user.
63 + let all = repo.list_all(user_id).await.unwrap();
64 + assert_eq!(all.len(), 1);
65 + }
66 +
67 + #[tokio::test]
68 + async fn list_all_is_ordered_by_note_date_ascending() {
69 + let pool = common::setup_test_db().await;
70 + let user_id = common::create_test_user(&pool).await;
71 + let repo = SqliteDailyNoteRepository::new(pool.clone());
72 +
73 + repo.upsert(user_id, date(2026, 3, 10), "c", "", false).await.unwrap();
74 + repo.upsert(user_id, date(2026, 3, 1), "a", "", false).await.unwrap();
75 + repo.upsert(user_id, date(2026, 3, 5), "b", "", false).await.unwrap();
76 +
77 + let all = repo.list_all(user_id).await.unwrap();
78 + let dates: Vec<NaiveDate> = all.iter().map(|n| n.note_date).collect();
79 + assert_eq!(dates, vec![date(2026, 3, 1), date(2026, 3, 5), date(2026, 3, 10)]);
80 + }
81 +
82 + #[tokio::test]
83 + async fn list_all_is_scoped_per_user() {
84 + let pool = common::setup_test_db().await;
85 + let user_a = common::create_test_user(&pool).await;
86 + let user_b = common::create_test_user(&pool).await;
87 + let repo = SqliteDailyNoteRepository::new(pool.clone());
88 +
89 + repo.upsert(user_a, date(2026, 2, 1), "a-note", "", false).await.unwrap();
90 + repo.upsert(user_b, date(2026, 2, 1), "b-note", "", false).await.unwrap();
91 +
92 + let a = repo.list_all(user_a).await.unwrap();
93 + assert_eq!(a.len(), 1);
94 + assert_eq!(a[0].went_well, "a-note");
95 + // user_b's identically-dated note does not leak into user_a's listing.
96 + assert!(repo.get_by_date(user_a, date(2026, 2, 1)).await.unwrap().is_some());
97 + }
@@ -0,0 +1,133 @@
1 + //! Integration tests for SqliteSyncAccountRepository.
2 +
3 + mod common;
4 +
5 + use goingson_core::SyncAccountRepository;
6 + use goingson_db_sqlite::SqliteSyncAccountRepository;
7 +
8 + #[tokio::test]
9 + async fn create_applies_schema_defaults() {
10 + let pool = common::setup_test_db().await;
11 + let user_id = common::create_test_user(&pool).await;
12 + let repo = SqliteSyncAccountRepository::new(pool.clone());
13 +
14 + let acct = repo
15 + .create(user_id, "google", "Work Calendar", Some("me@example.com"))
16 + .await
17 + .unwrap();
18 +
19 + assert_eq!(acct.provider, "google");
20 + assert_eq!(acct.account_name, "Work Calendar");
21 + assert_eq!(acct.email.as_deref(), Some("me@example.com"));
22 + // Defaults from migration 037.
23 + assert!(acct.sync_calendars);
24 + assert!(acct.sync_contacts);
25 + assert!(acct.enabled);
26 + assert_eq!(acct.sync_interval_minutes, 15);
27 + assert!(acct.calendar_ids.is_empty(), "calendar_ids defaults to '[]'");
28 + assert!(acct.last_calendar_sync.is_none());
29 + }
30 +
31 + #[tokio::test]
32 + async fn create_allows_null_email() {
33 + let pool = common::setup_test_db().await;
34 + let user_id = common::create_test_user(&pool).await;
35 + let repo = SqliteSyncAccountRepository::new(pool.clone());
36 +
37 + let acct = repo.create(user_id, "caldav", "Personal", None).await.unwrap();
38 + assert!(acct.email.is_none());
39 +
40 + let fetched = repo.get_by_id(acct.id, user_id).await.unwrap().unwrap();
41 + assert!(fetched.email.is_none());
42 + }
43 +
44 + #[tokio::test]
45 + async fn get_by_id_missing_is_none() {
46 + let pool = common::setup_test_db().await;
47 + let user_id = common::create_test_user(&pool).await;
48 + let repo = SqliteSyncAccountRepository::new(pool.clone());
49 +
50 + let missing = goingson_core::SyncAccountId::new();
51 + assert!(repo.get_by_id(missing, user_id).await.unwrap().is_none());
52 + }
53 +
54 + #[tokio::test]
55 + async fn update_changes_fields_and_returns_row() {
56 + let pool = common::setup_test_db().await;
57 + let user_id = common::create_test_user(&pool).await;
58 + let repo = SqliteSyncAccountRepository::new(pool.clone());
59 +
60 + let acct = repo.create(user_id, "google", "Old Name", None).await.unwrap();
61 + let updated = repo
62 + .update(acct.id, user_id, "New Name", false, false, false)
63 + .await
64 + .unwrap()
65 + .expect("existing account updates to Some");
66 +
67 + assert_eq!(updated.account_name, "New Name");
68 + assert!(!updated.sync_calendars);
69 + assert!(!updated.sync_contacts);
70 + assert!(!updated.enabled);
71 + assert_eq!(updated.id, acct.id);
72 + }
73 +
74 + #[tokio::test]
75 + async fn update_unknown_id_returns_none() {
76 + let pool = common::setup_test_db().await;
77 + let user_id = common::create_test_user(&pool).await;
78 + let repo = SqliteSyncAccountRepository::new(pool.clone());
79 +
80 + let missing = goingson_core::SyncAccountId::new();
81 + let result = repo
82 + .update(missing, user_id, "x", true, true, true)
83 + .await
84 + .unwrap();
85 + assert!(result.is_none(), "no rows affected -> None");
86 + }
87 +
88 + #[tokio::test]
89 + async fn delete_removes_row_and_reports_hit() {
90 + let pool = common::setup_test_db().await;
91 + let user_id = common::create_test_user(&pool).await;
92 + let repo = SqliteSyncAccountRepository::new(pool.clone());
93 +
94 + let acct = repo.create(user_id, "google", "Temp", None).await.unwrap();
95 + assert!(repo.delete(acct.id, user_id).await.unwrap(), "first delete hits");
96 + assert!(repo.get_by_id(acct.id, user_id).await.unwrap().is_none());
97 + assert!(!repo.delete(acct.id, user_id).await.unwrap(), "second delete is a miss");
98 + }
99 +
100 + #[tokio::test]
101 + async fn list_all_scoped_per_user_and_ordered_by_created_at() {
102 + let pool = common::setup_test_db().await;
103 + let user_a = common::create_test_user(&pool).await;
104 + let user_b = common::create_test_user(&pool).await;
105 + let repo = SqliteSyncAccountRepository::new(pool.clone());
106 +
107 + let first = repo.create(user_a, "google", "First", None).await.unwrap();
108 + let second = repo.create(user_a, "caldav", "Second", None).await.unwrap();
109 + repo.create(user_b, "google", "Other User", None).await.unwrap();
110 +
111 + let a = repo.list_all(user_a).await.unwrap();
112 + assert_eq!(a.len(), 2, "only user_a's accounts");
113 + // Ordered by created_at ASC (first created first).
114 + assert_eq!(a[0].id, first.id);
115 + assert_eq!(a[1].id, second.id);
116 + }
117 +
118 + #[tokio::test]
119 + async fn cross_user_access_is_denied() {
120 + let pool = common::setup_test_db().await;
121 + let owner = common::create_test_user(&pool).await;
122 + let intruder = common::create_test_user(&pool).await;
123 + let repo = SqliteSyncAccountRepository::new(pool.clone());
124 +
125 + let acct = repo.create(owner, "google", "Owned", None).await.unwrap();
126 +
127 + // Another user cannot read, update, or delete it.
128 + assert!(repo.get_by_id(acct.id, intruder).await.unwrap().is_none());
129 + assert!(repo.update(acct.id, intruder, "hijack", true, true, true).await.unwrap().is_none());
130 + assert!(!repo.delete(acct.id, intruder).await.unwrap());
131 + // Still intact for the owner.
132 + assert!(repo.get_by_id(acct.id, owner).await.unwrap().is_some());
133 + }