Skip to main content

max / goingson

10.1 KB · 302 lines History Blame Raw
1 //! What migration 067 does to data that already exists.
2 //!
3 //! The one migration in this app that GUESSES. Max accepted that (task
4 //! `8ee5c4fe`) on the condition that the guess is reversible per record, so
5 //! these tests are where the boundary of the guess is written down: what
6 //! converts, what is deliberately left alone, and what a converted record keeps
7 //! so it can be put back.
8 //!
9 //! They run the migration by hand rather than through `run_migrations`, because
10 //! the fixtures have to exist BEFORE 067 applies and the ordinary test database
11 //! arrives with every migration already run.
12
13 use rusqlite::{Connection, params};
14
15 /// Every migration up to but not including 067, then the fixtures, then 067.
16 ///
17 /// Reads the files from the migrations directory rather than embedding them:
18 /// this test is about the shipped SQL, and a copy of it here would be a second
19 /// migration to keep in step.
20 fn upto_067() -> Connection {
21 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite");
22 let mut files: Vec<_> = std::fs::read_dir(&dir)
23 .expect("the migrations directory")
24 .filter_map(|entry| {
25 let path = entry.ok()?.path();
26 let name = path.file_name()?.to_str()?.to_owned();
27 let version: u32 = name.split('_').next()?.parse().ok()?;
28 (version < 67).then_some((version, path))
29 })
30 .collect();
31 files.sort();
32
33 let conn = Connection::open_in_memory().expect("an in-memory database");
34 conn.execute_batch("PRAGMA foreign_keys = OFF")
35 .expect("fk off while migrating");
36 for (version, path) in files {
37 let sql = std::fs::read_to_string(&path).expect("a migration file");
38 conn.execute_batch(&sql)
39 .unwrap_or_else(|e| panic!("migration {version} failed: {e}"));
40 }
41 conn
42 }
43
44 fn apply_067(conn: &Connection) {
45 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
46 .join("../../migrations/sqlite/067_contexts.sql");
47 let sql = std::fs::read_to_string(path).expect("067 exists");
48 conn.execute_batch(&sql).expect("067 applies");
49 }
50
51 fn user(conn: &Connection) -> String {
52 let id = uuid::Uuid::new_v4().to_string();
53 conn.execute(
54 "INSERT INTO users (id, email, password_hash, display_name, created_at)
55 VALUES (?1, ?2, 'x', 'Test', datetime('now'))",
56 params![id, format!("{id}@example.com")],
57 )
58 .expect("a user");
59 id
60 }
61
62 /// An event over `start`..`end`, both UTC text. `end` may be absent.
63 fn event(conn: &Connection, user_id: &str, title: &str, start: &str, end: Option<&str>) -> String {
64 let id = uuid::Uuid::new_v4().to_string();
65 conn.execute(
66 "INSERT INTO events (id, user_id, title, start_time, end_time) VALUES (?1, ?2, ?3, ?4, ?5)",
67 params![id, user_id, title, start, end],
68 )
69 .expect("an event");
70 id
71 }
72
73 fn review(conn: &Connection, user_id: &str, week_start: &str, vacation_days: &str) {
74 conn.execute(
75 "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days)
76 VALUES (?1, ?2, ?3, datetime('now'), '', ?4)",
77 params![uuid::Uuid::new_v4().to_string(), user_id, week_start, vacation_days],
78 )
79 .expect("a weekly review");
80 }
81
82 fn contexts(conn: &Connection) -> Vec<(String, String, String, String)> {
83 let mut stmt = conn
84 .prepare("SELECT label, kind, starts_on, ends_on FROM contexts ORDER BY starts_on, label")
85 .expect("the contexts table exists");
86 stmt.query_map([], |row| {
87 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
88 })
89 .expect("rows")
90 .collect::<Result<Vec<_>, _>>()
91 .expect("rows")
92 }
93
94 #[test]
95 fn a_multi_day_event_becomes_a_context_and_a_shorter_one_does_not() {
96 let conn = upto_067();
97 let me = user(&conn);
98
99 // The case the whole ruling came from: the same event was an occupancy on
100 // Wednesday, a context on Thursday, and an occupancy again on Friday.
101 event(
102 &conn,
103 &me,
104 "Conference",
105 "2026-08-05 14:00:00",
106 Some("2026-08-07 17:00:00"),
107 );
108 // Crosses a midnight and covers no whole day. An occupancy, and the false
109 // positive a "does it end on a later date" test would have converted.
110 event(
111 &conn,
112 &me,
113 "Red-eye to Berlin",
114 "2026-08-10 22:00:00",
115 Some("2026-08-11 06:00:00"),
116 );
117 // A lone midnight-to-midnight marker. Ruling 1 leaves this alone by name.
118 event(
119 &conn,
120 &me,
121 "Tax deadline",
122 "2026-08-15 00:00:00",
123 Some("2026-08-16 00:00:00"),
124 );
125 // No end at all.
126 event(&conn, &me, "Standup", "2026-08-17 09:00:00", None);
127
128 apply_067(&conn);
129
130 let found = contexts(&conn);
131 assert_eq!(found.len(), 1, "only the conference converts: {found:?}");
132 let (label, kind, starts, ends) = &found[0];
133 assert_eq!(label, "Conference");
134 assert_eq!(kind, "Other", "an event says nothing about what kind it is");
135 assert_eq!(starts, "2026-08-05");
136 assert_eq!(ends, "2026-08-07");
137 }
138
139 #[test]
140 fn an_event_ending_at_midnight_does_not_reach_into_that_day() {
141 let conn = upto_067();
142 let me = user(&conn);
143 // Two whole days, ending exactly at the third midnight.
144 event(
145 &conn,
146 &me,
147 "Offsite",
148 "2026-08-05 00:00:00",
149 Some("2026-08-07 00:00:00"),
150 );
151
152 apply_067(&conn);
153
154 let found = contexts(&conn);
155 assert_eq!(found.len(), 1);
156 assert_eq!(found[0].2, "2026-08-05");
157 assert_eq!(found[0].3, "2026-08-06", "the 7th is not inside it");
158 }
159
160 #[test]
161 fn a_converted_event_is_hidden_rather_than_deleted_and_names_its_context() {
162 let conn = upto_067();
163 let me = user(&conn);
164 let conference = event(
165 &conn,
166 &me,
167 "Conference",
168 "2026-08-05 14:00:00",
169 Some("2026-08-07 17:00:00"),
170 );
171
172 apply_067(&conn);
173
174 // The event is still there with everything it had. Reversal is clearing one
175 // column, not retyping a time.
176 let (title, start, converted): (String, String, Option<String>) = conn
177 .query_row(
178 "SELECT title, start_time, converted_to_context_id FROM events WHERE id = ?1",
179 params![conference],
180 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
181 )
182 .expect("the event survives");
183 assert_eq!(title, "Conference");
184 assert_eq!(start, "2026-08-05 14:00:00");
185 let context_id = converted.expect("it points at the context it became");
186
187 // And the pointer goes both ways.
188 let back: String = conn
189 .query_row(
190 "SELECT migrated_from_event_id FROM contexts WHERE id = ?1",
191 params![context_id],
192 |row| row.get(0),
193 )
194 .expect("the context names the event");
195 assert_eq!(back, conference);
196 }
197
198 #[test]
199 fn vacation_days_become_spans_and_a_run_across_two_weeks_is_one_context() {
200 let conn = upto_067();
201 let me = user(&conn);
202
203 // Ruling 2's example. Week of Mon 2026-08-03: Thu..Sun marked (3,4,5,6).
204 // Week of Mon 2026-08-10: Mon,Tue marked (0,1). Nobody recording a
205 // six-day holiday meant two holidays.
206 review(&conn, &me, "2026-08-03", "3,4,5,6");
207 review(&conn, &me, "2026-08-10", "0,1");
208 // A separate run later the same month, with a clear day in between.
209 review(&conn, &me, "2026-08-17", "4");
210
211 apply_067(&conn);
212
213 let found = contexts(&conn);
214 assert_eq!(found.len(), 2, "two runs, not four weeks: {found:?}");
215
216 assert_eq!(found[0].0, "Vacation");
217 assert_eq!(found[0].1, "Vacation");
218 assert_eq!(found[0].2, "2026-08-06", "Thursday of the first week");
219 assert_eq!(found[0].3, "2026-08-11", "Tuesday of the second");
220
221 assert_eq!(found[1].2, "2026-08-21");
222 assert_eq!(found[1].3, "2026-08-21", "one day is a one-day span");
223 }
224
225 #[test]
226 fn one_persons_vacation_does_not_join_anothers() {
227 let conn = upto_067();
228 let me = user(&conn);
229 let you = user(&conn);
230
231 // Adjacent days, different people. Joining these would be the gaps-and-
232 // islands trick reading across the partition it is partitioned by.
233 review(&conn, &me, "2026-08-03", "0");
234 review(&conn, &you, "2026-08-03", "1");
235
236 apply_067(&conn);
237
238 let found = contexts(&conn);
239 assert_eq!(found.len(), 2, "two people, two contexts: {found:?}");
240 assert_eq!(found[0].2, found[0].3, "each is one day");
241 assert_eq!(found[1].2, found[1].3);
242 }
243
244 #[test]
245 fn an_empty_vacation_column_produces_nothing() {
246 let conn = upto_067();
247 let me = user(&conn);
248 review(&conn, &me, "2026-08-03", "");
249
250 apply_067(&conn);
251
252 assert!(contexts(&conn).is_empty());
253 }
254
255 #[test]
256 fn two_runs_inside_one_week_stay_two_contexts() {
257 let conn = upto_067();
258 let me = user(&conn);
259 // Mon, Tue off, back Wed, off again Thu and Fri. Joining these would be the
260 // islands trick failing to notice the gap it exists to notice.
261 //
262 // (The neighbouring case, one day marked by two reviews, cannot arise: the
263 // schema carries UNIQUE(user_id, week_start_date), so a week has at most
264 // one review. The DISTINCT in the migration is belt to that braces.)
265 review(&conn, &me, "2026-08-03", "0,1,3,4");
266
267 apply_067(&conn);
268
269 let found = contexts(&conn);
270 assert_eq!(found.len(), 2, "a clear Wednesday is two runs: {found:?}");
271 assert_eq!(
272 (found[0].2.as_str(), found[0].3.as_str()),
273 ("2026-08-03", "2026-08-04")
274 );
275 assert_eq!(
276 (found[1].2.as_str(), found[1].3.as_str()),
277 ("2026-08-06", "2026-08-07")
278 );
279 }
280
281 #[test]
282 fn the_vacation_days_column_is_left_in_place() {
283 // Dropping it is sync-visible and waits on the SyncKit version gate
284 // (ruling 3 of `8ee5c4fe`, synckit task `82bc96ba`). Until then the column
285 // stays as a legacy mirror that new code does not write, and this test is
286 // what says that is deliberate rather than forgotten.
287 let conn = upto_067();
288 let me = user(&conn);
289 review(&conn, &me, "2026-08-03", "0,1");
290
291 apply_067(&conn);
292
293 let still_there: String = conn
294 .query_row(
295 "SELECT vacation_days FROM weekly_reviews WHERE user_id = ?1",
296 params![me],
297 |row| row.get(0),
298 )
299 .expect("the column is still readable");
300 assert_eq!(still_there, "0,1");
301 }
302