Skip to main content

max / goingson

14.2 KB · 472 lines History Blame Raw
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, TaskCrud, TaskScheduling};
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
18 .create(user_id, NewTask::builder("Focus me").build())
19 .await
20 .unwrap();
21
22 // Not focused initially.
23 assert!(repo.list_focused(user_id).await.unwrap().is_empty());
24
25 let focused = repo
26 .set_focus(task.id, user_id, true)
27 .await
28 .expect("set focus")
29 .unwrap();
30 assert!(focused.is_focus);
31 assert!(
32 focused.focus_set_at.is_some(),
33 "focus_set_at should be stamped"
34 );
35
36 let list = repo.list_focused(user_id).await.unwrap();
37 assert_eq!(list.len(), 1);
38 assert_eq!(list[0].id, task.id);
39
40 // Clearing focus removes it from the set.
41 let unfocused = repo
42 .set_focus(task.id, user_id, false)
43 .await
44 .expect("clear focus")
45 .unwrap();
46 assert!(!unfocused.is_focus);
47 assert!(
48 unfocused.focus_set_at.is_none(),
49 "focus_set_at should be nulled"
50 );
51 assert!(repo.list_focused(user_id).await.unwrap().is_empty());
52 }
53
54 #[tokio::test]
55 async fn test_clear_all_focus() {
56 let pool = common::setup_test_db().await;
57 let user_id = common::create_test_user(&pool).await;
58 let repo = SqliteTaskRepository::new(pool);
59
60 let a = repo
61 .create(user_id, NewTask::builder("A").build())
62 .await
63 .unwrap();
64 let b = repo
65 .create(user_id, NewTask::builder("B").build())
66 .await
67 .unwrap();
68 repo.set_focus(a.id, user_id, true).await.unwrap();
69 repo.set_focus(b.id, user_id, true).await.unwrap();
70 assert_eq!(repo.list_focused(user_id).await.unwrap().len(), 2);
71
72 let cleared = repo.clear_all_focus(user_id).await.expect("clear all");
73 assert_eq!(cleared, 2, "both focused tasks should be cleared");
74 assert!(repo.list_focused(user_id).await.unwrap().is_empty());
75
76 // A second call clears nothing.
77 assert_eq!(repo.clear_all_focus(user_id).await.unwrap(), 0);
78 }
79
80 #[tokio::test]
81 async fn test_focus_is_per_user_scoped() {
82 let pool = common::setup_test_db().await;
83 let user_a = common::create_test_user(&pool).await;
84 let user_b = common::create_test_user(&pool).await;
85 let repo = SqliteTaskRepository::new(pool);
86
87 let task_a = repo
88 .create(user_a, NewTask::builder("A's task").build())
89 .await
90 .unwrap();
91 let task_b = repo
92 .create(user_b, NewTask::builder("B's task").build())
93 .await
94 .unwrap();
95 repo.set_focus(task_a.id, user_a, true).await.unwrap();
96 repo.set_focus(task_b.id, user_b, true).await.unwrap();
97
98 // Each user only sees their own focused task.
99 let a_list = repo.list_focused(user_a).await.unwrap();
100 assert_eq!(a_list.len(), 1);
101 assert_eq!(a_list[0].id, task_a.id);
102
103 // Clearing all focus for A must not touch B.
104 repo.clear_all_focus(user_a).await.unwrap();
105 assert!(repo.list_focused(user_a).await.unwrap().is_empty());
106 let b_list = repo.list_focused(user_b).await.unwrap();
107 assert_eq!(b_list.len(), 1, "clearing A's focus must not affect B");
108 assert_eq!(b_list[0].id, task_b.id);
109 }
110
111 #[tokio::test]
112 async fn test_list_available_for_focus_candidate_set() {
113 let pool = common::setup_test_db().await;
114 let user_id = common::create_test_user(&pool).await;
115 let repo = SqliteTaskRepository::new(pool);
116
117 // Candidate: pending, not focused, not snoozed, not waiting.
118 let candidate = repo
119 .create(
120 user_id,
121 NewTask::builder("Candidate")
122 .priority(Priority::High)
123 .build(),
124 )
125 .await
126 .unwrap();
127
128 // Excluded: already focused.
129 let focused = repo
130 .create(user_id, NewTask::builder("Focused").build())
131 .await
132 .unwrap();
133 repo.set_focus(focused.id, user_id, true).await.unwrap();
134
135 // Excluded: snoozed into the future.
136 let snoozed = repo
137 .create(user_id, NewTask::builder("Snoozed").build())
138 .await
139 .unwrap();
140 repo.snooze(snoozed.id, user_id, Utc::now() + Duration::hours(2))
141 .await
142 .unwrap();
143
144 // Excluded: waiting for response.
145 let waiting = repo
146 .create(user_id, NewTask::builder("Waiting").build())
147 .await
148 .unwrap();
149 repo.mark_waiting(waiting.id, user_id, None).await.unwrap();
150
151 // Excluded: completed.
152 let done = repo
153 .create(user_id, NewTask::builder("Done").build())
154 .await
155 .unwrap();
156 repo.complete(done.id, user_id).await.unwrap();
157
158 let available = repo
159 .list_available_for_focus(user_id, 100)
160 .await
161 .expect("list available");
162 let ids: Vec<_> = available.iter().map(|t| t.id).collect();
163 assert_eq!(
164 ids,
165 vec![candidate.id],
166 "only the pending unfocused task should be a focus candidate"
167 );
168 }
169
170 // Scheduling
171
172 #[tokio::test]
173 async fn test_update_schedule_persists_and_clears() {
174 let pool = common::setup_test_db().await;
175 let user_id = common::create_test_user(&pool).await;
176 let repo = SqliteTaskRepository::new(pool);
177
178 let task = repo
179 .create(user_id, NewTask::builder("Schedule me").build())
180 .await
181 .unwrap();
182 assert!(task.scheduled_start.is_none());
183 assert!(task.scheduled_duration.is_none());
184
185 let start = Utc.with_ymd_and_hms(2026, 8, 15, 14, 0, 0).unwrap();
186 let updated = repo
187 .update_schedule(task.id, user_id, Some(start), Some(90))
188 .await
189 .expect("schedule")
190 .unwrap();
191 assert_eq!(updated.scheduled_start, Some(start));
192 assert_eq!(updated.scheduled_duration, Some(90));
193
194 // Re-fetch to confirm persistence.
195 let fetched = repo.get_by_id(task.id, user_id).await.unwrap().unwrap();
196 assert_eq!(fetched.scheduled_start, Some(start));
197 assert_eq!(fetched.scheduled_duration, Some(90));
198
199 // Passing None clears the schedule.
200 let cleared = repo
201 .update_schedule(task.id, user_id, None, None)
202 .await
203 .expect("clear schedule")
204 .unwrap();
205 assert!(cleared.scheduled_start.is_none());
206 assert!(cleared.scheduled_duration.is_none());
207 let refetched = repo.get_by_id(task.id, user_id).await.unwrap().unwrap();
208 assert!(refetched.scheduled_start.is_none());
209 assert!(refetched.scheduled_duration.is_none());
210 }
211
212 #[tokio::test]
213 async fn test_list_scheduled_for_date() {
214 let pool = common::setup_test_db().await;
215 let user_id = common::create_test_user(&pool).await;
216 let repo = SqliteTaskRepository::new(pool);
217
218 let on_date = repo
219 .create(user_id, NewTask::builder("On date").build())
220 .await
221 .unwrap();
222 let off_date = repo
223 .create(user_id, NewTask::builder("Off date").build())
224 .await
225 .unwrap();
226 let _unscheduled = repo
227 .create(user_id, NewTask::builder("Unscheduled").build())
228 .await
229 .unwrap();
230
231 let target = Utc.with_ymd_and_hms(2026, 8, 15, 9, 30, 0).unwrap();
232 let other = Utc.with_ymd_and_hms(2026, 8, 16, 9, 30, 0).unwrap();
233 repo.update_schedule(on_date.id, user_id, Some(target), Some(60))
234 .await
235 .unwrap();
236 repo.update_schedule(off_date.id, user_id, Some(other), Some(60))
237 .await
238 .unwrap();
239
240 let date = chrono::NaiveDate::from_ymd_opt(2026, 8, 15).unwrap();
241 let scheduled = repo
242 .list_scheduled_for_date(user_id, date)
243 .await
244 .expect("list scheduled");
245 let ids: Vec<_> = scheduled.iter().map(|t| t.id).collect();
246 assert_eq!(
247 ids,
248 vec![on_date.id],
249 "only the task scheduled on the target date should match"
250 );
251 }
252
253 #[tokio::test]
254 async fn test_list_scheduled_for_date_excludes_completed() {
255 let pool = common::setup_test_db().await;
256 let user_id = common::create_test_user(&pool).await;
257 let repo = SqliteTaskRepository::new(pool);
258
259 let task = repo
260 .create(user_id, NewTask::builder("Scheduled then done").build())
261 .await
262 .unwrap();
263 let start = Utc.with_ymd_and_hms(2026, 8, 15, 9, 30, 0).unwrap();
264 repo.update_schedule(task.id, user_id, Some(start), Some(60))
265 .await
266 .unwrap();
267
268 let date = chrono::NaiveDate::from_ymd_opt(2026, 8, 15).unwrap();
269 assert_eq!(
270 repo.list_scheduled_for_date(user_id, date)
271 .await
272 .unwrap()
273 .len(),
274 1
275 );
276
277 repo.complete(task.id, user_id).await.unwrap();
278 assert!(
279 repo.list_scheduled_for_date(user_id, date)
280 .await
281 .unwrap()
282 .is_empty(),
283 "completed tasks should not appear in the scheduled-for-date list"
284 );
285 }
286
287 // Reporting windows
288
289 #[tokio::test]
290 async fn test_list_due_between_inclusive_boundaries() {
291 let pool = common::setup_test_db().await;
292 let user_id = common::create_test_user(&pool).await;
293 let repo = SqliteTaskRepository::new(pool);
294
295 let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
296 let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
297
298 let at_start = repo
299 .create(user_id, NewTask::builder("At start").due(start).build())
300 .await
301 .unwrap();
302 let at_end = repo
303 .create(user_id, NewTask::builder("At end").due(end).build())
304 .await
305 .unwrap();
306 let inside = repo
307 .create(
308 user_id,
309 NewTask::builder("Inside")
310 .due(Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap())
311 .build(),
312 )
313 .await
314 .unwrap();
315 let _before = repo
316 .create(
317 user_id,
318 NewTask::builder("Before")
319 .due(start - Duration::seconds(1))
320 .build(),
321 )
322 .await
323 .unwrap();
324 let _after = repo
325 .create(
326 user_id,
327 NewTask::builder("After")
328 .due(end + Duration::seconds(1))
329 .build(),
330 )
331 .await
332 .unwrap();
333
334 let found = repo
335 .list_due_between(user_id, start, end)
336 .await
337 .expect("list due between");
338 let ids: Vec<_> = found.iter().map(|t| t.id).collect();
339 assert_eq!(
340 ids.len(),
341 3,
342 "window is inclusive on both bounds and excludes tasks just outside"
343 );
344 for want in [at_start.id, at_end.id, inside.id] {
345 assert!(ids.contains(&want), "expected task {want} in window");
346 }
347 }
348
349 #[tokio::test]
350 async fn test_list_due_between_excludes_completed() {
351 let pool = common::setup_test_db().await;
352 let user_id = common::create_test_user(&pool).await;
353 let repo = SqliteTaskRepository::new(pool);
354
355 let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
356 let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
357
358 let task = repo
359 .create(
360 user_id,
361 NewTask::builder("Due and done")
362 .due(Utc.with_ymd_and_hms(2026, 9, 10, 0, 0, 0).unwrap())
363 .build(),
364 )
365 .await
366 .unwrap();
367 assert_eq!(
368 repo.list_due_between(user_id, start, end)
369 .await
370 .unwrap()
371 .len(),
372 1
373 );
374 repo.complete(task.id, user_id).await.unwrap();
375 assert!(
376 repo.list_due_between(user_id, start, end)
377 .await
378 .unwrap()
379 .is_empty()
380 );
381 }
382
383 #[tokio::test]
384 async fn test_list_became_overdue_between_window() {
385 let pool = common::setup_test_db().await;
386 let user_id = common::create_test_user(&pool).await;
387 let repo = SqliteTaskRepository::new(pool);
388
389 let start = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
390 let end = Utc.with_ymd_and_hms(2026, 9, 30, 23, 59, 59).unwrap();
391
392 let inside = repo
393 .create(
394 user_id,
395 NewTask::builder("Overdue inside")
396 .due(Utc.with_ymd_and_hms(2026, 9, 15, 8, 0, 0).unwrap())
397 .build(),
398 )
399 .await
400 .unwrap();
401 let _outside = repo
402 .create(
403 user_id,
404 NewTask::builder("Overdue outside")
405 .due(end + Duration::seconds(1))
406 .build(),
407 )
408 .await
409 .unwrap();
410 // Completed tasks are excluded even if due in range.
411 let done = repo
412 .create(
413 user_id,
414 NewTask::builder("Overdue done")
415 .due(Utc.with_ymd_and_hms(2026, 9, 20, 8, 0, 0).unwrap())
416 .build(),
417 )
418 .await
419 .unwrap();
420 repo.complete(done.id, user_id).await.unwrap();
421
422 let found = repo
423 .list_became_overdue_between(user_id, start, end)
424 .await
425 .expect("list overdue between");
426 let ids: Vec<_> = found.iter().map(|t| t.id).collect();
427 assert_eq!(
428 ids,
429 vec![inside.id],
430 "only the non-completed task due inside the window should match"
431 );
432 }
433
434 #[tokio::test]
435 async fn test_list_created_between_window() {
436 let pool = common::setup_test_db().await;
437 let user_id = common::create_test_user(&pool).await;
438 let repo = SqliteTaskRepository::new(pool);
439
440 let a = repo
441 .create(user_id, NewTask::builder("Created one").build())
442 .await
443 .unwrap();
444 let b = repo
445 .create(user_id, NewTask::builder("Created two").build())
446 .await
447 .unwrap();
448
449 // A window around now includes both freshly created tasks.
450 let start = Utc::now() - Duration::minutes(5);
451 let end = Utc::now() + Duration::minutes(5);
452 let found = repo
453 .list_created_between(user_id, start, end)
454 .await
455 .expect("list created between");
456 let ids: Vec<_> = found.iter().map(|t| t.id).collect();
457 assert!(
458 ids.contains(&a.id) && ids.contains(&b.id),
459 "both tasks created inside the window should match"
460 );
461
462 // A past window matches nothing.
463 let old_start = Utc::now() - Duration::days(30);
464 let old_end = Utc::now() - Duration::days(29);
465 assert!(
466 repo.list_created_between(user_id, old_start, old_end)
467 .await
468 .unwrap()
469 .is_empty()
470 );
471 }
472