|
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, TaskRepository};
|
|
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 |
+ |
}
|