Skip to main content

max / goingson

5.4 KB · 158 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, TaskCrud, TaskId};
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(
38 repo: &SqliteTaskRepository,
39 id: TaskId,
40 user: goingson_core::UserId,
41 ) -> Option<TaskId> {
42 repo.get_by_id(id, user)
43 .await
44 .unwrap()
45 .unwrap()
46 .recurrence_parent_id
47 }
48
49 #[tokio::test]
50 async fn migration_051_reanchors_to_earliest_and_preserves_membership() {
51 let pool = common::setup_test_db().await;
52 let user = common::create_test_user(&pool).await;
53 let tasks = SqliteTaskRepository::new(pool.clone());
54
55 // Three daily-recurring instances of one series.
56 let mut made = Vec::new();
57 for _ in 0..3 {
58 made.push(
59 tasks
60 .create(
61 user,
62 NewTask::builder("Daily standup")
63 .recurrence(Recurrence::Daily)
64 .build(),
65 )
66 .await
67 .unwrap(),
68 );
69 }
70 let ids: Vec<TaskId> = made.iter().map(|t| t.id).collect();
71
72 // The arbitrary MIN(id) anchor migration 047 would have chosen.
73 let min_id = *ids
74 .iter()
75 .min_by_key(std::string::ToString::to_string)
76 .unwrap();
77 // Earliest-created and middle: any two members that are not the MIN(id) one.
78 let others: Vec<TaskId> = ids.iter().copied().filter(|id| *id != min_id).collect();
79 let earliest = others[0];
80 let middle = others[1];
81
82 // Stamp created_at so the earliest-created member is deliberately NOT the
83 // MIN(id) anchor, this is exactly the 047 defect.
84 set_created_at(&pool, earliest, "2024-01-01 00:00:00").await;
85 set_created_at(&pool, middle, "2024-03-01 00:00:00").await;
86 set_created_at(&pool, min_id, "2024-06-01 00:00:00").await;
87
88 // Simulate 047's post-state: everyone points at MIN(id); the anchor is root.
89 for id in &ids {
90 set_parent(&pool, *id, Some(min_id)).await;
91 }
92 set_parent(&pool, min_id, None).await;
93
94 // Property that actually matters: the chain is complete regardless of anchor.
95 let before = tasks.list_recurrence_chain(min_id, user).await.unwrap();
96 assert_eq!(before.len(), 3, "chain complete under the MIN(id) anchor");
97
98 // Re-anchor.
99 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
100
101 // Earliest-created is now the root; the others point at it; nobody else is root.
102 assert_eq!(
103 parent_of(&tasks, earliest, user).await,
104 None,
105 "earliest is the new root"
106 );
107 assert_eq!(
108 parent_of(&tasks, middle, user).await,
109 Some(earliest),
110 "member points at earliest"
111 );
112 assert_eq!(
113 parent_of(&tasks, min_id, user).await,
114 Some(earliest),
115 "old anchor is now a member"
116 );
117
118 // Membership is unchanged and still complete under the new root.
119 let after = tasks.list_recurrence_chain(earliest, user).await.unwrap();
120 assert_eq!(after.len(), 3, "chain complete under the re-anchored root");
121 let after_ids: std::collections::BTreeSet<String> =
122 after.iter().map(|t| t.id.to_string()).collect();
123 let want_ids: std::collections::BTreeSet<String> =
124 ids.iter().map(std::string::ToString::to_string).collect();
125 assert_eq!(after_ids, want_ids, "membership unchanged");
126
127 // Idempotent: a second run is a no-op.
128 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
129 assert_eq!(parent_of(&tasks, earliest, user).await, None);
130 assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest));
131 assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest));
132 }
133
134 #[tokio::test]
135 async fn migration_051_leaves_a_singleton_recurring_task_as_its_own_root() {
136 let pool = common::setup_test_db().await;
137 let user = common::create_test_user(&pool).await;
138 let tasks = SqliteTaskRepository::new(pool.clone());
139
140 let solo = tasks
141 .create(
142 user,
143 NewTask::builder("Weekly review")
144 .recurrence(Recurrence::Weekly)
145 .build(),
146 )
147 .await
148 .unwrap();
149
150 sqlx::raw_sql(M051).execute(&pool).await.unwrap();
151
152 assert_eq!(
153 parent_of(&tasks, solo.id, user).await,
154 None,
155 "a lone recurring task stays its own root"
156 );
157 }
158