Skip to main content

max / goingson

22.0 KB · 600 lines History Blame Raw
1 //! Integration tests for SqliteTaskRepository.
2
3 mod common;
4
5 use chrono::{Duration, Utc};
6 use goingson_core::{
7 NewProject, NewTask, Priority, ProjectRepository, Recurrence, RecurrenceRule, TaskFilterQuery,
8 TaskCrud, TaskScheduling, TaskStatus, UpdateTask,
9 };
10 use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
11
12 #[tokio::test]
13 async fn test_create_and_get_task() {
14 let pool = common::setup_test_db().await;
15 let user_id = common::create_test_user(&pool).await;
16 let repo = SqliteTaskRepository::new(pool);
17
18 let new_task = NewTask::builder("Test task")
19 .priority(Priority::Medium)
20 .tag("test")
21 .build();
22
23 let created = repo.create(user_id, new_task).await.expect("Failed to create task");
24 assert_eq!(created.description, "Test task");
25 assert_eq!(created.status, TaskStatus::Pending);
26 assert_eq!(created.priority, Priority::Medium);
27 assert_eq!(created.tags, vec!["test"]);
28
29 let fetched = repo.get_by_id(created.id, user_id).await.expect("Failed to get task");
30 assert!(fetched.is_some());
31 assert_eq!(fetched.unwrap().id, created.id);
32 }
33
34 #[tokio::test]
35 async fn test_task_status_transitions() {
36 let pool = common::setup_test_db().await;
37 let user_id = common::create_test_user(&pool).await;
38 let repo = SqliteTaskRepository::new(pool);
39
40 let new_task = NewTask::builder("Status test")
41 .priority(Priority::High)
42 .build();
43
44 let task = repo.create(user_id, new_task).await.expect("Failed to create");
45 assert_eq!(task.status, TaskStatus::Pending);
46
47 // Start the task
48 let started = repo.start(task.id, user_id).await.expect("Failed to start");
49 assert!(started);
50
51 // Verify status changed
52 let fetched = repo.get_by_id(task.id, user_id).await.expect("Failed to get");
53 assert_eq!(fetched.unwrap().status, TaskStatus::Started);
54
55 // Complete the task
56 let completed = repo.complete(task.id, user_id).await.expect("Failed to complete");
57 assert!(completed.is_some());
58
59 // Verify status changed
60 let fetched = repo.get_by_id(task.id, user_id).await.expect("Failed to get");
61 assert_eq!(fetched.unwrap().status, TaskStatus::Completed);
62 }
63
64 #[tokio::test]
65 async fn test_task_snooze() {
66 let pool = common::setup_test_db().await;
67 let user_id = common::create_test_user(&pool).await;
68 let repo = SqliteTaskRepository::new(pool);
69
70 let new_task = NewTask::builder("Snooze test")
71 .priority(Priority::Low)
72 .build();
73
74 let task = repo.create(user_id, new_task).await.expect("Failed to create");
75
76 let until = Utc::now() + Duration::hours(2);
77 let snoozed = repo.snooze(task.id, user_id, until).await.expect("Failed to snooze");
78 assert!(snoozed.is_some());
79 assert!(snoozed.as_ref().unwrap().snoozed_until.is_some());
80
81 // Should appear in snoozed list
82 let snoozed_tasks = repo.list_snoozed(user_id).await.expect("Failed to list snoozed");
83 assert_eq!(snoozed_tasks.len(), 1);
84
85 // Unsnooze
86 let unsnoozed = repo.unsnooze(task.id, user_id).await.expect("Failed to unsnooze");
87 assert!(unsnoozed.is_some());
88 assert!(unsnoozed.unwrap().snoozed_until.is_none());
89 }
90
91 #[tokio::test]
92 async fn test_task_waiting_for_response() {
93 let pool = common::setup_test_db().await;
94 let user_id = common::create_test_user(&pool).await;
95 let repo = SqliteTaskRepository::new(pool);
96
97 let new_task = NewTask::builder("Waiting test")
98 .priority(Priority::Medium)
99 .build();
100
101 let task = repo.create(user_id, new_task).await.expect("Failed to create");
102
103 let expected = Utc::now() + Duration::days(3);
104 let waiting = repo.mark_waiting(task.id, user_id, Some(expected)).await.expect("Failed to mark waiting");
105 assert!(waiting.is_some());
106 assert!(waiting.as_ref().unwrap().waiting_for_response);
107
108 // Should appear in waiting list
109 let waiting_tasks = repo.list_waiting(user_id).await.expect("Failed to list waiting");
110 assert_eq!(waiting_tasks.len(), 1);
111
112 // Clear waiting
113 let cleared = repo.clear_waiting(task.id, user_id).await.expect("Failed to clear");
114 assert!(cleared.is_some());
115 assert!(!cleared.unwrap().waiting_for_response);
116 }
117
118 #[tokio::test]
119 async fn test_task_with_due_date() {
120 let pool = common::setup_test_db().await;
121 let user_id = common::create_test_user(&pool).await;
122 let repo = SqliteTaskRepository::new(pool);
123
124 let due = Utc::now() + Duration::days(1);
125 let new_task = NewTask::builder("Due tomorrow")
126 .priority(Priority::High)
127 .due(due)
128 .tag("urgent")
129 .urgency(8.5) // High priority task with urgency set
130 .build();
131
132 let task = repo.create(user_id, new_task).await.expect("Failed to create");
133 assert!(task.due.is_some());
134 // Urgency should be stored as provided
135 assert!((task.urgency - 8.5).abs() < 0.01);
136 }
137
138 #[tokio::test]
139 async fn test_delete_task() {
140 let pool = common::setup_test_db().await;
141 let user_id = common::create_test_user(&pool).await;
142 let repo = SqliteTaskRepository::new(pool);
143
144 let new_task = NewTask::builder("To delete")
145 .priority(Priority::Low)
146 .build();
147
148 let task = repo.create(user_id, new_task).await.expect("Failed to create");
149
150 let deleted = repo.delete(task.id, user_id).await.expect("Failed to delete");
151 assert!(deleted);
152
153 // Task should not appear in normal list (deleted tasks are filtered out)
154 let tasks = repo.list_all(user_id).await.expect("Failed to list");
155 assert!(tasks.iter().all(|t| t.id != task.id));
156 }
157
158 #[tokio::test]
159 async fn test_task_urgency_ordering() {
160 let pool = common::setup_test_db().await;
161 let user_id = common::create_test_user(&pool).await;
162 let repo = SqliteTaskRepository::new(pool);
163
164 // Create low urgency task
165 let low = NewTask::builder("Low urgency")
166 .priority(Priority::Low)
167 .urgency(2.0)
168 .build();
169 repo.create(user_id, low).await.expect("Failed to create");
170
171 // Create high urgency task
172 let high = NewTask::builder("High urgency")
173 .priority(Priority::High)
174 .urgency(8.0)
175 .build();
176 repo.create(user_id, high).await.expect("Failed to create");
177
178 // Create highest urgency task (overdue)
179 let overdue = NewTask::builder("Overdue task")
180 .priority(Priority::Medium)
181 .due(Utc::now() - Duration::days(1))
182 .urgency(15.0) // Overdue penalty applied
183 .build();
184 repo.create(user_id, overdue).await.expect("Failed to create");
185
186 let tasks = repo.list_all(user_id).await.expect("Failed to list");
187 assert_eq!(tasks.len(), 3);
188
189 // Tasks should be ordered by urgency descending
190 assert!(tasks[0].urgency >= tasks[1].urgency);
191 assert!(tasks[1].urgency >= tasks[2].urgency);
192 // Verify the highest urgency task is first
193 assert_eq!(tasks[0].description, "Overdue task");
194 }
195
196 #[tokio::test]
197 async fn test_complete_sets_completed_at() {
198 let pool = common::setup_test_db().await;
199 let user_id = common::create_test_user(&pool).await;
200 let repo = SqliteTaskRepository::new(pool);
201
202 let new_task = NewTask::builder("Complete me")
203 .priority(Priority::Medium)
204 .build();
205
206 let task = repo.create(user_id, new_task).await.expect("Failed to create");
207 assert!(task.completed_at.is_none());
208
209 // Complete the task
210 let completed = repo.complete(task.id, user_id).await.expect("Failed to complete");
211 assert!(completed.is_some());
212 let completed = completed.unwrap();
213 assert_eq!(completed.status, TaskStatus::Completed);
214 assert!(completed.completed_at.is_some(), "completed_at should be set after completing");
215 }
216
217 #[tokio::test]
218 async fn test_list_completed_between_filters_by_date() {
219 let pool = common::setup_test_db().await;
220 let user_id = common::create_test_user(&pool).await;
221 let repo = SqliteTaskRepository::new(pool);
222
223 // Create and complete two tasks
224 let task1 = NewTask::builder("Task one")
225 .priority(Priority::Medium)
226 .build();
227 let t1 = repo.create(user_id, task1).await.expect("Failed to create");
228 repo.complete(t1.id, user_id).await.expect("Failed to complete");
229
230 let task2 = NewTask::builder("Task two")
231 .priority(Priority::Low)
232 .build();
233 let t2 = repo.create(user_id, task2).await.expect("Failed to create");
234 repo.complete(t2.id, user_id).await.expect("Failed to complete");
235
236 // Query with a range that includes now
237 let start = Utc::now() - Duration::minutes(5);
238 let end = Utc::now() + Duration::minutes(5);
239 let results = repo.list_completed_between(user_id, start, end).await.expect("Failed to list");
240 assert_eq!(results.len(), 2, "Both tasks should be in the date range");
241
242 // Query with a range in the past — should return nothing
243 let old_start = Utc::now() - Duration::days(30);
244 let old_end = Utc::now() - Duration::days(29);
245 let results = repo.list_completed_between(user_id, old_start, old_end).await.expect("Failed to list");
246 assert!(results.is_empty(), "No tasks should match a past date range");
247 }
248
249 #[tokio::test]
250 async fn test_update_to_completed_sets_completed_at() {
251 let pool = common::setup_test_db().await;
252 let user_id = common::create_test_user(&pool).await;
253 let repo = SqliteTaskRepository::new(pool);
254
255 let new_task = NewTask::builder("Update to completed")
256 .priority(Priority::Medium)
257 .build();
258
259 let task = repo.create(user_id, new_task).await.expect("Failed to create");
260 assert!(task.completed_at.is_none());
261
262 // Update status to Completed via update()
263 let update = UpdateTask {
264 project_id: None,
265 milestone_id: None,
266 contact_id: None,
267 description: task.description.clone(),
268 status: TaskStatus::Completed,
269 priority: task.priority.clone(),
270 due: task.due,
271 tags: task.tags.clone(),
272 recurrence: Recurrence::None,
273 recurrence_rule: None,
274 urgency: task.urgency,
275 scheduled_start: None,
276 scheduled_duration: None,
277 estimated_minutes: None,
278 };
279
280 let updated = repo.update(task.id, user_id, update).await.expect("Failed to update");
281 assert!(updated.is_some());
282 let updated = updated.unwrap();
283 assert_eq!(updated.status, TaskStatus::Completed);
284 assert!(updated.completed_at.is_some(), "completed_at should be set when updating to Completed");
285 }
286
287 #[tokio::test]
288 async fn test_update_from_completed_clears_completed_at() {
289 let pool = common::setup_test_db().await;
290 let user_id = common::create_test_user(&pool).await;
291 let repo = SqliteTaskRepository::new(pool);
292
293 let new_task = NewTask::builder("Revert from completed")
294 .priority(Priority::High)
295 .build();
296
297 let task = repo.create(user_id, new_task).await.expect("Failed to create");
298
299 // Complete it first
300 let completed = repo.complete(task.id, user_id).await.expect("Failed to complete");
301 assert!(completed.is_some());
302 assert!(completed.unwrap().completed_at.is_some());
303
304 // Now move it back to Pending via update()
305 let update = UpdateTask {
306 project_id: None,
307 milestone_id: None,
308 contact_id: None,
309 description: task.description.clone(),
310 status: TaskStatus::Pending,
311 priority: task.priority.clone(),
312 due: task.due,
313 tags: task.tags.clone(),
314 recurrence: Recurrence::None,
315 recurrence_rule: None,
316 urgency: task.urgency,
317 scheduled_start: None,
318 scheduled_duration: None,
319 estimated_minutes: None,
320 };
321
322 let reverted = repo.update(task.id, user_id, update).await.expect("Failed to update");
323 assert!(reverted.is_some());
324 let reverted = reverted.unwrap();
325 assert_eq!(reverted.status, TaskStatus::Pending);
326 assert!(reverted.completed_at.is_none(), "completed_at should be cleared when moving away from Completed");
327 }
328
329 #[tokio::test]
330 async fn test_snooze_completed_task_fails() {
331 let pool = common::setup_test_db().await;
332 let user_id = common::create_test_user(&pool).await;
333 let repo = SqliteTaskRepository::new(pool);
334
335 let new_task = NewTask::builder("Completed task")
336 .priority(Priority::Medium)
337 .build();
338
339 let task = repo.create(user_id, new_task).await.expect("Failed to create");
340
341 // Complete the task
342 repo.complete(task.id, user_id).await.expect("Failed to complete");
343
344 // Attempt to snooze the completed task — should fail
345 let until = Utc::now() + Duration::hours(2);
346 let result = repo.snooze(task.id, user_id, until).await;
347 assert!(result.is_err(), "Snoozing a completed task should return an error");
348 let err = result.unwrap_err();
349 assert!(err.is_validation(), "Error should be a validation error, got: {}", err);
350 }
351
352 #[tokio::test]
353 async fn test_snooze_deleted_task_fails() {
354 let pool = common::setup_test_db().await;
355 let user_id = common::create_test_user(&pool).await;
356 let repo = SqliteTaskRepository::new(pool);
357
358 let new_task = NewTask::builder("Deleted task")
359 .priority(Priority::Medium)
360 .build();
361
362 let task = repo.create(user_id, new_task).await.expect("Failed to create");
363
364 // Delete the task
365 repo.delete(task.id, user_id).await.expect("Failed to delete");
366
367 // Attempt to snooze the deleted task — should fail
368 let until = Utc::now() + Duration::hours(2);
369 let result = repo.snooze(task.id, user_id, until).await;
370 assert!(result.is_err(), "Snoozing a deleted task should return an error");
371 let err = result.unwrap_err();
372 assert!(err.is_validation(), "Error should be a validation error, got: {}", err);
373 }
374
375 #[tokio::test]
376 async fn test_snooze_pending_task_succeeds() {
377 let pool = common::setup_test_db().await;
378 let user_id = common::create_test_user(&pool).await;
379 let repo = SqliteTaskRepository::new(pool);
380
381 let new_task = NewTask::builder("Pending task")
382 .priority(Priority::Medium)
383 .build();
384
385 let task = repo.create(user_id, new_task).await.expect("Failed to create");
386
387 // Snooze the pending task — should succeed
388 let until = Utc::now() + Duration::hours(2);
389 let result = repo.snooze(task.id, user_id, until).await;
390 assert!(result.is_ok(), "Snoozing a pending task should succeed");
391 let snoozed = result.unwrap();
392 assert!(snoozed.is_some());
393 assert!(snoozed.unwrap().snoozed_until.is_some());
394 }
395
396 #[tokio::test]
397 async fn test_snooze_started_task_succeeds() {
398 let pool = common::setup_test_db().await;
399 let user_id = common::create_test_user(&pool).await;
400 let repo = SqliteTaskRepository::new(pool);
401
402 let new_task = NewTask::builder("Started task")
403 .priority(Priority::Medium)
404 .build();
405
406 let task = repo.create(user_id, new_task).await.expect("Failed to create");
407
408 // Start the task first
409 repo.start(task.id, user_id).await.expect("Failed to start");
410
411 // Snooze the started task — should succeed
412 let until = Utc::now() + Duration::hours(2);
413 let result = repo.snooze(task.id, user_id, until).await;
414 assert!(result.is_ok(), "Snoozing a started task should succeed");
415 let snoozed = result.unwrap();
416 assert!(snoozed.is_some());
417 assert!(snoozed.unwrap().snoozed_until.is_some());
418 }
419
420 // Backs GO-6: the windowed due query must include a task whose `due` lies
421 // inside the UTC window and exclude one just outside it. The day_planning
422 // command maps a user-local civil day to this window so the boundary respects
423 // the user's timezone, not UTC midnight.
424 #[tokio::test]
425 async fn test_list_unscheduled_due_between_respects_window() {
426 let pool = common::setup_test_db().await;
427 let user_id = common::create_test_user(&pool).await;
428 let repo = SqliteTaskRepository::new(pool);
429
430 // UTC-5 local day 2026-06-11 -> [05:00Z 11th, 05:00Z 12th].
431 let win_start = "2026-06-11T05:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap();
432 let win_end = "2026-06-12T04:59:59Z".parse::<chrono::DateTime<Utc>>().unwrap();
433
434 // Due 02:00Z on the 12th = 21:00 local on the 11th -> inside the window.
435 let inside = repo
436 .create(user_id, NewTask::builder("Due late local 11th")
437 .due("2026-06-12T02:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap())
438 .build())
439 .await
440 .expect("create inside");
441
442 // Due 06:00Z on the 12th = 01:00 local on the 12th -> outside the window.
443 let _outside = repo
444 .create(user_id, NewTask::builder("Due early local 12th")
445 .due("2026-06-12T06:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap())
446 .build())
447 .await
448 .expect("create outside");
449
450 let found = repo
451 .list_unscheduled_due_between(user_id, win_start, win_end)
452 .await
453 .expect("query window");
454
455 assert_eq!(found.len(), 1, "only the in-window task should match");
456 assert_eq!(found[0].id, inside.id);
457 }
458
459 #[tokio::test]
460 async fn list_filtered_clamps_negative_limit() {
461 let pool = common::setup_test_db().await;
462 let user_id = common::create_test_user(&pool).await;
463 let repo = SqliteTaskRepository::new(pool);
464
465 for i in 0..3 {
466 let task = NewTask::builder(format!("Task {i}")).build();
467 repo.create(user_id, task).await.expect("Failed to create");
468 }
469
470 // A negative LIMIT means "unbounded" in SQLite; the clamp must turn it into a
471 // bounded page (0) rather than returning every row.
472 let query = TaskFilterQuery {
473 limit: Some(-1),
474 ..Default::default()
475 };
476 let (page, total) = repo.list_filtered(user_id, query).await.expect("list_filtered");
477 assert!(page.is_empty(), "negative limit must not return rows unbounded");
478 assert_eq!(total, 3, "total count is independent of the clamped limit");
479 }
480
481 #[tokio::test]
482 async fn test_bulk_set_priority_recomputes_urgency() {
483 let pool = common::setup_test_db().await;
484 let user_id = common::create_test_user(&pool).await;
485 let repo = SqliteTaskRepository::new(pool);
486
487 let a = repo.create(user_id, NewTask::builder("A").priority(Priority::Low).build()).await.unwrap();
488 let b = repo.create(user_id, NewTask::builder("B").priority(Priority::Low).build()).await.unwrap();
489 let low_urgency = a.urgency;
490
491 let n = repo.bulk_set_priority(user_id, &[a.id, b.id], Priority::High).await.unwrap();
492 assert_eq!(n, 2);
493
494 let a2 = repo.get_by_id(a.id, user_id).await.unwrap().unwrap();
495 let b2 = repo.get_by_id(b.id, user_id).await.unwrap().unwrap();
496 assert_eq!(a2.priority, Priority::High);
497 assert_eq!(b2.priority, Priority::High);
498 assert!(a2.urgency > low_urgency, "urgency recomputed upward for higher priority");
499 }
500
501 #[tokio::test]
502 async fn test_bulk_set_project() {
503 let pool = common::setup_test_db().await;
504 let user_id = common::create_test_user(&pool).await;
505 let tasks = SqliteTaskRepository::new(pool.clone());
506 let projects = SqliteProjectRepository::new(pool);
507
508 let project = projects
509 .create(user_id, NewProject {
510 name: "Bulk".into(),
511 description: String::new(),
512 project_type: Default::default(),
513 status: Default::default(),
514 })
515 .await
516 .unwrap();
517
518 let a = tasks.create(user_id, NewTask::builder("A").build()).await.unwrap();
519 let b = tasks.create(user_id, NewTask::builder("B").build()).await.unwrap();
520
521 let n = tasks.bulk_set_project(user_id, &[a.id, b.id], Some(project.id)).await.unwrap();
522 assert_eq!(n, 2);
523 assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, Some(project.id));
524
525 // Clearing the project (None) also works.
526 let n2 = tasks.bulk_set_project(user_id, &[a.id], None).await.unwrap();
527 assert_eq!(n2, 1);
528 assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, None);
529 }
530
531 #[tokio::test]
532 async fn test_update_persists_recurrence_rule() {
533 let pool = common::setup_test_db().await;
534 let user_id = common::create_test_user(&pool).await;
535 let repo = SqliteTaskRepository::new(pool);
536
537 // Create a task with a custom weekly rule (every 2 weeks, Mon/Wed).
538 let new_task = NewTask::builder("Recurring standup")
539 .recurrence(Recurrence::Weekly)
540 .recurrence_rule(RecurrenceRule {
541 pattern: Recurrence::Weekly,
542 interval: 2,
543 weekdays: vec![0, 2],
544 monthly_spec: None,
545 })
546 .build();
547 let task = repo.create(user_id, new_task).await.expect("create");
548 let stored = task.recurrence_rule.expect("rule persisted on create");
549 assert_eq!(stored.interval, 2);
550 assert_eq!(stored.weekdays, vec![0, 2]);
551
552 // Edit the rule to every 3 weeks on Friday. The update path must persist the new rule,
553 // not leave the stale create-time rule (GO-31-11).
554 let update = UpdateTask {
555 project_id: None,
556 milestone_id: None,
557 contact_id: None,
558 description: task.description.clone(),
559 status: task.status.clone(),
560 priority: task.priority.clone(),
561 due: task.due,
562 tags: task.tags.clone(),
563 recurrence: Recurrence::Weekly,
564 recurrence_rule: Some(RecurrenceRule {
565 pattern: Recurrence::Weekly,
566 interval: 3,
567 weekdays: vec![4],
568 monthly_spec: None,
569 }),
570 urgency: task.urgency,
571 scheduled_start: None,
572 scheduled_duration: None,
573 estimated_minutes: None,
574 };
575 let updated = repo.update(task.id, user_id, update).await.expect("update").unwrap();
576 let rule = updated.recurrence_rule.expect("rule present after update");
577 assert_eq!(rule.interval, 3, "edited interval must persist");
578 assert_eq!(rule.weekdays, vec![4], "edited weekdays must persist");
579
580 // Clearing the rule on update must null it out, not retain the old one.
581 let clear = UpdateTask {
582 project_id: None,
583 milestone_id: None,
584 contact_id: None,
585 description: task.description.clone(),
586 status: task.status.clone(),
587 priority: task.priority.clone(),
588 due: task.due,
589 tags: task.tags.clone(),
590 recurrence: Recurrence::None,
591 recurrence_rule: None,
592 urgency: task.urgency,
593 scheduled_start: None,
594 scheduled_duration: None,
595 estimated_minutes: None,
596 };
597 let cleared = repo.update(task.id, user_id, clear).await.expect("update").unwrap();
598 assert!(cleared.recurrence_rule.is_none(), "cleared rule must not persist stale value");
599 }
600