max / goingson
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 | + | } |