//! Migration 051 re-anchors recurrence chains to their earliest-created member. //! //! Migration 047 anchored backfilled chains at MIN(id) over random UUIDs (an //! arbitrary sibling). 051 re-anchors each chain at its earliest task by //! created_at so the stored data matches the runtime invariant (first instance = //! root). These tests prove: (1) chain membership is complete regardless of which //! sibling is the anchor, the property the app actually relies on, and (2) 051 //! moves the anchor to the earliest member without changing membership, and is //! idempotent. mod common; use goingson_core::{NewTask, Recurrence, TaskCrud, TaskId}; use goingson_db_sqlite::SqliteTaskRepository; use sqlx::SqlitePool; const M051: &str = include_str!("../../../migrations/sqlite/051_reanchor_recurrence_chain.sql"); async fn set_created_at(pool: &SqlitePool, id: TaskId, when: &str) { sqlx::query("UPDATE tasks SET created_at = ? WHERE id = ?") .bind(when) .bind(id.to_string()) .execute(pool) .await .unwrap(); } async fn set_parent(pool: &SqlitePool, id: TaskId, parent: Option) { sqlx::query("UPDATE tasks SET recurrence_parent_id = ? WHERE id = ?") .bind(parent.map(|p| p.to_string())) .bind(id.to_string()) .execute(pool) .await .unwrap(); } async fn parent_of( repo: &SqliteTaskRepository, id: TaskId, user: goingson_core::UserId, ) -> Option { repo.get_by_id(id, user) .await .unwrap() .unwrap() .recurrence_parent_id } #[tokio::test] async fn migration_051_reanchors_to_earliest_and_preserves_membership() { let pool = common::setup_test_db().await; let user = common::create_test_user(&pool).await; let tasks = SqliteTaskRepository::new(pool.clone()); // Three daily-recurring instances of one series. let mut made = Vec::new(); for _ in 0..3 { made.push( tasks .create( user, NewTask::builder("Daily standup") .recurrence(Recurrence::Daily) .build(), ) .await .unwrap(), ); } let ids: Vec = made.iter().map(|t| t.id).collect(); // The arbitrary MIN(id) anchor migration 047 would have chosen. let min_id = *ids .iter() .min_by_key(std::string::ToString::to_string) .unwrap(); // Earliest-created and middle: any two members that are not the MIN(id) one. let others: Vec = ids.iter().copied().filter(|id| *id != min_id).collect(); let earliest = others[0]; let middle = others[1]; // Stamp created_at so the earliest-created member is deliberately NOT the // MIN(id) anchor, this is exactly the 047 defect. set_created_at(&pool, earliest, "2024-01-01 00:00:00").await; set_created_at(&pool, middle, "2024-03-01 00:00:00").await; set_created_at(&pool, min_id, "2024-06-01 00:00:00").await; // Simulate 047's post-state: everyone points at MIN(id); the anchor is root. for id in &ids { set_parent(&pool, *id, Some(min_id)).await; } set_parent(&pool, min_id, None).await; // Property that actually matters: the chain is complete regardless of anchor. let before = tasks.list_recurrence_chain(min_id, user).await.unwrap(); assert_eq!(before.len(), 3, "chain complete under the MIN(id) anchor"); // Re-anchor. sqlx::raw_sql(M051).execute(&pool).await.unwrap(); // Earliest-created is now the root; the others point at it; nobody else is root. assert_eq!( parent_of(&tasks, earliest, user).await, None, "earliest is the new root" ); assert_eq!( parent_of(&tasks, middle, user).await, Some(earliest), "member points at earliest" ); assert_eq!( parent_of(&tasks, min_id, user).await, Some(earliest), "old anchor is now a member" ); // Membership is unchanged and still complete under the new root. let after = tasks.list_recurrence_chain(earliest, user).await.unwrap(); assert_eq!(after.len(), 3, "chain complete under the re-anchored root"); let after_ids: std::collections::BTreeSet = after.iter().map(|t| t.id.to_string()).collect(); let want_ids: std::collections::BTreeSet = ids.iter().map(std::string::ToString::to_string).collect(); assert_eq!(after_ids, want_ids, "membership unchanged"); // Idempotent: a second run is a no-op. sqlx::raw_sql(M051).execute(&pool).await.unwrap(); assert_eq!(parent_of(&tasks, earliest, user).await, None); assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest)); assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest)); } #[tokio::test] async fn migration_051_leaves_a_singleton_recurring_task_as_its_own_root() { let pool = common::setup_test_db().await; let user = common::create_test_user(&pool).await; let tasks = SqliteTaskRepository::new(pool.clone()); let solo = tasks .create( user, NewTask::builder("Weekly review") .recurrence(Recurrence::Weekly) .build(), ) .await .unwrap(); sqlx::raw_sql(M051).execute(&pool).await.unwrap(); assert_eq!( parent_of(&tasks, solo.id, user).await, None, "a lone recurring task stays its own root" ); }