Skip to main content

max / goingson

5.2 KB · 135 lines History Blame Raw
1 //! Migration 051 re-anchors recurrence chains to their earliest-created member.
2 //!
3 //! Migration 047 anchored backfilled chains at MIN(id) over random UUIDs (an
4 //! arbitrary sibling). 051 re-anchors each chain at its earliest task by
5 //! created_at so the stored data matches the runtime invariant (first instance =
6 //! root). These tests prove: (1) chain membership is complete regardless of which
7 //! sibling is the anchor -- the property the app actually relies on -- and (2) 051
8 //! moves the anchor to the earliest member without changing membership, and is
9 //! idempotent.
10
11 mod common;
12
13 use goingson_core::{NewTask, Recurrence, TaskId, TaskCrud};
14 use goingson_db_sqlite::SqliteTaskRepository;
15 use sqlx::SqlitePool;
16
17 const M051: &str = include_str!("../../../migrations/sqlite/051_reanchor_recurrence_chain.sql");
18
19 async fn set_created_at(pool: &SqlitePool, id: TaskId, when: &str) {
20 sqlx::query("UPDATE tasks SET created_at = ? WHERE id = ?")
21 .bind(when)
22 .bind(id.to_string())
23 .execute(pool)
24 .await
25 .unwrap();
26 }
27
28 async fn set_parent(pool: &SqlitePool, id: TaskId, parent: Option<TaskId>) {
29 sqlx::query("UPDATE tasks SET recurrence_parent_id = ? WHERE id = ?")
30 .bind(parent.map(|p| p.to_string()))
31 .bind(id.to_string())
32 .execute(pool)
33 .await
34 .unwrap();
35 }
36
37 async fn parent_of(repo: &SqliteTaskRepository, id: TaskId, user: goingson_core::UserId) -> Option<TaskId> {
38 repo.get_by_id(id, user).await.unwrap().unwrap().recurrence_parent_id
39 }
40
41 #[tokio::test]
42 async fn migration_051_reanchors_to_earliest_and_preserves_membership() {
43 let pool = common::setup_test_db().await;
44 let user = common::create_test_user(&pool).await;
45 let tasks = SqliteTaskRepository::new(pool.clone());
46
47 // Three daily-recurring instances of one series.
48 let mut made = Vec::new();
49 for _ in 0..3 {
50 made.push(
51 tasks
52 .create(
53 user,
54 NewTask::builder("Daily standup")
55 .recurrence(Recurrence::Daily)
56 .build(),
57 )
58 .await
59 .unwrap(),
60 );
61 }
62 let ids: Vec<TaskId> = made.iter().map(|t| t.id).collect();
63
64 // The arbitrary MIN(id) anchor migration 047 would have chosen.
65 let min_id = *ids.iter().min_by_key(|id| id.to_string()).unwrap();
66 // Earliest-created and middle: any two members that are not the MIN(id) one.
67 let others: Vec<TaskId> = ids.iter().copied().filter(|id| *id != min_id).collect();
68 let earliest = others[0];
69 let middle = others[1];
70
71 // Stamp created_at so the earliest-created member is deliberately NOT the
72 // MIN(id) anchor -- this is exactly the 047 defect.
73 set_created_at(&pool, earliest, "2024-01-01 00:00:00").await;
74 set_created_at(&pool, middle, "2024-03-01 00:00:00").await;
75 set_created_at(&pool, min_id, "2024-06-01 00:00:00").await;
76
77 // Simulate 047's post-state: everyone points at MIN(id); the anchor is root.
78 for id in &ids {
79 set_parent(&pool, *id, Some(min_id)).await;
80 }
81 set_parent(&pool, min_id, None).await;
82
83 // Property that actually matters: the chain is complete regardless of anchor.
84 let before = tasks.list_recurrence_chain(min_id, user).await.unwrap();
85 assert_eq!(before.len(), 3, "chain complete under the MIN(id) anchor");
86
87 // Re-anchor.
88 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
89
90 // Earliest-created is now the root; the others point at it; nobody else is root.
91 assert_eq!(parent_of(&tasks, earliest, user).await, None, "earliest is the new root");
92 assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest), "member points at earliest");
93 assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest), "old anchor is now a member");
94
95 // Membership is unchanged and still complete under the new root.
96 let after = tasks.list_recurrence_chain(earliest, user).await.unwrap();
97 assert_eq!(after.len(), 3, "chain complete under the re-anchored root");
98 let after_ids: std::collections::BTreeSet<String> =
99 after.iter().map(|t| t.id.to_string()).collect();
100 let want_ids: std::collections::BTreeSet<String> =
101 ids.iter().map(|id| id.to_string()).collect();
102 assert_eq!(after_ids, want_ids, "membership unchanged");
103
104 // Idempotent: a second run is a no-op.
105 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
106 assert_eq!(parent_of(&tasks, earliest, user).await, None);
107 assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest));
108 assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest));
109 }
110
111 #[tokio::test]
112 async fn migration_051_leaves_a_singleton_recurring_task_as_its_own_root() {
113 let pool = common::setup_test_db().await;
114 let user = common::create_test_user(&pool).await;
115 let tasks = SqliteTaskRepository::new(pool.clone());
116
117 let solo = tasks
118 .create(
119 user,
120 NewTask::builder("Weekly review")
121 .recurrence(Recurrence::Weekly)
122 .build(),
123 )
124 .await
125 .unwrap();
126
127 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
128
129 assert_eq!(
130 parent_of(&tasks, solo.id, user).await,
131 None,
132 "a lone recurring task stays its own root"
133 );
134 }
135