Skip to main content

max / goingson

Testing + arch cold spots: +54 tests, import_external repo refactor, milestone/FTS fixes Tests (+54): user_repo auth (Argon2), stats_repo, backup_settings_repo, task_repo_state focus/scheduling, milestone/saved_views/stats/search commands, sync HLC monotonicity/idempotency/clock-poison/bind_json_value Arch: ContactRepository/EventRepository set_external_ref; import_external.rs drops raw SQL and swallowed ics dedup error Fixes surfaced by tests: MilestoneStatus lowercase db_value now round-trips (was reverting Completed->Open on read); FTS task search excludes soft-deleted tasks Cleanup: strip [timer] debug console.log from time-tracking.js Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 18:02 UTC
Commit: f902cecdc737ad9ae7138a1451a4b41cd59f60c0
Parent: ea9ba85
17 files changed, +1759 insertions, -24 deletions
@@ -9,7 +9,11 @@ use super::shared::{CssClass, DbValue, ParseableEnum};
9 9 // ============ Milestones ============
10 10
11 11 /// Lifecycle status of a milestone.
12 + // `ascii_case_insensitive` so the lowercase `db_value()` form ("open"/"completed")
13 + // round-trips back through `from_str_or_default`; without it a stored "completed"
14 + // failed to parse and silently reverted to `Open` on every read.
12 15 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)]
16 + #[strum(ascii_case_insensitive)]
13 17 pub enum MilestoneStatus {
14 18 /// Milestone is still being worked toward.
15 19 #[strum(serialize = "Open")]
@@ -84,3 +88,23 @@ pub struct NewMilestone {
84 88 pub position: i32,
85 89 pub target_date: Option<chrono::NaiveDate>,
86 90 }
91 +
92 + #[cfg(test)]
93 + mod tests {
94 + use super::*;
95 +
96 + #[test]
97 + fn milestone_status_db_value_round_trips() {
98 + // Regression: the lowercase db_value form must parse back to the same
99 + // variant (was reverting Completed -> Open on read).
100 + for status in [MilestoneStatus::Open, MilestoneStatus::Completed] {
101 + let stored = status.db_value();
102 + assert_eq!(MilestoneStatus::from_str_or_default(stored), status);
103 + }
104 + // The capitalized display form still parses too.
105 + assert_eq!(
106 + MilestoneStatus::from_str_or_default("Completed"),
107 + MilestoneStatus::Completed
108 + );
109 + }
110 + }
@@ -285,6 +285,16 @@ pub trait EventRepository: Send + Sync {
285 285 /// Deletes an event.
286 286 async fn delete(&self, id: EventId, user_id: UserId) -> Result<bool>;
287 287
288 + /// Records the external source/id for an event (e.g. after an iCal import),
289 + /// used to dedup on re-import.
290 + async fn set_external_ref(
291 + &self,
292 + id: EventId,
293 + user_id: UserId,
294 + source: &str,
295 + external_id: &str,
296 + ) -> Result<()>;
297 +
288 298 /// Deletes multiple events by ID, returning the number deleted.
289 299 async fn delete_many(&self, ids: &[EventId], user_id: UserId) -> Result<u64>;
290 300
@@ -968,6 +978,16 @@ pub trait ContactRepository: Send + Sync {
968 978 /// Deletes a contact (CASCADE removes sub-entities), returning `true` if deleted.
969 979 async fn delete(&self, id: ContactId, user_id: UserId) -> Result<bool>;
970 980
981 + /// Records the external source/id for a contact (e.g. after a vCard import),
982 + /// used to dedup on re-import.
983 + async fn set_external_ref(
984 + &self,
985 + id: ContactId,
986 + user_id: UserId,
987 + source: &str,
988 + external_id: &str,
989 + ) -> Result<()>;
990 +
971 991 /// Deletes multiple contacts by ID, returning the number deleted.
972 992 async fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64>;
973 993
@@ -469,6 +469,28 @@ impl ContactRepository for SqliteContactRepository {
469 469 }
470 470
471 471 #[tracing::instrument(skip_all)]
472 + async fn set_external_ref(
473 + &self,
474 + id: ContactId,
475 + user_id: UserId,
476 + source: &str,
477 + external_id: &str,
478 + ) -> Result<()> {
479 + sqlx::query(
480 + "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ? AND user_id = ?",
481 + )
482 + .bind(source)
483 + .bind(external_id)
484 + .bind(id.to_string())
485 + .bind(user_id.to_string())
486 + .execute(&self.pool)
487 + .await
488 + .map_err(CoreError::database)?;
489 +
490 + Ok(())
491 + }
492 +
493 + #[tracing::instrument(skip_all)]
472 494 async fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64> {
473 495 if ids.is_empty() {
474 496 return Ok(0);
@@ -292,6 +292,28 @@ impl EventRepository for SqliteEventRepository {
292 292 }
293 293
294 294 #[tracing::instrument(skip_all)]
295 + async fn set_external_ref(
296 + &self,
297 + id: EventId,
298 + user_id: UserId,
299 + source: &str,
300 + external_id: &str,
301 + ) -> Result<()> {
302 + sqlx::query(
303 + "UPDATE events SET external_source = ?, external_id = ? WHERE id = ? AND user_id = ?",
304 + )
305 + .bind(source)
306 + .bind(external_id)
307 + .bind(id.to_string())
308 + .bind(user_id.to_string())
309 + .execute(&self.pool)
310 + .await
311 + .map_err(CoreError::database)?;
312 +
313 + Ok(())
314 + }
315 +
316 + #[tracing::instrument(skip_all)]
295 317 async fn delete_many(&self, ids: &[EventId], user_id: UserId) -> Result<u64> {
296 318 if ids.is_empty() {
297 319 return Ok(0);
@@ -278,6 +278,7 @@ async fn search_tasks_fts(
278 278 LEFT JOIN projects p ON t.project_id = p.id
279 279 WHERE tasks_fts MATCH $1
280 280 AND tasks_fts.user_id = $2
281 + AND t.status != 'Deleted'
281 282 "#
282 283 .to_string(),
283 284 true,
@@ -0,0 +1,120 @@
1 + //! Integration tests for SqliteBackupSettingsRepository.
2 +
3 + mod common;
4 +
5 + use chrono::{Duration, Utc};
6 + use goingson_core::{BackupSettingsRepository, NewBackupSettings};
7 + use goingson_db_sqlite::SqliteBackupSettingsRepository;
8 +
9 + #[tokio::test]
10 + async fn test_get_returns_none_when_absent() {
11 + let pool = common::setup_test_db().await;
12 + let user_id = common::create_test_user(&pool).await;
13 + let repo = SqliteBackupSettingsRepository::new(pool);
14 +
15 + let settings = repo.get(user_id).await.expect("get");
16 + assert!(settings.is_none(), "no row should exist for a fresh user");
17 + }
18 +
19 + #[tokio::test]
20 + async fn test_upsert_inserts_then_get_round_trips() {
21 + let pool = common::setup_test_db().await;
22 + let user_id = common::create_test_user(&pool).await;
23 + let repo = SqliteBackupSettingsRepository::new(pool);
24 +
25 + let created = repo
26 + .upsert(user_id, NewBackupSettings {
27 + auto_backup_enabled: true,
28 + backup_frequency_minutes: 30,
29 + max_backups_to_keep: 5,
30 + })
31 + .await
32 + .expect("upsert insert");
33 +
34 + assert!(created.auto_backup_enabled);
35 + assert_eq!(created.backup_frequency_minutes, 30);
36 + assert_eq!(created.max_backups_to_keep, 5);
37 + assert!(created.last_backup_at.is_none(), "a new row has no backup timestamp yet");
38 +
39 + let fetched = repo.get(user_id).await.expect("get").expect("row should exist after upsert");
40 + assert_eq!(fetched.id, created.id, "get must return the same persisted row");
41 + assert!(fetched.auto_backup_enabled);
42 + assert_eq!(fetched.backup_frequency_minutes, 30);
43 + assert_eq!(fetched.max_backups_to_keep, 5);
44 + }
45 +
46 + #[tokio::test]
47 + async fn test_upsert_updates_existing_row_in_place() {
48 + let pool = common::setup_test_db().await;
49 + let user_id = common::create_test_user(&pool).await;
50 + let repo = SqliteBackupSettingsRepository::new(pool);
51 +
52 + let first = repo
53 + .upsert(user_id, NewBackupSettings {
54 + auto_backup_enabled: true,
55 + backup_frequency_minutes: 15,
56 + max_backups_to_keep: 1,
57 + })
58 + .await
59 + .expect("first upsert");
60 +
61 + let second = repo
62 + .upsert(user_id, NewBackupSettings {
63 + auto_backup_enabled: false,
64 + backup_frequency_minutes: 120,
65 + max_backups_to_keep: 10,
66 + })
67 + .await
68 + .expect("second upsert");
69 +
70 + assert_eq!(second.id, first.id, "upsert must update in place, not create a second row");
71 + assert!(!second.auto_backup_enabled);
72 + assert_eq!(second.backup_frequency_minutes, 120);
73 + assert_eq!(second.max_backups_to_keep, 10);
74 +
75 + let fetched = repo.get(user_id).await.expect("get").expect("row exists");
76 + assert!(!fetched.auto_backup_enabled, "the updated values must persist");
77 + assert_eq!(fetched.backup_frequency_minutes, 120);
78 + assert_eq!(fetched.max_backups_to_keep, 10);
79 + }
80 +
81 + #[tokio::test]
82 + async fn test_update_last_backup_at_reflected_in_get() {
83 + let pool = common::setup_test_db().await;
84 + let user_id = common::create_test_user(&pool).await;
85 + let repo = SqliteBackupSettingsRepository::new(pool);
86 +
87 + repo.upsert(user_id, NewBackupSettings {
88 + auto_backup_enabled: true,
89 + backup_frequency_minutes: 15,
90 + max_backups_to_keep: 1,
91 + })
92 + .await
93 + .expect("upsert");
94 +
95 + let ts = Utc::now() - Duration::minutes(3);
96 + repo.update_last_backup_at(user_id, ts).await.expect("update_last_backup_at");
97 +
98 + let fetched = repo.get(user_id).await.expect("get").expect("row exists");
99 + let stored = fetched.last_backup_at.expect("last_backup_at must be set");
100 + assert!((stored - ts).num_seconds().abs() <= 1, "stored backup time must match the value written (within second precision)");
101 + }
102 +
103 + #[tokio::test]
104 + async fn test_update_last_backup_at_creates_defaults_when_absent() {
105 + let pool = common::setup_test_db().await;
106 + let user_id = common::create_test_user(&pool).await;
107 + let repo = SqliteBackupSettingsRepository::new(pool);
108 +
109 + // No prior upsert: the update must lazily create default settings.
110 + assert!(repo.get(user_id).await.expect("get").is_none());
111 +
112 + let ts = Utc::now();
113 + repo.update_last_backup_at(user_id, ts).await.expect("update_last_backup_at");
114 +
115 + let fetched = repo.get(user_id).await.expect("get").expect("defaults should have been created");
116 + assert!(fetched.auto_backup_enabled, "lazy default enables auto backup");
117 + assert_eq!(fetched.backup_frequency_minutes, 15, "lazy default frequency");
118 + assert_eq!(fetched.max_backups_to_keep, 1, "lazy default retention");
119 + assert!(fetched.last_backup_at.is_some(), "the backup timestamp must be recorded");
120 + }
@@ -0,0 +1,264 @@
1 + //! Integration tests for SqliteTaskRepository focus + scheduling + reporting methods.
2 +
3 + mod common;
4 +
5 + use chrono::{Duration, TimeZone, Utc};
6 + use goingson_core::{NewTask, Priority, TaskRepository};
7 + use goingson_db_sqlite::SqliteTaskRepository;
8 +
9 + // ---- Focus ----
10 +
11 + #[tokio::test]
12 + async fn test_set_focus_and_list_focused() {
13 + let pool = common::setup_test_db().await;
14 + let user_id = common::create_test_user(&pool).await;
15 + let repo = SqliteTaskRepository::new(pool);
16 +
17 + let task = repo.create(user_id, NewTask::builder("Focus me").build()).await.unwrap();
18 +
19 + // Not focused initially.
20 + assert!(repo.list_focused(user_id).await.unwrap().is_empty());
21 +
22 + let focused = repo.set_focus(task.id, user_id, true).await.expect("set focus").unwrap();
23 + assert!(focused.is_focus);
24 + assert!(focused.focus_set_at.is_some(), "focus_set_at should be stamped");
25 +
26 + let list = repo.list_focused(user_id).await.unwrap();
27 + assert_eq!(list.len(), 1);
28 + assert_eq!(list[0].id, task.id);
29 +
30 + // Clearing focus removes it from the set.
31 + let unfocused = repo.set_focus(task.id, user_id, false).await.expect("clear focus").unwrap();
32 + assert!(!unfocused.is_focus);
33 + assert!(unfocused.focus_set_at.is_none(), "focus_set_at should be nulled");
34 + assert!(repo.list_focused(user_id).await.unwrap().is_empty());
35 + }
36 +
37 + #[tokio::test]
38 + async fn test_clear_all_focus() {
39 + let pool = common::setup_test_db().await;
40 + let user_id = common::create_test_user(&pool).await;
41 + let repo = SqliteTaskRepository::new(pool);
42 +
43 + let a = repo.create(user_id, NewTask::builder("A").build()).await.unwrap();
44 + let b = repo.create(user_id, NewTask::builder("B").build()).await.unwrap();
45 + repo.set_focus(a.id, user_id, true).await.unwrap();
46 + repo.set_focus(b.id, user_id, true).await.unwrap();
47 + assert_eq!(repo.list_focused(user_id).await.unwrap().len(), 2);
48 +
49 + let cleared = repo.clear_all_focus(user_id).await.expect("clear all");
50 + assert_eq!(cleared, 2, "both focused tasks should be cleared");
51 + assert!(repo.list_focused(user_id).await.unwrap().is_empty());
52 +
53 + // A second call clears nothing.
54 + assert_eq!(repo.clear_all_focus(user_id).await.unwrap(), 0);
55 + }
56 +
57 + #[tokio::test]
58 + async fn test_focus_is_per_user_scoped() {
59 + let pool = common::setup_test_db().await;
60 + let user_a = common::create_test_user(&pool).await;
61 + let user_b = common::create_test_user(&pool).await;
62 + let repo = SqliteTaskRepository::new(pool);
63 +
64 + let task_a = repo.create(user_a, NewTask::builder("A's task").build()).await.unwrap();
65 + let task_b = repo.create(user_b, NewTask::builder("B's task").build()).await.unwrap();
66 + repo.set_focus(task_a.id, user_a, true).await.unwrap();
67 + repo.set_focus(task_b.id, user_b, true).await.unwrap();
68 +
69 + // Each user only sees their own focused task.
70 + let a_list = repo.list_focused(user_a).await.unwrap();
71 + assert_eq!(a_list.len(), 1);
72 + assert_eq!(a_list[0].id, task_a.id);
73 +
74 + // Clearing all focus for A must not touch B.
75 + repo.clear_all_focus(user_a).await.unwrap();
76 + assert!(repo.list_focused(user_a).await.unwrap().is_empty());
77 + let b_list = repo.list_focused(user_b).await.unwrap();
78 + assert_eq!(b_list.len(), 1, "clearing A's focus must not affect B");
79 + assert_eq!(b_list[0].id, task_b.id);
80 + }
81 +
82 + #[tokio::test]
83 + async fn test_list_available_for_focus_candidate_set() {
84 + let pool = common::setup_test_db().await;
85 + let user_id = common::create_test_user(&pool).await;
86 + let repo = SqliteTaskRepository::new(pool);
87 +
88 + // Candidate: pending, not focused, not snoozed, not waiting.
89 + let candidate = repo.create(user_id, NewTask::builder("Candidate").priority(Priority::High).build()).await.unwrap();
90 +
91 + // Excluded: already focused.
92 + let focused = repo.create(user_id, NewTask::builder("Focused").build()).await.unwrap();
93 + repo.set_focus(focused.id, user_id, true).await.unwrap();
94 +
95 + // Excluded: snoozed into the future.
96 + let snoozed = repo.create(user_id, NewTask::builder("Snoozed").build()).await.unwrap();
97 + repo.snooze(snoozed.id, user_id, Utc::now() + Duration::hours(2)).await.unwrap();
98 +
99 + // Excluded: waiting for response.
100 + let waiting = repo.create(user_id, NewTask::builder("Waiting").build()).await.unwrap();
101 + repo.mark_waiting(waiting.id, user_id, None).await.unwrap();
102 +
103 + // Excluded: completed.
104 + let done = repo.create(user_id, NewTask::builder("Done").build()).await.unwrap();
105 + repo.complete(done.id, user_id).await.unwrap();
106 +
107 + let available = repo.list_available_for_focus(user_id, 100).await.expect("list available");
108 + let ids: Vec<_> = available.iter().map(|t| t.id).collect();
109 + assert_eq!(ids, vec![candidate.id], "only the pending unfocused task should be a focus candidate");
110 + }
111 +
112 + // ---- Scheduling ----
113 +
114 + #[tokio::test]
115 + async fn test_update_schedule_persists_and_clears() {
116 + let pool = common::setup_test_db().await;
117 + let user_id = common::create_test_user(&pool).await;
118 + let repo = SqliteTaskRepository::new(pool);
119 +
120 + let task = repo.create(user_id, NewTask::builder("Schedule me").build()).await.unwrap();
121 + assert!(task.scheduled_start.is_none());
122 + assert!(task.scheduled_duration.is_none());
123 +
124 + let start = Utc.with_ymd_and_hms(2026, 8, 15, 14, 0, 0).unwrap();
125 + let updated = repo.update_schedule(task.id, user_id, Some(start), Some(90)).await.expect("schedule").unwrap();
126 + assert_eq!(updated.scheduled_start, Some(start));
127 + assert_eq!(updated.scheduled_duration, Some(90));
128 +
129 + // Re-fetch to confirm persistence.
130 + let fetched = repo.get_by_id(task.id, user_id).await.unwrap().unwrap();
131 + assert_eq!(fetched.scheduled_start, Some(start));
132 + assert_eq!(fetched.scheduled_duration, Some(90));
133 +
134 + // Passing None clears the schedule.
135 + let cleared = repo.update_schedule(task.id, user_id, None, None).await.expect("clear schedule").unwrap();
136 + assert!(cleared.scheduled_start.is_none());
137 + assert!(cleared.scheduled_duration.is_none());
138 + let refetched = repo.get_by_id(task.id, user_id).await.unwrap().unwrap();
139 + assert!(refetched.scheduled_start.is_none());
140 + assert!(refetched.scheduled_duration.is_none());
141 + }
142 +
143 + #[tokio::test]
144 + async fn test_list_scheduled_for_date() {
145 + let pool = common::setup_test_db().await;
146 + let user_id = common::create_test_user(&pool).await;
147 + let repo = SqliteTaskRepository::new(pool);
148 +
149 + let on_date = repo.create(user_id, NewTask::builder("On date").build()).await.unwrap();
150 + let off_date = repo.create(user_id, NewTask::builder("Off date").build()).await.unwrap();
151 + let _unscheduled = repo.create(user_id, NewTask::builder("Unscheduled").build()).await.unwrap();
152 +
153 + let target = Utc.with_ymd_and_hms(2026, 8, 15, 9, 30, 0).unwrap();
154 + let other = Utc.with_ymd_and_hms(2026, 8, 16, 9, 30, 0).unwrap();
155 + repo.update_schedule(on_date.id, user_id, Some(target), Some(60)).await.unwrap();
156 + repo.update_schedule(off_date.id, user_id, Some(other), Some(60)).await.unwrap();
157 +
158 + let date = chrono::NaiveDate::from_ymd_opt(2026, 8, 15).unwrap();
159 + let scheduled = repo.list_scheduled_for_date(user_id, date).await.expect("list scheduled");
160 + let ids: Vec<_> = scheduled.iter().map(|t| t.id).collect();
161 + assert_eq!(ids, vec![on_date.id], "only the task scheduled on the target date should match");
162 + }
163 +
164 + #[tokio::test]
165 + async fn test_list_scheduled_for_date_excludes_completed() {
166 + let pool = common::setup_test_db().await;
167 + let user_id = common::create_test_user(&pool).await;
168 + let repo = SqliteTaskRepository::new(pool);
169 +
170 + let task = repo.create(user_id, NewTask::builder("Scheduled then done").build()).await.unwrap();
171 + let start = Utc.with_ymd_and_hms(2026, 8, 15, 9, 30, 0).unwrap();
172 + repo.update_schedule(task.id, user_id, Some(start), Some(60)).await.unwrap();
173 +
174 + let date = chrono::NaiveDate::from_ymd_opt(2026, 8, 15).unwrap();
175 + assert_eq!(repo.list_scheduled_for_date(user_id, date).await.unwrap().len(), 1);
176 +
177 + repo.complete(task.id, user_id).await.unwrap();
178 + assert!(
179 + repo.list_scheduled_for_date(user_id, date).await.unwrap().is_empty(),
180 + "completed tasks should not appear in the scheduled-for-date list"
181 + );
182 + }
183 +
184 + // ---- Reporting windows ----
185 +
186 + #[tokio::test]
187 + async fn test_list_due_between_inclusive_boundaries() {
188 + let pool = common::setup_test_db().await;
189 + let user_id = common::create_test_user(&pool).await;
190 + let repo = SqliteTaskRepository::new(pool);
191 +
192 + let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
193 + let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
194 +
195 + let at_start = repo.create(user_id, NewTask::builder("At start").due(start).build()).await.unwrap();
196 + let at_end = repo.create(user_id, NewTask::builder("At end").due(end).build()).await.unwrap();
197 + let inside = repo.create(user_id, NewTask::builder("Inside").due(Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap()).build()).await.unwrap();
198 + let _before = repo.create(user_id, NewTask::builder("Before").due(start - Duration::seconds(1)).build()).await.unwrap();
199 + let _after = repo.create(user_id, NewTask::builder("After").due(end + Duration::seconds(1)).build()).await.unwrap();
200 +
201 + let found = repo.list_due_between(user_id, start, end).await.expect("list due between");
202 + let ids: Vec<_> = found.iter().map(|t| t.id).collect();
203 + assert_eq!(ids.len(), 3, "window is inclusive on both bounds and excludes tasks just outside");
204 + for want in [at_start.id, at_end.id, inside.id] {
205 + assert!(ids.contains(&want), "expected task {want} in window");
206 + }
207 + }
208 +
209 + #[tokio::test]
210 + async fn test_list_due_between_excludes_completed() {
211 + let pool = common::setup_test_db().await;
212 + let user_id = common::create_test_user(&pool).await;
213 + let repo = SqliteTaskRepository::new(pool);
214 +
215 + let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
216 + let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
217 +
218 + let task = repo.create(user_id, NewTask::builder("Due and done").due(Utc.with_ymd_and_hms(2026, 9, 10, 0, 0, 0).unwrap()).build()).await.unwrap();
219 + assert_eq!(repo.list_due_between(user_id, start, end).await.unwrap().len(), 1);
220 + repo.complete(task.id, user_id).await.unwrap();
221 + assert!(repo.list_due_between(user_id, start, end).await.unwrap().is_empty());
222 + }
223 +
224 + #[tokio::test]
225 + async fn test_list_became_overdue_between_window() {
226 + let pool = common::setup_test_db().await;
227 + let user_id = common::create_test_user(&pool).await;
228 + let repo = SqliteTaskRepository::new(pool);
229 +
230 + let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
231 + let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
232 +
233 + let inside = repo.create(user_id, NewTask::builder("Overdue inside").due(Utc.with_ymd_and_hms(2026, 9, 15, 8, 0, 0).unwrap()).build()).await.unwrap();
234 + let _outside = repo.create(user_id, NewTask::builder("Overdue outside").due(end + Duration::seconds(1)).build()).await.unwrap();
235 + // Completed tasks are excluded even if due in range.
236 + let done = repo.create(user_id, NewTask::builder("Overdue done").due(Utc.with_ymd_and_hms(2026, 9, 20, 8, 0, 0).unwrap()).build()).await.unwrap();
237 + repo.complete(done.id, user_id).await.unwrap();
238 +
239 + let found = repo.list_became_overdue_between(user_id, start, end).await.expect("list overdue between");
240 + let ids: Vec<_> = found.iter().map(|t| t.id).collect();
241 + assert_eq!(ids, vec![inside.id], "only the non-completed task due inside the window should match");
242 + }
243 +
244 + #[tokio::test]
245 + async fn test_list_created_between_window() {
246 + let pool = common::setup_test_db().await;
247 + let user_id = common::create_test_user(&pool).await;
248 + let repo = SqliteTaskRepository::new(pool);
249 +
250 + let a = repo.create(user_id, NewTask::builder("Created one").build()).await.unwrap();
251 + let b = repo.create(user_id, NewTask::builder("Created two").build()).await.unwrap();
252 +
253 + // A window around now includes both freshly created tasks.
254 + let start = Utc::now() - Duration::minutes(5);
255 + let end = Utc::now() + Duration::minutes(5);
256 + let found = repo.list_created_between(user_id, start, end).await.expect("list created between");
257 + let ids: Vec<_> = found.iter().map(|t| t.id).collect();
258 + assert!(ids.contains(&a.id) && ids.contains(&b.id), "both tasks created inside the window should match");
259 +
260 + // A past window matches nothing.
261 + let old_start = Utc::now() - Duration::days(30);
262 + let old_end = Utc::now() - Duration::days(29);
263 + assert!(repo.list_created_between(user_id, old_start, old_end).await.unwrap().is_empty());
264 + }
@@ -0,0 +1,203 @@
1 + //! Integration tests for SqliteStatsRepository.
2 +
3 + mod common;
4 +
5 + use chrono::{Duration, Utc};
6 + use goingson_core::{
7 + EmailRepository, EventRepository, NewEmail, NewEvent, NewProject, NewTask, Priority,
8 + ProjectRepository, ProjectStatus, ProjectType, Recurrence, StatsRepository, TaskRepository,
9 + };
10 + use goingson_db_sqlite::{
11 + SqliteEmailRepository, SqliteEventRepository, SqliteProjectRepository, SqliteStatsRepository,
12 + SqliteTaskRepository,
13 + };
14 +
15 + #[tokio::test]
16 + async fn test_dashboard_stats_empty_db_is_all_zeros() {
17 + let pool = common::setup_test_db().await;
18 + let user_id = common::create_test_user(&pool).await;
19 + let repo = SqliteStatsRepository::new(pool);
20 +
21 + let now = Utc::now();
22 + let stats = repo
23 + .get_dashboard_stats(user_id, now, now - Duration::hours(1), now + Duration::hours(12), now + Duration::days(7))
24 + .await
25 + .expect("get_dashboard_stats");
26 +
27 + assert_eq!(stats.tasks_due_today, 0);
28 + assert_eq!(stats.tasks_due_this_week, 0);
29 + assert_eq!(stats.overdue_count, 0);
30 + assert_eq!(stats.unread_emails, 0);
31 + assert_eq!(stats.upcoming_events, 0);
32 + assert_eq!(stats.active_projects, 0);
33 + assert!(stats.high_urgency_tasks.is_empty());
34 + }
35 +
36 + #[tokio::test]
37 + async fn test_dashboard_stats_populated_counts_match() {
38 + let pool = common::setup_test_db().await;
39 + let user_id = common::create_test_user(&pool).await;
40 +
41 + let tasks = SqliteTaskRepository::new(pool.clone());
42 + let events = SqliteEventRepository::new(pool.clone());
43 + let emails = SqliteEmailRepository::new(pool.clone());
44 + let projects = SqliteProjectRepository::new(pool.clone());
45 + let stats_repo = SqliteStatsRepository::new(pool);
46 +
47 + let now = Utc::now();
48 + let today_start = now - Duration::hours(1);
49 + let tomorrow_start = now + Duration::hours(12);
50 + let week_end = now + Duration::days(7);
51 +
52 + // Task due later today (inside [today_start, tomorrow_start)).
53 + tasks
54 + .create(user_id, NewTask::builder("Due today").priority(Priority::High).due(now + Duration::hours(2)).build())
55 + .await
56 + .expect("create today task");
57 + // Task due in three days: this week but not today.
58 + tasks
59 + .create(user_id, NewTask::builder("Due this week").priority(Priority::Medium).due(now + Duration::days(3)).build())
60 + .await
61 + .expect("create week task");
62 + // Overdue task.
63 + tasks
64 + .create(user_id, NewTask::builder("Overdue").priority(Priority::Low).due(now - Duration::days(2)).build())
65 + .await
66 + .expect("create overdue task");
67 + // Completed task is excluded from every task count and from high-urgency.
68 + let done = tasks
69 + .create(user_id, NewTask::builder("Already done").priority(Priority::High).due(now + Duration::hours(3)).build())
70 + .await
71 + .expect("create done task");
72 + tasks.complete(done.id, user_id).await.expect("complete");
73 +
74 + // One unread email (counted) and one read email (not counted).
75 + emails
76 + .create(user_id, NewEmail {
77 + project_id: None,
78 + from_address: "a@example.com".into(),
79 + to_address: "me@example.com".into(),
80 + subject: "Unread".into(),
81 + body: "body".into(),
82 + is_read: false,
83 + received_at: Some(now),
84 + })
85 + .await
86 + .expect("create unread email");
87 + let read = emails
88 + .create(user_id, NewEmail {
89 + project_id: None,
90 + from_address: "b@example.com".into(),
91 + to_address: "me@example.com".into(),
92 + subject: "Read".into(),
93 + body: "body".into(),
94 + is_read: true,
95 + received_at: Some(now),
96 + })
97 + .await
98 + .expect("create read email");
99 + assert!(read.is_read);
100 +
101 + // One upcoming event (within 7 days) and one far-future event (excluded).
102 + events
103 + .create(user_id, NewEvent {
104 + user_id: Some(user_id),
105 + project_id: None,
106 + contact_id: None,
107 + title: "Upcoming".into(),
108 + description: String::new(),
109 + start_time: now + Duration::days(1),
110 + end_time: None,
111 + location: None,
112 + linked_task_id: None,
113 + recurrence: Recurrence::None,
114 + recurrence_rule: None,
115 + block_type: None,
116 + reminder_offsets_seconds: Vec::new(),
117 + })
118 + .await
119 + .expect("create upcoming event");
120 + events
121 + .create(user_id, NewEvent {
122 + user_id: Some(user_id),
123 + project_id: None,
124 + contact_id: None,
125 + title: "Far off".into(),
126 + description: String::new(),
127 + start_time: now + Duration::days(30),
128 + end_time: None,
129 + location: None,
130 + linked_task_id: None,
131 + recurrence: Recurrence::None,
132 + recurrence_rule: None,
133 + block_type: None,
134 + reminder_offsets_seconds: Vec::new(),
135 + })
136 + .await
137 + .expect("create far event");
138 +
139 + // One active project (counted) and one on-hold project (excluded).
140 + projects
141 + .create(user_id, NewProject {
142 + name: "Active work".into(),
143 + description: String::new(),
144 + project_type: ProjectType::default(),
145 + status: ProjectStatus::Active,
146 + })
147 + .await
148 + .expect("create active project");
149 + projects
150 + .create(user_id, NewProject {
151 + name: "Paused work".into(),
152 + description: String::new(),
153 + project_type: ProjectType::default(),
154 + status: ProjectStatus::OnHold,
155 + })
156 + .await
157 + .expect("create on-hold project");
158 +
159 + let stats = stats_repo
160 + .get_dashboard_stats(user_id, now, today_start, tomorrow_start, week_end)
161 + .await
162 + .expect("get_dashboard_stats");
163 +
164 + assert_eq!(stats.tasks_due_today, 1, "only the task due later today counts");
165 + assert_eq!(stats.tasks_due_this_week, 2, "today's task plus the three-day task");
166 + assert_eq!(stats.overdue_count, 1, "only the past-due task counts");
167 + assert_eq!(stats.unread_emails, 1, "only the unread email counts");
168 + assert_eq!(stats.upcoming_events, 1, "only the within-a-week event counts");
169 + assert_eq!(stats.active_projects, 1, "only the Active project counts");
170 +
171 + // Three non-completed tasks remain; the completed one is excluded.
172 + assert_eq!(stats.high_urgency_tasks.len(), 3, "completed task excluded from high-urgency list");
173 + assert!(stats.high_urgency_tasks.iter().all(|t| t.status != "Completed"));
174 + // The list is ordered by urgency descending.
175 + for pair in stats.high_urgency_tasks.windows(2) {
176 + assert!(pair[0].urgency >= pair[1].urgency, "high-urgency tasks must be sorted descending");
177 + }
178 + }
179 +
180 + #[tokio::test]
181 + async fn test_dashboard_stats_scoped_to_user() {
182 + let pool = common::setup_test_db().await;
183 + let mine = common::create_test_user(&pool).await;
184 + let other = common::create_test_user(&pool).await;
185 +
186 + let tasks = SqliteTaskRepository::new(pool.clone());
187 + let stats_repo = SqliteStatsRepository::new(pool);
188 +
189 + let now = Utc::now();
190 + // Another user's overdue task must not leak into my dashboard.
191 + tasks
192 + .create(other, NewTask::builder("Not mine").priority(Priority::High).due(now - Duration::days(1)).build())
193 + .await
194 + .expect("create other task");
195 +
196 + let stats = stats_repo
197 + .get_dashboard_stats(mine, now, now - Duration::hours(1), now + Duration::hours(12), now + Duration::days(7))
198 + .await
199 + .expect("get_dashboard_stats");
200 +
201 + assert_eq!(stats.overdue_count, 0, "stats must be scoped to the requesting user");
202 + assert!(stats.high_urgency_tasks.is_empty());
203 + }
@@ -0,0 +1,105 @@
1 + //! Integration tests for SqliteUserRepository.
2 +
3 + #[allow(dead_code)]
4 + mod common;
5 +
6 + use goingson_core::UserRepository;
7 + use goingson_db_sqlite::SqliteUserRepository;
8 +
9 + #[tokio::test]
10 + async fn test_create_hashes_password_and_lowercases_email() {
11 + let pool = common::setup_test_db().await;
12 + let repo = SqliteUserRepository::new(pool);
13 +
14 + let user = repo
15 + .create("Alice@Example.COM", "correct horse battery staple", "Alice")
16 + .await
17 + .expect("Failed to create user");
18 +
19 + assert_eq!(user.email, "alice@example.com", "email must be stored lowercased");
20 + assert_eq!(user.display_name, "Alice");
21 + assert_ne!(user.password_hash, "correct horse battery staple", "password must not be stored in plaintext");
22 + assert!(user.password_hash.starts_with("$argon2"), "stored hash should be a PHC Argon2 string, got: {}", user.password_hash);
23 + assert!(user.last_login_at.is_none(), "a freshly created user has never logged in");
24 + }
25 +
26 + #[tokio::test]
27 + async fn test_find_by_email_found_and_not_found() {
28 + let pool = common::setup_test_db().await;
29 + let repo = SqliteUserRepository::new(pool);
30 +
31 + repo.create("bob@example.com", "hunter2hunter2", "Bob").await.expect("create");
32 +
33 + let found = repo.find_by_email("bob@example.com").await.expect("find");
34 + assert!(found.is_some());
35 + assert_eq!(found.unwrap().display_name, "Bob");
36 +
37 + let missing = repo.find_by_email("nobody@example.com").await.expect("find");
38 + assert!(missing.is_none(), "unknown email must return None");
39 + }
40 +
41 + #[tokio::test]
42 + async fn test_find_by_email_is_case_insensitive() {
43 + let pool = common::setup_test_db().await;
44 + let repo = SqliteUserRepository::new(pool);
45 +
46 + let created = repo.create("carol@example.com", "passwordpassword", "Carol").await.expect("create");
47 +
48 + let found = repo.find_by_email("CAROL@EXAMPLE.COM").await.expect("find").expect("should match despite case");
49 + assert_eq!(found.id, created.id, "lookup must fold case to match the stored lowercase email");
50 + }
51 +
52 + #[tokio::test]
53 + async fn test_authenticate_correct_password_succeeds() {
54 + let pool = common::setup_test_db().await;
55 + let repo = SqliteUserRepository::new(pool);
56 +
57 + let created = repo.create("dave@example.com", "s3cr3t-passphrase", "Dave").await.expect("create");
58 + assert!(created.last_login_at.is_none());
59 +
60 + let authed = repo.authenticate("dave@example.com", "s3cr3t-passphrase").await.expect("authenticate");
61 + assert!(authed.is_some(), "correct password must authenticate");
62 + assert_eq!(authed.unwrap().id, created.id);
63 +
64 + // authenticate() records a successful login timestamp.
65 + let after = repo.find_by_email("dave@example.com").await.expect("find").unwrap();
66 + assert!(after.last_login_at.is_some(), "successful authentication must stamp last_login_at");
67 + }
68 +
69 + #[tokio::test]
70 + async fn test_authenticate_wrong_password_fails() {
71 + let pool = common::setup_test_db().await;
72 + let repo = SqliteUserRepository::new(pool);
73 +
74 + repo.create("erin@example.com", "the-right-password", "Erin").await.expect("create");
75 +
76 + let result = repo.authenticate("erin@example.com", "the-wrong-password").await.expect("authenticate");
77 + assert!(result.is_none(), "wrong password must not authenticate");
78 +
79 + // A failed login must not stamp last_login_at.
80 + let after = repo.find_by_email("erin@example.com").await.expect("find").unwrap();
81 + assert!(after.last_login_at.is_none(), "failed authentication must leave last_login_at untouched");
82 + }
83 +
84 + #[tokio::test]
85 + async fn test_authenticate_unknown_email_returns_none() {
86 + let pool = common::setup_test_db().await;
87 + let repo = SqliteUserRepository::new(pool);
88 +
89 + let result = repo.authenticate("ghost@example.com", "whatever").await.expect("authenticate");
90 + assert!(result.is_none(), "authenticating an unknown email must return None, not error");
91 + }
92 +
93 + #[tokio::test]
94 + async fn test_update_last_login_sets_timestamp() {
95 + let pool = common::setup_test_db().await;
96 + let repo = SqliteUserRepository::new(pool);
97 +
98 + let user = repo.create("frank@example.com", "frankspassword", "Frank").await.expect("create");
99 + assert!(user.last_login_at.is_none());
100 +
101 + repo.update_last_login(user.id).await.expect("update_last_login");
102 +
103 + let refreshed = repo.find_by_email("frank@example.com").await.expect("find").unwrap();
104 + assert!(refreshed.last_login_at.is_some(), "update_last_login must persist a timestamp");
105 + }
@@ -79,10 +79,8 @@ function updateElapsed() {
79 79 * @param {string} taskId - Task ID to track time for
80 80 */
81 81 async function startTimer(taskId) {
82 - console.log('[timer] startTimer called', { taskId, hasApi: !!GoingsOn.api?.timeTracking?.startTimer });
83 82 try {
84 - const result = await GoingsOn.api.timeTracking.startTimer(taskId);
85 - console.log('[timer] startTimer succeeded', result);
83 + await GoingsOn.api.timeTracking.startTimer(taskId);
86 84 await checkActive();
87 85 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
88 86 } catch (err) {
@@ -177,7 +175,6 @@ function clearSubviewTick() {
177 175 async function loadTimerView() {
178 176 const container = document.getElementById('timer-subview-content');
179 177 if (!container) return;
180 - console.log('[timer] loadTimerView start');
181 178
182 179 // Fetch data independently so one failure doesn't block the rest
183 180 let activeResult = null;
@@ -185,7 +182,6 @@ async function loadTimerView() {
185 182
186 183 try {
187 184 activeResult = await GoingsOn.api.timeTracking.getActive();
188 - console.log('[timer] getActive result:', activeResult);
189 185 } catch (err) {
190 186 console.error('[timer] getActive failed:', err);
191 187 }
@@ -195,7 +191,6 @@ async function loadTimerView() {
195 191 GoingsOn.api.tasks.listFiltered({ status: 'Pending', showSnoozed: false, limit: 200 }),
196 192 GoingsOn.api.tasks.listFiltered({ status: 'Started', showSnoozed: false, limit: 200 }),
197 193 ]);
198 - console.log('[timer] listFiltered results — pending:', pendingResp?.tasks?.length, 'started:', startedResp?.tasks?.length);
199 194 const pending = pendingResp?.tasks || [];
200 195 const started = startedResp?.tasks || [];
201 196 // Started first (more likely to be tracked), then pending
@@ -178,14 +178,10 @@ pub async fn import_vcf(
178 178 match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
179 179 Ok(contact) => {
180 180 // Set external source/id for dedup on re-import (must succeed to prevent duplicates)
181 - if let Err(e) = sqlx::query(
182 - "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ?",
183 - )
184 - .bind("vcf")
185 - .bind(&ext_id)
186 - .bind(contact.id.to_string())
187 - .execute(&state.pool)
188 - .await
181 + if let Err(e) = state
182 + .contacts
183 + .set_external_ref(contact.id, DESKTOP_USER_ID, "vcf", &ext_id)
184 + .await
189 185 {
190 186 tracing::error!(contact = %card.display_name, "Failed to set external source (dedup key lost): {}", e);
191 187 errors.push(format!("{}: failed to set dedup key: {}", card.display_name, e));
@@ -331,16 +327,15 @@ pub async fn import_ics(
331 327
332 328 match state.events.create(DESKTOP_USER_ID, new_event).await {
333 329 Ok(event) => {
334 - // Set external source/id (file imports are editable, not read-only)
335 - if let Some(ref uid) = parsed.external_id {
336 - let _ = sqlx::query(
337 - "UPDATE events SET external_source = ?, external_id = ? WHERE id = ?",
338 - )
339 - .bind("ics")
340 - .bind(uid)
341 - .bind(event.id.to_string())
342 - .execute(&state.pool)
343 - .await;
330 + // Set external source/id for dedup on re-import (file imports are editable, not read-only)
331 + if let Some(ref uid) = parsed.external_id
332 + && let Err(e) = state
333 + .events
334 + .set_external_ref(event.id, DESKTOP_USER_ID, "ics", uid)
335 + .await
336 + {
337 + tracing::error!(event = %parsed.title, "Failed to set external source (dedup key lost): {}", e);
338 + errors.push(format!("{}: failed to set dedup key: {}", parsed.title, e));
344 339 }
345 340
346 341 imported += 1;
@@ -0,0 +1,194 @@
1 + //! Integration tests for milestone repository operations.
2 +
3 + use chrono::NaiveDate;
4 + use goingson_core::{MilestoneStatus, NewMilestone, NewTask, Priority, TaskStatus};
5 +
6 + use crate::test_utils::{create_test_project, setup_test_state};
7 +
8 + #[tokio::test]
9 + async fn create_and_list_milestone() {
10 + let (state, user_id) = setup_test_state().await;
11 + let project_id = create_test_project(&state, user_id).await;
12 +
13 + let target = NaiveDate::from_ymd_opt(2026, 8, 15).unwrap();
14 + let created = state
15 + .milestones
16 + .create(
17 + user_id,
18 + NewMilestone {
19 + project_id,
20 + name: "Alpha Release".to_string(),
21 + description: "Ship it".to_string(),
22 + position: 0,
23 + target_date: Some(target),
24 + },
25 + )
26 + .await
27 + .unwrap();
28 +
29 + assert_eq!(created.name, "Alpha Release");
30 + assert_eq!(created.description, "Ship it");
31 + assert_eq!(created.target_date, Some(target));
32 + assert_eq!(created.status, MilestoneStatus::Open);
33 +
34 + let milestones = state
35 + .milestones
36 + .list_by_project(project_id, user_id)
37 + .await
38 + .unwrap();
39 + assert_eq!(milestones.len(), 1);
40 + assert_eq!(milestones[0].id, created.id);
41 + }
42 +
43 + #[tokio::test]
44 + async fn update_milestone() {
45 + let (state, user_id) = setup_test_state().await;
46 + let project_id = create_test_project(&state, user_id).await;
47 +
48 + let created = state
49 + .milestones
50 + .create(
51 + user_id,
52 + NewMilestone {
53 + project_id,
54 + name: "Original".to_string(),
55 + description: "old".to_string(),
56 + position: 0,
57 + target_date: None,
58 + },
59 + )
60 + .await
61 + .unwrap();
62 +
63 + let new_date = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
64 + let updated = state
65 + .milestones
66 + .update(
67 + created.id,
68 + user_id,
69 + "Renamed",
70 + "new desc",
71 + Some(new_date),
72 + &MilestoneStatus::Completed,
73 + )
74 + .await
75 + .unwrap()
76 + .unwrap();
77 +
78 + assert_eq!(updated.name, "Renamed");
79 + assert_eq!(updated.description, "new desc");
80 + assert_eq!(updated.target_date, Some(new_date));
81 + }
82 +
83 + #[tokio::test]
84 + async fn update_nonexistent_milestone_returns_none() {
85 + let (state, user_id) = setup_test_state().await;
86 +
87 + let fake_id = goingson_core::MilestoneId::from(uuid::Uuid::new_v4());
88 + let result = state
89 + .milestones
90 + .update(
91 + fake_id,
92 + user_id,
93 + "Nope",
94 + "",
95 + None,
96 + &MilestoneStatus::Open,
97 + )
98 + .await
99 + .unwrap();
100 + assert!(result.is_none());
101 + }
102 +
103 + #[tokio::test]
104 + async fn delete_milestone() {
105 + let (state, user_id) = setup_test_state().await;
106 + let project_id = create_test_project(&state, user_id).await;
107 +
108 + let created = state
109 + .milestones
110 + .create(
111 + user_id,
112 + NewMilestone {
113 + project_id,
114 + name: "To Delete".to_string(),
115 + description: String::new(),
116 + position: 0,
117 + target_date: None,
118 + },
119 + )
120 + .await
121 + .unwrap();
122 +
123 + assert!(state.milestones.delete(created.id, user_id).await.unwrap());
124 + assert!(state
125 + .milestones
126 + .get_by_id(created.id, user_id)
127 + .await
128 + .unwrap()
129 + .is_none());
130 + // Deleting an already-gone milestone reports no rows affected.
131 + assert!(!state.milestones.delete(created.id, user_id).await.unwrap());
132 + }
133 +
134 + /// Mirrors the progress fields the `list_milestones`/`update_milestone` commands
135 + /// pre-compute: task_count, completed_count, and rounded percentage from the tasks
136 + /// linked to a milestone.
137 + #[tokio::test]
138 + async fn milestone_progress_reflects_linked_tasks() {
139 + let (state, user_id) = setup_test_state().await;
140 + let project_id = create_test_project(&state, user_id).await;
141 +
142 + let milestone = state
143 + .milestones
144 + .create(
145 + user_id,
146 + NewMilestone {
147 + project_id,
148 + name: "Progress".to_string(),
149 + description: String::new(),
150 + position: 0,
151 + target_date: None,
152 + },
153 + )
154 + .await
155 + .unwrap();
156 +
157 + // 4 tasks on the milestone, 3 complete; plus 1 unrelated task on the project.
158 + for i in 0..4 {
159 + let t = NewTask::builder(format!("Milestone task {i}"))
160 + .project_id(project_id)
161 + .milestone_id(milestone.id)
162 + .priority(Priority::Medium)
163 + .build();
164 + let task = state.tasks.create(user_id, t).await.unwrap();
165 + if i < 3 {
166 + state.tasks.complete(task.id, user_id).await.unwrap();
167 + }
168 + }
169 + let unrelated = NewTask::builder("Not on milestone")
170 + .project_id(project_id)
171 + .priority(Priority::Low)
172 + .build();
173 + state.tasks.create(user_id, unrelated).await.unwrap();
174 +
175 + let all_tasks = state
176 + .tasks
177 + .list_by_project(user_id, project_id)
178 + .await
179 + .unwrap();
180 + let milestone_tasks: Vec<_> = all_tasks
181 + .iter()
182 + .filter(|t| t.milestone_id == Some(milestone.id))
183 + .collect();
184 + let task_count = milestone_tasks.len();
185 + let completed_count = milestone_tasks
186 + .iter()
187 + .filter(|t| t.status == TaskStatus::Completed)
188 + .count();
189 + let progress = ((completed_count as f64 / task_count as f64) * 100.0).round() as u8;
190 +
191 + assert_eq!(task_count, 4);
192 + assert_eq!(completed_count, 3);
193 + assert_eq!(progress, 75);
194 + }
@@ -11,7 +11,11 @@ mod day_planning_tests;
11 11 mod email_tests;
12 12 mod event_tests;
13 13 mod export_tests;
14 + mod milestone_tests;
14 15 mod project_tests;
16 + mod saved_views_tests;
17 + mod search_tests;
15 18 mod snooze_tests;
19 + mod stats_tests;
16 20 mod task_tests;
17 21 mod time_tracking_tests;
@@ -0,0 +1,168 @@
1 + //! Integration tests for saved view repository operations.
2 +
3 + use goingson_core::{
4 + NewSavedView, Priority, ProjectId, SortDirection, SortField, TaskStatus, ViewFilters, ViewType,
5 + };
6 +
7 + use crate::test_utils::setup_test_state;
8 +
9 + fn view(name: &str, view_type: ViewType, pinned: bool) -> NewSavedView {
10 + NewSavedView {
11 + name: name.to_string(),
12 + view_type,
13 + filters: ViewFilters::default(),
14 + sort_by: None,
15 + sort_order: Some(SortDirection::Asc),
16 + is_pinned: Some(pinned),
17 + }
18 + }
19 +
20 + #[tokio::test]
21 + async fn create_and_list_saved_view() {
22 + let (state, user_id) = setup_test_state().await;
23 +
24 + let created = state
25 + .saved_views
26 + .create(user_id, view("My Tasks", ViewType::Tasks, false))
27 + .await
28 + .unwrap();
29 + assert_eq!(created.name, "My Tasks");
30 + assert_eq!(created.view_type, ViewType::Tasks);
31 + assert!(!created.is_pinned);
32 +
33 + let all = state.saved_views.list_all(user_id).await.unwrap();
34 + assert_eq!(all.len(), 1);
35 + assert_eq!(all[0].id, created.id);
36 + }
37 +
38 + #[tokio::test]
39 + async fn list_pinned_filters_to_pinned_only() {
40 + let (state, user_id) = setup_test_state().await;
41 +
42 + state
43 + .saved_views
44 + .create(user_id, view("Pinned", ViewType::Tasks, true))
45 + .await
46 + .unwrap();
47 + state
48 + .saved_views
49 + .create(user_id, view("Unpinned", ViewType::Emails, false))
50 + .await
51 + .unwrap();
52 +
53 + let pinned = state.saved_views.list_pinned(user_id).await.unwrap();
54 + assert_eq!(pinned.len(), 1);
55 + assert_eq!(pinned[0].name, "Pinned");
56 + assert_eq!(state.saved_views.list_all(user_id).await.unwrap().len(), 2);
57 + }
58 +
59 + #[tokio::test]
60 + async fn delete_saved_view() {
61 + let (state, user_id) = setup_test_state().await;
62 +
63 + let created = state
64 + .saved_views
65 + .create(user_id, view("Temp", ViewType::Events, false))
66 + .await
67 + .unwrap();
68 +
69 + assert!(state.saved_views.delete(created.id, user_id).await.unwrap());
70 + assert!(state
71 + .saved_views
72 + .get_by_id(created.id, user_id)
73 + .await
74 + .unwrap()
75 + .is_none());
76 + // Second delete finds nothing to remove.
77 + assert!(!state.saved_views.delete(created.id, user_id).await.unwrap());
78 + }
79 +
80 + #[tokio::test]
81 + async fn get_nonexistent_view_returns_none() {
82 + let (state, user_id) = setup_test_state().await;
83 +
84 + let fake_id = goingson_core::SavedViewId::from(uuid::Uuid::new_v4());
85 + assert!(state
86 + .saved_views
87 + .get_by_id(fake_id, user_id)
88 + .await
89 + .unwrap()
90 + .is_none());
91 + }
92 +
93 + /// The view's filter payload is stored as JSON and the sort config as text columns.
94 + /// Verify the full query payload survives a create -> fetch round-trip.
95 + #[tokio::test]
96 + async fn filters_and_sort_round_trip() {
97 + let (state, user_id) = setup_test_state().await;
98 +
99 + let project_id = ProjectId::new();
100 + let filters = ViewFilters {
101 + status: Some(vec![TaskStatus::Pending, TaskStatus::Started]),
102 + priority: Some(vec![Priority::High]),
103 + project_id: Some(project_id),
104 + is_snoozed: Some(true),
105 + is_waiting: Some(false),
106 + ..Default::default()
107 + };
108 +
109 + let created = state
110 + .saved_views
111 + .create(
112 + user_id,
113 + NewSavedView {
114 + name: "Complex".to_string(),
115 + view_type: ViewType::Tasks,
116 + filters: filters.clone(),
117 + sort_by: Some(SortField::Urgency),
118 + sort_order: Some(SortDirection::Desc),
119 + is_pinned: Some(true),
120 + },
121 + )
122 + .await
123 + .unwrap();
124 +
125 + let fetched = state
126 + .saved_views
127 + .get_by_id(created.id, user_id)
128 + .await
129 + .unwrap()
130 + .unwrap();
131 +
132 + assert_eq!(fetched.filters.status, filters.status);
133 + assert_eq!(fetched.filters.priority, filters.priority);
134 + assert_eq!(fetched.filters.project_id, Some(project_id));
135 + assert_eq!(fetched.filters.is_snoozed, Some(true));
136 + assert_eq!(fetched.filters.is_waiting, Some(false));
137 + assert_eq!(fetched.sort_by, Some(SortField::Urgency));
138 + assert_eq!(fetched.sort_order, SortDirection::Desc);
139 + assert!(fetched.is_pinned);
140 + }
141 +
142 + #[tokio::test]
143 + async fn toggle_pinned_flips_state() {
144 + let (state, user_id) = setup_test_state().await;
145 +
146 + let created = state
147 + .saved_views
148 + .create(user_id, view("Toggle", ViewType::Tasks, false))
149 + .await
150 + .unwrap();
151 + assert!(!created.is_pinned);
152 +
153 + let on = state
154 + .saved_views
155 + .toggle_pinned(created.id, user_id)
156 + .await
157 + .unwrap()
158 + .unwrap();
159 + assert!(on.is_pinned);
160 +
161 + let off = state
162 + .saved_views
163 + .toggle_pinned(created.id, user_id)
164 + .await
165 + .unwrap()
166 + .unwrap();
167 + assert!(!off.is_pinned);
168 + }
@@ -0,0 +1,203 @@
1 + //! Integration tests for full-text search.
2 +
3 + use chrono::{Duration, Utc};
4 + use goingson_core::{
5 + NewContact, NewEvent, NewTask, Priority, SearchQuery, SearchResultItem, SearchResultType,
6 + };
7 + use uuid::Uuid;
8 +
9 + use crate::commands::search::SearchResultResponse;
10 + use crate::test_utils::{create_test_project, setup_test_state};
11 +
12 + fn task(desc: &str, priority: Priority) -> NewTask {
13 + NewTask::builder(desc).priority(priority).build()
14 + }
15 +
16 + #[tokio::test]
17 + async fn text_search_returns_task_hit() {
18 + let (state, user_id) = setup_test_state().await;
19 +
20 + state
21 + .tasks
22 + .create(user_id, task("Fix login bug in auth module", Priority::Medium))
23 + .await
24 + .unwrap();
25 + state
26 + .tasks
27 + .create(user_id, task("Write release notes", Priority::Medium))
28 + .await
29 + .unwrap();
30 +
31 + let (results, _total) = state
32 + .search
33 + .search(user_id, SearchQuery::new("login"))
34 + .await
35 + .unwrap();
36 +
37 + assert_eq!(results.len(), 1);
38 + assert_eq!(results[0].result_type, SearchResultType::Task);
39 + assert!(results[0].title.contains("login"));
40 + }
41 +
42 + #[tokio::test]
43 + async fn search_spans_multiple_types() {
44 + let (state, user_id) = setup_test_state().await;
45 +
46 + state
47 + .tasks
48 + .create(user_id, task("Authentication rewrite", Priority::High))
49 + .await
50 + .unwrap();
51 + create_test_project(&state, user_id).await; // "Test Project", no match
52 + state
53 + .contacts
54 + .create(
55 + user_id,
56 + NewContact {
57 + display_name: "Authentication Vendor".to_string(),
58 + nickname: None,
59 + company: None,
60 + title: None,
61 + notes: String::new(),
62 + tags: vec![],
63 + birthday: None,
64 + timezone: None,
65 + is_implicit: false,
66 + },
67 + )
68 + .await
69 + .unwrap();
70 + let start = Utc::now() + Duration::days(1);
71 + state
72 + .events
73 + .create(
74 + user_id,
75 + NewEvent::builder("Authentication planning", start).build(),
76 + )
77 + .await
78 + .unwrap();
79 +
80 + let (results, _total) = state
81 + .search
82 + .search(user_id, SearchQuery::new("authentication"))
83 + .await
84 + .unwrap();
85 +
86 + let types: Vec<_> = results.iter().map(|r| r.result_type).collect();
87 + assert!(types.contains(&SearchResultType::Task));
88 + assert!(types.contains(&SearchResultType::Contact));
89 + assert!(types.contains(&SearchResultType::Event));
90 + }
91 +
92 + #[tokio::test]
93 + async fn type_filter_restricts_results() {
94 + let (state, user_id) = setup_test_state().await;
95 +
96 + state
97 + .tasks
98 + .create(user_id, task("Deploy infrastructure", Priority::Medium))
99 + .await
100 + .unwrap();
101 + state
102 + .contacts
103 + .create(
104 + user_id,
105 + NewContact {
106 + display_name: "Infrastructure Team".to_string(),
107 + nickname: None,
108 + company: None,
109 + title: None,
110 + notes: String::new(),
111 + tags: vec![],
112 + birthday: None,
113 + timezone: None,
114 + is_implicit: false,
115 + },
116 + )
117 + .await
118 + .unwrap();
119 +
120 + let query = SearchQuery::new("infrastructure").with_types(vec![SearchResultType::Task]);
121 + let (results, _total) = state.search.search(user_id, query).await.unwrap();
122 +
123 + assert!(!results.is_empty());
124 + assert!(results
125 + .iter()
126 + .all(|r| r.result_type == SearchResultType::Task));
127 + }
128 +
129 + #[tokio::test]
130 + async fn empty_query_returns_nothing() {
131 + let (state, user_id) = setup_test_state().await;
132 + state
133 + .tasks
134 + .create(user_id, task("Some task", Priority::Medium))
135 + .await
136 + .unwrap();
137 +
138 + let (results, total) = state
139 + .search
140 + .search(user_id, SearchQuery::new(""))
141 + .await
142 + .unwrap();
143 + assert!(results.is_empty());
144 + assert_eq!(total, 0);
145 + }
146 +
147 + /// A filter-only (no text) task search must exclude soft-deleted tasks
148 + /// (status = 'Deleted').
149 + #[tokio::test]
150 + async fn filter_only_search_excludes_deleted_tasks() {
151 + let (state, user_id) = setup_test_state().await;
152 +
153 + let keep = state
154 + .tasks
155 + .create(user_id, task("Keep me high", Priority::High))
156 + .await
157 + .unwrap();
158 + let drop = state
159 + .tasks
160 + .create(user_id, task("Delete me high", Priority::High))
161 + .await
162 + .unwrap();
163 + state.tasks.delete(drop.id, user_id).await.unwrap();
164 +
165 + let query = SearchQuery {
166 + priority: Some(Priority::High),
167 + ..Default::default()
168 + };
169 + let (results, _total) = state.search.search(user_id, query).await.unwrap();
170 +
171 + assert_eq!(results.len(), 1);
172 + assert_eq!(results[0].id, *keep.id);
173 + }
174 +
175 + /// The result-type enum is stringified in the response conversion; verify the mapping.
176 + #[tokio::test]
177 + async fn response_conversion_maps_type_strings() {
178 + let cases = [
179 + (SearchResultType::Task, "task"),
180 + (SearchResultType::Email, "email"),
181 + (SearchResultType::Project, "project"),
182 + (SearchResultType::Event, "event"),
183 + (SearchResultType::Contact, "contact"),
184 + ];
185 +
186 + for (rt, expected) in cases {
187 + let item = SearchResultItem {
188 + id: Uuid::new_v4(),
189 + result_type: rt,
190 + title: "Title".to_string(),
191 + snippet: Some("snip".to_string()),
192 + project_id: None,
193 + project_name: Some("Proj".to_string()),
194 + rank: 1.5,
195 + };
196 + let resp = SearchResultResponse::from(item.clone());
197 + assert_eq!(resp.result_type, expected);
198 + assert_eq!(resp.id, item.id);
199 + assert_eq!(resp.title, "Title");
200 + assert_eq!(resp.snippet.as_deref(), Some("snip"));
201 + assert_eq!(resp.rank, 1.5);
202 + }
203 + }
@@ -0,0 +1,150 @@
1 + //! Integration tests for dashboard stats.
2 +
3 + use chrono::{Duration, Utc};
4 + use goingson_core::{DashboardStats, HighUrgencyTask, NewEvent, NewTask, Priority};
5 +
6 + use crate::commands::stats::DashboardStatsResponse;
7 + use crate::test_utils::{create_test_project, setup_test_state};
8 +
9 + /// Computes the same UTC day windows the `get_dashboard_stats` command derives.
10 + fn windows() -> (
11 + chrono::DateTime<Utc>,
12 + chrono::DateTime<Utc>,
13 + chrono::DateTime<Utc>,
14 + chrono::DateTime<Utc>,
15 + ) {
16 + let now = Utc::now();
17 + let today = now.date_naive();
18 + let midnight = |d: chrono::NaiveDate| d.and_hms_opt(0, 0, 0).unwrap().and_utc();
19 + let today_start = midnight(today);
20 + let tomorrow_start = midnight(today + Duration::days(1));
21 + let week_end = midnight(today + Duration::days(8));
22 + (now, today_start, tomorrow_start, week_end)
23 + }
24 +
25 + #[tokio::test]
26 + async fn dashboard_counts_tasks_by_due_window() {
27 + let (state, user_id) = setup_test_state().await;
28 + let (now, today_start, tomorrow_start, week_end) = windows();
29 +
30 + // Overdue: due 3 days ago.
31 + let overdue = NewTask::builder("Overdue task")
32 + .priority(Priority::High)
33 + .due(now - Duration::days(3))
34 + .build();
35 + state.tasks.create(user_id, overdue).await.unwrap();
36 +
37 + // Due this week (but not today): 3 days out.
38 + let this_week = NewTask::builder("This week task")
39 + .priority(Priority::Medium)
40 + .due(now + Duration::days(3))
41 + .build();
42 + state.tasks.create(user_id, this_week).await.unwrap();
43 +
44 + let stats = state
45 + .stats
46 + .get_dashboard_stats(user_id, now, today_start, tomorrow_start, week_end)
47 + .await
48 + .unwrap();
49 +
50 + assert_eq!(stats.overdue_count, 1);
51 + assert_eq!(stats.tasks_due_today, 0);
52 + assert_eq!(stats.tasks_due_this_week, 1);
53 + // No emails seeded.
54 + assert_eq!(stats.unread_emails, 0);
55 + }
56 +
57 + #[tokio::test]
58 + async fn dashboard_counts_projects_and_events() {
59 + let (state, user_id) = setup_test_state().await;
60 + let (now, today_start, tomorrow_start, week_end) = windows();
61 +
62 + // create_test_project makes one Active project.
63 + create_test_project(&state, user_id).await;
64 +
65 + // Event within the 7-day window and one beyond it.
66 + let soon = NewEvent::builder("Soon", now + Duration::days(2)).build();
67 + state.events.create(user_id, soon).await.unwrap();
68 + let far = NewEvent::builder("Far", now + Duration::days(30)).build();
69 + state.events.create(user_id, far).await.unwrap();
70 +
71 + let stats = state
72 + .stats
73 + .get_dashboard_stats(user_id, now, today_start, tomorrow_start, week_end)
74 + .await
75 + .unwrap();
76 +
77 + assert_eq!(stats.active_projects, 1);
78 + assert_eq!(stats.upcoming_events, 1);
79 + }
80 +
81 + #[tokio::test]
82 + async fn dashboard_high_urgency_sorted_and_excludes_completed() {
83 + let (state, user_id) = setup_test_state().await;
84 + let (now, today_start, tomorrow_start, week_end) = windows();
85 +
86 + for (desc, urgency) in [("low", 1.0), ("high", 9.0), ("mid", 5.0)] {
87 + let t = NewTask::builder(desc)
88 + .priority(Priority::Medium)
89 + .urgency(urgency)
90 + .build();
91 + state.tasks.create(user_id, t).await.unwrap();
92 + }
93 + // A very urgent but completed task must not appear.
94 + let done = NewTask::builder("done")
95 + .priority(Priority::High)
96 + .urgency(100.0)
97 + .build();
98 + let done = state.tasks.create(user_id, done).await.unwrap();
99 + state.tasks.complete(done.id, user_id).await.unwrap();
100 +
101 + let stats = state
102 + .stats
103 + .get_dashboard_stats(user_id, now, today_start, tomorrow_start, week_end)
104 + .await
105 + .unwrap();
106 +
107 + assert_eq!(stats.high_urgency_tasks.len(), 3);
108 + assert_eq!(stats.high_urgency_tasks[0].description, "high");
109 + assert_eq!(stats.high_urgency_tasks[0].urgency, 9.0);
110 + assert_eq!(stats.high_urgency_tasks[2].description, "low");
111 + assert!(stats
112 + .high_urgency_tasks
113 + .iter()
114 + .all(|t| t.description != "done"));
115 + }
116 +
117 + /// The response type renames fields (tasks_due_today -> dueTodayCount, etc.), so the
118 + /// `From` mapping is where a wiring regression would hide.
119 + #[tokio::test]
120 + async fn response_conversion_maps_fields() {
121 + let stats = DashboardStats {
122 + tasks_due_today: 2,
123 + tasks_due_this_week: 5,
124 + overdue_count: 3,
125 + unread_emails: 7,
126 + upcoming_events: 4,
127 + active_projects: 6,
128 + high_urgency_tasks: vec![HighUrgencyTask {
129 + id: "abc".to_string(),
130 + description: "Ship it".to_string(),
131 + urgency: 8.5,
132 + status: "Pending".to_string(),
133 + due: Some("2026-07-04T00:00:00Z".to_string()),
134 + }],
135 + };
136 +
137 + let resp = DashboardStatsResponse::from(stats);
138 +
139 + assert_eq!(resp.due_today_count, 2);
140 + assert_eq!(resp.due_this_week_count, 5);
141 + assert_eq!(resp.overdue_count, 3);
142 + assert_eq!(resp.unread_emails, 7);
143 + assert_eq!(resp.upcoming_events, 4);
144 + assert_eq!(resp.active_projects, 6);
145 + assert_eq!(resp.high_urgency_tasks.len(), 1);
146 + assert_eq!(resp.high_urgency_tasks[0].id, "abc");
147 + assert_eq!(resp.high_urgency_tasks[0].description, "Ship it");
148 + assert_eq!(resp.high_urgency_tasks[0].urgency, 8.5);
149 + assert_eq!(resp.high_urgency_tasks[0].due.as_deref(), Some("2026-07-04T00:00:00Z"));
150 + }
@@ -1852,3 +1852,248 @@ use sqlx::SqlitePool;
1852 1852 );
1853 1853 }
1854 1854 }
1855 +
1856 + // ── HLC assignment monotonicity ─────────────────────────────────────────────
1857 + //
1858 + // assign_pending_hlcs stamps unpushed local rows lazily; the stamps must sort
1859 + // strictly after each other in stamp order so push and conflict-detection agree
1860 + // on a total order, and each stamp must land in the committed-HLC store as the
1861 + // row's committed clock (so a later older remote edit gates out).
1862 +
1863 + #[tokio::test]
1864 + async fn assign_pending_hlcs_stamps_are_strictly_increasing_and_recorded_committed() {
1865 + use crate::sync_service::hlc::{assign_pending_hlcs, load_committed_hlcs};
1866 +
1867 + let pool = setup_test_db().await;
1868 + let device_id = uuid::Uuid::new_v4();
1869 +
1870 + // Seed a batch of unstamped pending rows in a known id order.
1871 + let row_ids: Vec<String> = (0..6).map(|i| format!("row-{i}")).collect();
1872 + for rid in &row_ids {
1873 + sqlx::query(
1874 + "INSERT INTO sync_changelog (table_name, op, row_id, pushed, hlc_wall, hlc_counter) \
1875 + VALUES ('tasks', 'INSERT', ?, 0, NULL, NULL)",
1876 + )
1877 + .bind(rid)
1878 + .execute(&pool)
1879 + .await
1880 + .unwrap();
1881 + }
1882 +
1883 + assign_pending_hlcs(&pool, device_id).await.unwrap();
1884 +
1885 + // Read the stamps back in the order they were stamped (id ASC).
1886 + let stamped: Vec<(i64, i64, String)> = sqlx::query_as(
1887 + "SELECT hlc_wall, hlc_counter, row_id FROM sync_changelog \
1888 + WHERE pushed = 0 ORDER BY id ASC",
1889 + )
1890 + .fetch_all(&pool)
1891 + .await
1892 + .unwrap();
1893 + assert_eq!(stamped.len(), row_ids.len(), "every pending row is stamped");
1894 +
1895 + // Each stamp is strictly greater than the previous by (wall, counter).
1896 + for w in stamped.windows(2) {
1897 + let (a_wall, a_ctr, _) = &w[0];
1898 + let (b_wall, b_ctr, _) = &w[1];
1899 + assert!(
1900 + (*b_wall, *b_ctr) > (*a_wall, *a_ctr),
1901 + "HLC stamps must be strictly increasing: {:?} then {:?}",
1902 + (a_wall, a_ctr),
1903 + (b_wall, b_ctr),
1904 + );
1905 + }
1906 +
1907 + // Each row's committed HLC equals its stamp (recorded as the max), with this
1908 + // device's node.
1909 + let keys: Vec<(String, String)> = stamped
1910 + .iter()
1911 + .map(|(_, _, r)| ("tasks".to_string(), r.clone()))
1912 + .collect();
1913 + let committed = load_committed_hlcs(&pool, &keys).await.unwrap();
1914 + for (wall, ctr, row_id) in &stamped {
1915 + let hlc = committed
1916 + .get(&("tasks".to_string(), row_id.clone()))
1917 + .copied()
1918 + .unwrap_or_else(|| panic!("no committed HLC for {row_id}"));
1919 + assert_eq!(hlc.wall_ms, *wall);
1920 + assert_eq!(hlc.counter as i64, *ctr);
1921 + assert_eq!(hlc.node, device_id, "committed HLC carries this device's node");
1922 + }
1923 +
1924 + // The persistent clock advanced exactly to the last stamp.
1925 + let (clock_wall, clock_ctr): (i64, i64) =
1926 + sqlx::query_as("SELECT wall_ms, counter FROM hlc_state WHERE id = 1")
1927 + .fetch_one(&pool)
1928 + .await
1929 + .unwrap();
1930 + let last = stamped.last().unwrap();
1931 + assert_eq!(
1932 + (clock_wall, clock_ctr),
1933 + (last.0, last.1),
1934 + "persistent clock advanced to the final stamp",
1935 + );
1936 + }
1937 +
1938 + // ── Apply idempotency + committed-HLC gate backing ──────────────────────────
1939 + //
1940 + // Applying the same upsert twice must be a no-op: no duplicate row, no child
1941 + // cascade, and the committed-HLC store still holds the change's HLC (max-kept),
1942 + // so the CleanChanges gate would drop the already-seen change on a later pull.
1943 +
1944 + #[tokio::test]
1945 + async fn apply_changes_inner_upsert_is_idempotent_and_gate_holds_committed_hlc() {
1946 + use crate::sync_service::hlc::load_committed_hlcs;
1947 + use synckit_client::{ChangeEntry, ChangeOp, Hlc};
1948 +
1949 + let pool = setup_test_db().await;
1950 + let user_id = create_test_user(&pool).await;
1951 + let task_id = uuid::Uuid::new_v4().to_string();
1952 + let annotation_id = uuid::Uuid::new_v4().to_string();
1953 + let now = now_sql();
1954 + let node = uuid::Uuid::new_v4();
1955 + let hlc = Hlc { wall_ms: 5000, counter: 2, node };
1956 +
1957 + let task_data = json!({
1958 + "id": task_id, "project_id": null, "description": "Idempotent",
1959 + "status": "Active", "priority": "Medium", "due": null, "tags": "",
1960 + "urgency": 0.0, "recurrence": "None", "recurrence_rule": null,
1961 + "created_at": now, "user_id": user_id, "recurrence_parent_id": null,
1962 + "source_email_id": null, "snoozed_until": null, "waiting_for_response": 0,
1963 + "waiting_since": null, "expected_response_date": null, "scheduled_start": null,
1964 + "scheduled_duration": null, "is_focus": 0, "focus_set_at": null,
1965 + "contact_id": null, "milestone_id": null, "completed_at": null,
1966 + "estimated_minutes": null, "actual_minutes": null,
1967 + });
1968 +
1969 + let entry = ChangeEntry {
1970 + table: "tasks".to_string(),
1971 + op: ChangeOp::Insert,
1972 + row_id: task_id.clone(),
1973 + timestamp: chrono::Utc::now(),
1974 + hlc,
1975 + data: Some(task_data),
1976 + extra: Default::default(),
1977 + };
1978 +
1979 + let mut conn = pool.acquire().await.unwrap();
1980 + sqlx::query("PRAGMA foreign_keys = ON").execute(&mut *conn).await.unwrap();
1981 +
1982 + // First apply: inserts the task and records its committed HLC.
1983 + pull::apply_changes_inner(&mut conn, vec![entry.clone()]).await.unwrap();
1984 +
1985 + // Attach a child annotation to prove a re-apply does not cascade-delete it.
1986 + apply::apply_upsert(&mut conn, "annotations", &annotation_id, &json!({
1987 + "id": annotation_id, "task_id": task_id, "timestamp": now, "note": "child",
1988 + })).await.unwrap();
1989 +
1990 + // Second apply of the identical change: a no-op, not an error.
1991 + let skipped = pull::apply_changes_inner(&mut conn, vec![entry.clone()]).await.unwrap();
1992 + assert_eq!(skipped, 0, "identical re-apply is not a skip/error");
1993 +
1994 + // Exactly one task row, child annotation intact -- no duplicate, no cascade.
1995 + let task_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks WHERE id = ?")
1996 + .bind(&task_id).fetch_one(&pool).await.unwrap();
1997 + assert_eq!(task_count.0, 1, "re-apply must not duplicate the row");
1998 + let ann_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM annotations WHERE task_id = ?")
1999 + .bind(&task_id).fetch_one(&pool).await.unwrap();
2000 + assert_eq!(ann_count.0, 1, "child annotation survives the re-apply");
2001 +
2002 + // The committed-HLC store holds exactly the change's HLC (max-kept across both
2003 + // applies). Its HLC is therefore not strictly greater than the committed clock,
2004 + // so SyncKit's CleanChanges gate would drop this already-seen change next pull.
2005 + let key = ("tasks".to_string(), task_id.clone());
2006 + let committed = load_committed_hlcs(&pool, std::slice::from_ref(&key)).await.unwrap();
2007 + let committed_hlc = committed.get(&key).copied().expect("committed HLC recorded");
2008 + assert_eq!(committed_hlc, hlc);
2009 + assert!(
2010 + entry.hlc <= committed_hlc,
2011 + "already-seen HLC is gated out (not newer than committed)",
2012 + );
2013 + }
2014 +
2015 + // ── LWW clock-poison guard ──────────────────────────────────────────────────
2016 + //
2017 + // A remote HLC beyond MAX_HLC_DRIFT_MS in the future is clock-poisoned and, per
2018 + // resolve_lww_at's (honest local, poisoned remote) => KeepLocal rule, cannot win
2019 + // LWW over an honest local write -- the local value survives. resolve_lww_at
2020 + // takes a PulledChange (which is #[non_exhaustive] and not constructible outside
2021 + // synckit-client, so the full resolution runs in synckit-client's own conflict
2022 + // tests); here we pin the load-bearing predicate GO's pull relies on.
2023 +
2024 + #[test]
2025 + fn clock_poison_guard_flags_far_future_remote_but_not_honest_local() {
2026 + use synckit_client::Hlc;
2027 + use synckit_client::conflict::{is_clock_poisoned, MAX_HLC_DRIFT_MS};
2028 +
2029 + let now = chrono::Utc::now();
2030 + let now_ms = now.timestamp_millis();
2031 + let node = uuid::Uuid::new_v4();
2032 +
2033 + // An honest local write at ~now is never flagged.
2034 + let local = Hlc { wall_ms: now_ms, counter: 0, node };
2035 + assert!(!is_clock_poisoned(&local, now), "an at-now HLC must not be poisoned");
2036 +
2037 + // Just inside the drift window is still honest (inter-device skew tolerated).
2038 + let within = Hlc { wall_ms: now_ms + MAX_HLC_DRIFT_MS - 1_000, counter: 0, node };
2039 + assert!(!is_clock_poisoned(&within, now), "skew within the drift cap is allowed");
2040 +
2041 + // A remote HLC far beyond the drift cap is poisoned. It sorts ABOVE the local
2042 + // write, so it would win raw LWW ordering -- but the poison guard makes it lose,
2043 + // so the local value survives.
2044 + let poisoned = Hlc { wall_ms: now_ms + MAX_HLC_DRIFT_MS + 60_000, counter: 0, node };
2045 + assert!(is_clock_poisoned(&poisoned, now), "a far-future remote HLC must be flagged");
2046 + assert!(poisoned > local, "the poisoned HLC would otherwise win raw LWW ordering");
2047 + }
2048 +
2049 + // ── bind_json_value ─────────────────────────────────────────────────────────
2050 + //
2051 + // The apply-side binder: JSON null (and an absent object field, which indexes to
2052 + // Value::Null) must bind SQL NULL; scalars must bind through; a JSON bool binds
2053 + // as an integer.
2054 +
2055 + #[tokio::test]
2056 + async fn bind_json_value_binds_null_absent_and_passthrough_values() {
2057 + let pool = setup_test_db().await;
2058 + let mut conn = pool.acquire().await.unwrap();
2059 +
2060 + sqlx::query("CREATE TABLE bjv_probe (id INTEGER PRIMARY KEY, v)")
2061 + .execute(&mut *conn).await.unwrap();
2062 +
2063 + // Explicit JSON null -> SQL NULL.
2064 + let null_val = serde_json::Value::Null;
2065 + apply::bind_json_value(
2066 + sqlx::query("INSERT INTO bjv_probe (id, v) VALUES (1, ?)"),
2067 + &null_val,
2068 + ).execute(&mut *conn).await.unwrap();
2069 +
2070 + // Absent object field indexes to Value::Null -> SQL NULL.
2071 + let obj = json!({"present": "x"});
2072 + apply::bind_json_value(
2073 + sqlx::query("INSERT INTO bjv_probe (id, v) VALUES (2, ?)"),
2074 + &obj["missing"],
2075 + ).execute(&mut *conn).await.unwrap();
2076 +
2077 + // A normal string binds through unchanged.
2078 + let s = json!("hello");
2079 + apply::bind_json_value(
2080 + sqlx::query("INSERT INTO bjv_probe (id, v) VALUES (3, ?)"),
2081 + &s,
2082 + ).execute(&mut *conn).await.unwrap();
2083 +
2084 + // A JSON bool binds as integer (0/1).
2085 + let b = json!(true);
2086 + apply::bind_json_value(
2087 + sqlx::query("INSERT INTO bjv_probe (id, v) VALUES (4, ?)"),
2088 + &b,
2089 + ).execute(&mut *conn).await.unwrap();
2090 +
2091 + // CAST to TEXT so every affinity decodes uniformly (NULL stays NULL).
2092 + let rows: Vec<(i64, Option<String>)> =
2093 + sqlx::query_as("SELECT id, CAST(v AS TEXT) FROM bjv_probe ORDER BY id")
2094 + .fetch_all(&pool).await.unwrap();
2095 + assert_eq!(rows[0].1, None, "JSON null binds SQL NULL");
2096 + assert_eq!(rows[1].1, None, "absent object field binds SQL NULL");
2097 + assert_eq!(rows[2].1.as_deref(), Some("hello"), "string binds through");
2098 + assert_eq!(rows[3].1.as_deref(), Some("1"), "bool binds as integer 1");
2099 + }