//! What migration 067 does to data that already exists. //! //! The one migration in this app that GUESSES. Max accepted that (task //! `8ee5c4fe`) on the condition that the guess is reversible per record, so //! these tests are where the boundary of the guess is written down: what //! converts, what is deliberately left alone, and what a converted record keeps //! so it can be put back. //! //! They run the migration by hand rather than through `run_migrations`, because //! the fixtures have to exist BEFORE 067 applies and the ordinary test database //! arrives with every migration already run. use rusqlite::{Connection, params}; /// Every migration up to but not including 067, then the fixtures, then 067. /// /// Reads the files from the migrations directory rather than embedding them: /// this test is about the shipped SQL, and a copy of it here would be a second /// migration to keep in step. fn upto_067() -> Connection { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite"); let mut files: Vec<_> = std::fs::read_dir(&dir) .expect("the migrations directory") .filter_map(|entry| { let path = entry.ok()?.path(); let name = path.file_name()?.to_str()?.to_owned(); let version: u32 = name.split('_').next()?.parse().ok()?; (version < 67).then_some((version, path)) }) .collect(); files.sort(); let conn = Connection::open_in_memory().expect("an in-memory database"); conn.execute_batch("PRAGMA foreign_keys = OFF") .expect("fk off while migrating"); for (version, path) in files { let sql = std::fs::read_to_string(&path).expect("a migration file"); conn.execute_batch(&sql) .unwrap_or_else(|e| panic!("migration {version} failed: {e}")); } conn } fn apply_067(conn: &Connection) { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../migrations/sqlite/067_contexts.sql"); let sql = std::fs::read_to_string(path).expect("067 exists"); conn.execute_batch(&sql).expect("067 applies"); } fn user(conn: &Connection) -> String { let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?1, ?2, 'x', 'Test', datetime('now'))", params![id, format!("{id}@example.com")], ) .expect("a user"); id } /// An event over `start`..`end`, both UTC text. `end` may be absent. fn event(conn: &Connection, user_id: &str, title: &str, start: &str, end: Option<&str>) -> String { let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO events (id, user_id, title, start_time, end_time) VALUES (?1, ?2, ?3, ?4, ?5)", params![id, user_id, title, start, end], ) .expect("an event"); id } fn review(conn: &Connection, user_id: &str, week_start: &str, vacation_days: &str) { conn.execute( "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days) VALUES (?1, ?2, ?3, datetime('now'), '', ?4)", params![uuid::Uuid::new_v4().to_string(), user_id, week_start, vacation_days], ) .expect("a weekly review"); } fn contexts(conn: &Connection) -> Vec<(String, String, String, String)> { let mut stmt = conn .prepare("SELECT label, kind, starts_on, ends_on FROM contexts ORDER BY starts_on, label") .expect("the contexts table exists"); stmt.query_map([], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) }) .expect("rows") .collect::, _>>() .expect("rows") } #[test] fn a_multi_day_event_becomes_a_context_and_a_shorter_one_does_not() { let conn = upto_067(); let me = user(&conn); // The case the whole ruling came from: the same event was an occupancy on // Wednesday, a context on Thursday, and an occupancy again on Friday. event( &conn, &me, "Conference", "2026-08-05 14:00:00", Some("2026-08-07 17:00:00"), ); // Crosses a midnight and covers no whole day. An occupancy, and the false // positive a "does it end on a later date" test would have converted. event( &conn, &me, "Red-eye to Berlin", "2026-08-10 22:00:00", Some("2026-08-11 06:00:00"), ); // A lone midnight-to-midnight marker. Ruling 1 leaves this alone by name. event( &conn, &me, "Tax deadline", "2026-08-15 00:00:00", Some("2026-08-16 00:00:00"), ); // No end at all. event(&conn, &me, "Standup", "2026-08-17 09:00:00", None); apply_067(&conn); let found = contexts(&conn); assert_eq!(found.len(), 1, "only the conference converts: {found:?}"); let (label, kind, starts, ends) = &found[0]; assert_eq!(label, "Conference"); assert_eq!(kind, "Other", "an event says nothing about what kind it is"); assert_eq!(starts, "2026-08-05"); assert_eq!(ends, "2026-08-07"); } #[test] fn an_event_ending_at_midnight_does_not_reach_into_that_day() { let conn = upto_067(); let me = user(&conn); // Two whole days, ending exactly at the third midnight. event( &conn, &me, "Offsite", "2026-08-05 00:00:00", Some("2026-08-07 00:00:00"), ); apply_067(&conn); let found = contexts(&conn); assert_eq!(found.len(), 1); assert_eq!(found[0].2, "2026-08-05"); assert_eq!(found[0].3, "2026-08-06", "the 7th is not inside it"); } #[test] fn a_converted_event_is_hidden_rather_than_deleted_and_names_its_context() { let conn = upto_067(); let me = user(&conn); let conference = event( &conn, &me, "Conference", "2026-08-05 14:00:00", Some("2026-08-07 17:00:00"), ); apply_067(&conn); // The event is still there with everything it had. Reversal is clearing one // column, not retyping a time. let (title, start, converted): (String, String, Option) = conn .query_row( "SELECT title, start_time, converted_to_context_id FROM events WHERE id = ?1", params![conference], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .expect("the event survives"); assert_eq!(title, "Conference"); assert_eq!(start, "2026-08-05 14:00:00"); let context_id = converted.expect("it points at the context it became"); // And the pointer goes both ways. let back: String = conn .query_row( "SELECT migrated_from_event_id FROM contexts WHERE id = ?1", params![context_id], |row| row.get(0), ) .expect("the context names the event"); assert_eq!(back, conference); } #[test] fn vacation_days_become_spans_and_a_run_across_two_weeks_is_one_context() { let conn = upto_067(); let me = user(&conn); // Ruling 2's example. Week of Mon 2026-08-03: Thu..Sun marked (3,4,5,6). // Week of Mon 2026-08-10: Mon,Tue marked (0,1). Nobody recording a // six-day holiday meant two holidays. review(&conn, &me, "2026-08-03", "3,4,5,6"); review(&conn, &me, "2026-08-10", "0,1"); // A separate run later the same month, with a clear day in between. review(&conn, &me, "2026-08-17", "4"); apply_067(&conn); let found = contexts(&conn); assert_eq!(found.len(), 2, "two runs, not four weeks: {found:?}"); assert_eq!(found[0].0, "Vacation"); assert_eq!(found[0].1, "Vacation"); assert_eq!(found[0].2, "2026-08-06", "Thursday of the first week"); assert_eq!(found[0].3, "2026-08-11", "Tuesday of the second"); assert_eq!(found[1].2, "2026-08-21"); assert_eq!(found[1].3, "2026-08-21", "one day is a one-day span"); } #[test] fn one_persons_vacation_does_not_join_anothers() { let conn = upto_067(); let me = user(&conn); let you = user(&conn); // Adjacent days, different people. Joining these would be the gaps-and- // islands trick reading across the partition it is partitioned by. review(&conn, &me, "2026-08-03", "0"); review(&conn, &you, "2026-08-03", "1"); apply_067(&conn); let found = contexts(&conn); assert_eq!(found.len(), 2, "two people, two contexts: {found:?}"); assert_eq!(found[0].2, found[0].3, "each is one day"); assert_eq!(found[1].2, found[1].3); } #[test] fn an_empty_vacation_column_produces_nothing() { let conn = upto_067(); let me = user(&conn); review(&conn, &me, "2026-08-03", ""); apply_067(&conn); assert!(contexts(&conn).is_empty()); } #[test] fn two_runs_inside_one_week_stay_two_contexts() { let conn = upto_067(); let me = user(&conn); // Mon, Tue off, back Wed, off again Thu and Fri. Joining these would be the // islands trick failing to notice the gap it exists to notice. // // (The neighbouring case, one day marked by two reviews, cannot arise: the // schema carries UNIQUE(user_id, week_start_date), so a week has at most // one review. The DISTINCT in the migration is belt to that braces.) review(&conn, &me, "2026-08-03", "0,1,3,4"); apply_067(&conn); let found = contexts(&conn); assert_eq!(found.len(), 2, "a clear Wednesday is two runs: {found:?}"); assert_eq!( (found[0].2.as_str(), found[0].3.as_str()), ("2026-08-03", "2026-08-04") ); assert_eq!( (found[1].2.as_str(), found[1].3.as_str()), ("2026-08-06", "2026-08-07") ); } #[test] fn the_vacation_days_column_is_left_in_place() { // Dropping it is sync-visible and waits on the SyncKit version gate // (ruling 3 of `8ee5c4fe`, synckit task `82bc96ba`). Until then the column // stays as a legacy mirror that new code does not write, and this test is // what says that is deliberate rather than forgotten. let conn = upto_067(); let me = user(&conn); review(&conn, &me, "2026-08-03", "0,1"); apply_067(&conn); let still_there: String = conn .query_row( "SELECT vacation_days FROM weekly_reviews WHERE user_id = ?1", params![me], |row| row.get(0), ) .expect("the column is still readable"); assert_eq!(still_there, "0,1"); }