//! Integration tests for SqliteTaskRepository. mod common; use chrono::{Duration, Utc}; use goingson_core::{ NewProject, NewTask, Priority, ProjectRepository, Recurrence, RecurrenceRule, TaskFilterQuery, TaskCrud, TaskScheduling, TaskStatus, UpdateTask, }; use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; #[tokio::test] async fn test_create_and_get_task() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Test task") .priority(Priority::Medium) .tag("test") .build(); let created = repo.create(user_id, new_task).await.expect("Failed to create task"); assert_eq!(created.description, "Test task"); assert_eq!(created.status, TaskStatus::Pending); assert_eq!(created.priority, Priority::Medium); assert_eq!(created.tags, vec!["test"]); let fetched = repo.get_by_id(created.id, user_id).await.expect("Failed to get task"); assert!(fetched.is_some()); assert_eq!(fetched.unwrap().id, created.id); } #[tokio::test] async fn test_task_status_transitions() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Status test") .priority(Priority::High) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); assert_eq!(task.status, TaskStatus::Pending); // Start the task let started = repo.start(task.id, user_id).await.expect("Failed to start"); assert!(started); // Verify status changed let fetched = repo.get_by_id(task.id, user_id).await.expect("Failed to get"); assert_eq!(fetched.unwrap().status, TaskStatus::Started); // Complete the task let completed = repo.complete(task.id, user_id).await.expect("Failed to complete"); assert!(completed.is_some()); // Verify status changed let fetched = repo.get_by_id(task.id, user_id).await.expect("Failed to get"); assert_eq!(fetched.unwrap().status, TaskStatus::Completed); } #[tokio::test] async fn test_task_snooze() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Snooze test") .priority(Priority::Low) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); let until = Utc::now() + Duration::hours(2); let snoozed = repo.snooze(task.id, user_id, until).await.expect("Failed to snooze"); assert!(snoozed.is_some()); assert!(snoozed.as_ref().unwrap().snoozed_until.is_some()); // Should appear in snoozed list let snoozed_tasks = repo.list_snoozed(user_id).await.expect("Failed to list snoozed"); assert_eq!(snoozed_tasks.len(), 1); // Unsnooze let unsnoozed = repo.unsnooze(task.id, user_id).await.expect("Failed to unsnooze"); assert!(unsnoozed.is_some()); assert!(unsnoozed.unwrap().snoozed_until.is_none()); } #[tokio::test] async fn test_task_waiting_for_response() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Waiting test") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); let expected = Utc::now() + Duration::days(3); let waiting = repo.mark_waiting(task.id, user_id, Some(expected)).await.expect("Failed to mark waiting"); assert!(waiting.is_some()); assert!(waiting.as_ref().unwrap().waiting_for_response); // Should appear in waiting list let waiting_tasks = repo.list_waiting(user_id).await.expect("Failed to list waiting"); assert_eq!(waiting_tasks.len(), 1); // Clear waiting let cleared = repo.clear_waiting(task.id, user_id).await.expect("Failed to clear"); assert!(cleared.is_some()); assert!(!cleared.unwrap().waiting_for_response); } #[tokio::test] async fn test_task_with_due_date() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let due = Utc::now() + Duration::days(1); let new_task = NewTask::builder("Due tomorrow") .priority(Priority::High) .due(due) .tag("urgent") .urgency(8.5) // High priority task with urgency set .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); assert!(task.due.is_some()); // Urgency should be stored as provided assert!((task.urgency - 8.5).abs() < 0.01); } #[tokio::test] async fn test_delete_task() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("To delete") .priority(Priority::Low) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); let deleted = repo.delete(task.id, user_id).await.expect("Failed to delete"); assert!(deleted); // Task should not appear in normal list (deleted tasks are filtered out) let tasks = repo.list_all(user_id).await.expect("Failed to list"); assert!(tasks.iter().all(|t| t.id != task.id)); } #[tokio::test] async fn test_task_urgency_ordering() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); // Create low urgency task let low = NewTask::builder("Low urgency") .priority(Priority::Low) .urgency(2.0) .build(); repo.create(user_id, low).await.expect("Failed to create"); // Create high urgency task let high = NewTask::builder("High urgency") .priority(Priority::High) .urgency(8.0) .build(); repo.create(user_id, high).await.expect("Failed to create"); // Create highest urgency task (overdue) let overdue = NewTask::builder("Overdue task") .priority(Priority::Medium) .due(Utc::now() - Duration::days(1)) .urgency(15.0) // Overdue penalty applied .build(); repo.create(user_id, overdue).await.expect("Failed to create"); let tasks = repo.list_all(user_id).await.expect("Failed to list"); assert_eq!(tasks.len(), 3); // Tasks should be ordered by urgency descending assert!(tasks[0].urgency >= tasks[1].urgency); assert!(tasks[1].urgency >= tasks[2].urgency); // Verify the highest urgency task is first assert_eq!(tasks[0].description, "Overdue task"); } #[tokio::test] async fn test_complete_sets_completed_at() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Complete me") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); assert!(task.completed_at.is_none()); // Complete the task let completed = repo.complete(task.id, user_id).await.expect("Failed to complete"); assert!(completed.is_some()); let completed = completed.unwrap(); assert_eq!(completed.status, TaskStatus::Completed); assert!(completed.completed_at.is_some(), "completed_at should be set after completing"); } #[tokio::test] async fn test_list_completed_between_filters_by_date() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); // Create and complete two tasks let task1 = NewTask::builder("Task one") .priority(Priority::Medium) .build(); let t1 = repo.create(user_id, task1).await.expect("Failed to create"); repo.complete(t1.id, user_id).await.expect("Failed to complete"); let task2 = NewTask::builder("Task two") .priority(Priority::Low) .build(); let t2 = repo.create(user_id, task2).await.expect("Failed to create"); repo.complete(t2.id, user_id).await.expect("Failed to complete"); // Query with a range that includes now let start = Utc::now() - Duration::minutes(5); let end = Utc::now() + Duration::minutes(5); let results = repo.list_completed_between(user_id, start, end).await.expect("Failed to list"); assert_eq!(results.len(), 2, "Both tasks should be in the date range"); // Query with a range in the past — should return nothing let old_start = Utc::now() - Duration::days(30); let old_end = Utc::now() - Duration::days(29); let results = repo.list_completed_between(user_id, old_start, old_end).await.expect("Failed to list"); assert!(results.is_empty(), "No tasks should match a past date range"); } #[tokio::test] async fn test_update_to_completed_sets_completed_at() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Update to completed") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); assert!(task.completed_at.is_none()); // Update status to Completed via update() let update = UpdateTask { project_id: None, milestone_id: None, contact_id: None, description: task.description.clone(), status: TaskStatus::Completed, priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: Recurrence::None, recurrence_rule: None, urgency: task.urgency, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, }; let updated = repo.update(task.id, user_id, update).await.expect("Failed to update"); assert!(updated.is_some()); let updated = updated.unwrap(); assert_eq!(updated.status, TaskStatus::Completed); assert!(updated.completed_at.is_some(), "completed_at should be set when updating to Completed"); } #[tokio::test] async fn test_update_from_completed_clears_completed_at() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Revert from completed") .priority(Priority::High) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); // Complete it first let completed = repo.complete(task.id, user_id).await.expect("Failed to complete"); assert!(completed.is_some()); assert!(completed.unwrap().completed_at.is_some()); // Now move it back to Pending via update() let update = UpdateTask { project_id: None, milestone_id: None, contact_id: None, description: task.description.clone(), status: TaskStatus::Pending, priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: Recurrence::None, recurrence_rule: None, urgency: task.urgency, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, }; let reverted = repo.update(task.id, user_id, update).await.expect("Failed to update"); assert!(reverted.is_some()); let reverted = reverted.unwrap(); assert_eq!(reverted.status, TaskStatus::Pending); assert!(reverted.completed_at.is_none(), "completed_at should be cleared when moving away from Completed"); } #[tokio::test] async fn test_snooze_completed_task_fails() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Completed task") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); // Complete the task repo.complete(task.id, user_id).await.expect("Failed to complete"); // Attempt to snooze the completed task — should fail let until = Utc::now() + Duration::hours(2); let result = repo.snooze(task.id, user_id, until).await; assert!(result.is_err(), "Snoozing a completed task should return an error"); let err = result.unwrap_err(); assert!(err.is_validation(), "Error should be a validation error, got: {}", err); } #[tokio::test] async fn test_snooze_deleted_task_fails() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Deleted task") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); // Delete the task repo.delete(task.id, user_id).await.expect("Failed to delete"); // Attempt to snooze the deleted task — should fail let until = Utc::now() + Duration::hours(2); let result = repo.snooze(task.id, user_id, until).await; assert!(result.is_err(), "Snoozing a deleted task should return an error"); let err = result.unwrap_err(); assert!(err.is_validation(), "Error should be a validation error, got: {}", err); } #[tokio::test] async fn test_snooze_pending_task_succeeds() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Pending task") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); // Snooze the pending task — should succeed let until = Utc::now() + Duration::hours(2); let result = repo.snooze(task.id, user_id, until).await; assert!(result.is_ok(), "Snoozing a pending task should succeed"); let snoozed = result.unwrap(); assert!(snoozed.is_some()); assert!(snoozed.unwrap().snoozed_until.is_some()); } #[tokio::test] async fn test_snooze_started_task_succeeds() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let new_task = NewTask::builder("Started task") .priority(Priority::Medium) .build(); let task = repo.create(user_id, new_task).await.expect("Failed to create"); // Start the task first repo.start(task.id, user_id).await.expect("Failed to start"); // Snooze the started task — should succeed let until = Utc::now() + Duration::hours(2); let result = repo.snooze(task.id, user_id, until).await; assert!(result.is_ok(), "Snoozing a started task should succeed"); let snoozed = result.unwrap(); assert!(snoozed.is_some()); assert!(snoozed.unwrap().snoozed_until.is_some()); } // Backs GO-6: the windowed due query must include a task whose `due` lies // inside the UTC window and exclude one just outside it. The day_planning // command maps a user-local civil day to this window so the boundary respects // the user's timezone, not UTC midnight. #[tokio::test] async fn test_list_unscheduled_due_between_respects_window() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); // UTC-5 local day 2026-06-11 -> [05:00Z 11th, 05:00Z 12th]. let win_start = "2026-06-11T05:00:00Z".parse::>().unwrap(); let win_end = "2026-06-12T04:59:59Z".parse::>().unwrap(); // Due 02:00Z on the 12th = 21:00 local on the 11th -> inside the window. let inside = repo .create(user_id, NewTask::builder("Due late local 11th") .due("2026-06-12T02:00:00Z".parse::>().unwrap()) .build()) .await .expect("create inside"); // Due 06:00Z on the 12th = 01:00 local on the 12th -> outside the window. let _outside = repo .create(user_id, NewTask::builder("Due early local 12th") .due("2026-06-12T06:00:00Z".parse::>().unwrap()) .build()) .await .expect("create outside"); let found = repo .list_unscheduled_due_between(user_id, win_start, win_end) .await .expect("query window"); assert_eq!(found.len(), 1, "only the in-window task should match"); assert_eq!(found[0].id, inside.id); } #[tokio::test] async fn list_filtered_clamps_negative_limit() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); for i in 0..3 { let task = NewTask::builder(format!("Task {i}")).build(); repo.create(user_id, task).await.expect("Failed to create"); } // A negative LIMIT means "unbounded" in SQLite; the clamp must turn it into a // bounded page (0) rather than returning every row. let query = TaskFilterQuery { limit: Some(-1), ..Default::default() }; let (page, total) = repo.list_filtered(user_id, query).await.expect("list_filtered"); assert!(page.is_empty(), "negative limit must not return rows unbounded"); assert_eq!(total, 3, "total count is independent of the clamped limit"); } #[tokio::test] async fn test_bulk_set_priority_recomputes_urgency() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); let a = repo.create(user_id, NewTask::builder("A").priority(Priority::Low).build()).await.unwrap(); let b = repo.create(user_id, NewTask::builder("B").priority(Priority::Low).build()).await.unwrap(); let low_urgency = a.urgency; let n = repo.bulk_set_priority(user_id, &[a.id, b.id], Priority::High).await.unwrap(); assert_eq!(n, 2); let a2 = repo.get_by_id(a.id, user_id).await.unwrap().unwrap(); let b2 = repo.get_by_id(b.id, user_id).await.unwrap().unwrap(); assert_eq!(a2.priority, Priority::High); assert_eq!(b2.priority, Priority::High); assert!(a2.urgency > low_urgency, "urgency recomputed upward for higher priority"); } #[tokio::test] async fn test_bulk_set_project() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let tasks = SqliteTaskRepository::new(pool.clone()); let projects = SqliteProjectRepository::new(pool); let project = projects .create(user_id, NewProject { name: "Bulk".into(), description: String::new(), project_type: Default::default(), status: Default::default(), }) .await .unwrap(); let a = tasks.create(user_id, NewTask::builder("A").build()).await.unwrap(); let b = tasks.create(user_id, NewTask::builder("B").build()).await.unwrap(); let n = tasks.bulk_set_project(user_id, &[a.id, b.id], Some(project.id)).await.unwrap(); assert_eq!(n, 2); assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, Some(project.id)); // Clearing the project (None) also works. let n2 = tasks.bulk_set_project(user_id, &[a.id], None).await.unwrap(); assert_eq!(n2, 1); assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, None); } #[tokio::test] async fn test_update_persists_recurrence_rule() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteTaskRepository::new(pool); // Create a task with a custom weekly rule (every 2 weeks, Mon/Wed). let new_task = NewTask::builder("Recurring standup") .recurrence(Recurrence::Weekly) .recurrence_rule(RecurrenceRule { pattern: Recurrence::Weekly, interval: 2, weekdays: vec![0, 2], monthly_spec: None, }) .build(); let task = repo.create(user_id, new_task).await.expect("create"); let stored = task.recurrence_rule.expect("rule persisted on create"); assert_eq!(stored.interval, 2); assert_eq!(stored.weekdays, vec![0, 2]); // Edit the rule to every 3 weeks on Friday. The update path must persist the new rule, // not leave the stale create-time rule (GO-31-11). let update = UpdateTask { project_id: None, milestone_id: None, contact_id: None, description: task.description.clone(), status: task.status.clone(), priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: Recurrence::Weekly, recurrence_rule: Some(RecurrenceRule { pattern: Recurrence::Weekly, interval: 3, weekdays: vec![4], monthly_spec: None, }), urgency: task.urgency, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, }; let updated = repo.update(task.id, user_id, update).await.expect("update").unwrap(); let rule = updated.recurrence_rule.expect("rule present after update"); assert_eq!(rule.interval, 3, "edited interval must persist"); assert_eq!(rule.weekdays, vec![4], "edited weekdays must persist"); // Clearing the rule on update must null it out, not retain the old one. let clear = UpdateTask { project_id: None, milestone_id: None, contact_id: None, description: task.description.clone(), status: task.status.clone(), priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: Recurrence::None, recurrence_rule: None, urgency: task.urgency, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, }; let cleared = repo.update(task.id, user_id, clear).await.expect("update").unwrap(); assert!(cleared.recurrence_rule.is_none(), "cleared rule must not persist stale value"); }