1use std::time::{Duration, Instant};
8
9use rusqlite::Connection;
10
11use crate::core::error::ThingsError;
12use crate::core::reader::pool::ReaderPool;
13use crate::core::types::{TaskKind, TaskStatus, TodoSummary};
14
15#[derive(Debug, Clone)]
16pub enum VerifyPredicate {
17 CreateByTitle {
20 title: String,
21 since_unix: f64,
22 kind: TaskKind,
23 },
24 UpdateById {
26 id: String,
27 expected_title: Option<String>,
28 expected_notes: Option<String>,
29 },
30 StatusChange { id: String, want: TaskStatus },
32 MoveById {
36 id: String,
37 expected_list_id: Option<String>,
38 },
39 TagOnTodoById {
44 id: String,
45 tag: String,
46 present: bool,
47 },
48}
49
50#[derive(Debug)]
51pub enum VerifyOutcome {
52 Verified { row: TodoSummary, latency_ms: u64 },
53 Timeout { latency_ms: u64 },
54 NotFound { latency_ms: u64 },
57}
58
59pub async fn verify(
60 pool: &ReaderPool,
61 pred: VerifyPredicate,
62 timeout: Duration,
63 interval: Duration,
64) -> Result<VerifyOutcome, ThingsError> {
65 let start = Instant::now();
66
67 if let VerifyPredicate::UpdateById { id, .. }
70 | VerifyPredicate::StatusChange { id, .. }
71 | VerifyPredicate::MoveById { id, .. }
72 | VerifyPredicate::TagOnTodoById { id, .. } = &pred
73 {
74 let id_for_probe = id.clone();
75 let exists = pool
76 .with_conn(move |c| -> rusqlite::Result<bool> {
77 c.query_row(
78 "SELECT EXISTS (SELECT 1 FROM TMTask WHERE uuid = ? AND trashed = 0)",
79 rusqlite::params![id_for_probe],
80 |r| r.get::<_, i64>(0).map(|n| n != 0),
81 )
82 })
83 .await?;
84 if !exists {
85 return Ok(VerifyOutcome::NotFound {
86 latency_ms: start.elapsed().as_millis() as u64,
87 });
88 }
89 }
90
91 loop {
92 let pred_clone = pred.clone();
93 let found = pool
94 .with_conn(move |c| check_once(c, &pred_clone))
95 .await?;
96 if let Some(row) = found {
97 return Ok(VerifyOutcome::Verified {
98 row,
99 latency_ms: start.elapsed().as_millis() as u64,
100 });
101 }
102 if start.elapsed() >= timeout {
103 return Ok(VerifyOutcome::Timeout {
104 latency_ms: start.elapsed().as_millis() as u64,
105 });
106 }
107 tokio::time::sleep(interval).await;
108 }
109}
110
111fn check_once(c: &Connection, pred: &VerifyPredicate) -> rusqlite::Result<Option<TodoSummary>> {
112 use crate::core::reader::queries::{row_to_summary, SUMMARY_COLS, SUMMARY_COLS_LEN};
113 match pred {
114 VerifyPredicate::CreateByTitle { title, since_unix, kind } => {
115 let type_int: i64 = match kind {
116 TaskKind::Todo => 0,
117 TaskKind::Project => 1,
118 TaskKind::Heading => 2,
119 };
120 let sql = format!(
121 r#"
122 SELECT {SUMMARY_COLS}
123 FROM TMTask AS t
124 WHERE t.trashed = 0
125 AND t.type = ?
126 AND t.title = ?
127 AND t.creationDate >= ?
128 ORDER BY t.creationDate DESC
129 LIMIT 1
130 "#
131 );
132 let mut stmt = c.prepare_cached(&sql)?;
133 let mut rows = stmt.query(rusqlite::params![type_int, title, since_unix])?;
134 if let Some(r) = rows.next()? {
135 return row_to_summary(r).map(Some);
136 }
137 Ok(None)
138 }
139 VerifyPredicate::UpdateById {
140 id,
141 expected_title,
142 expected_notes,
143 } => {
144 let sql = format!(
145 r#"
146 SELECT {SUMMARY_COLS}, t.notes
147 FROM TMTask AS t
148 WHERE t.uuid = ? AND t.trashed = 0
149 LIMIT 1
150 "#
151 );
152 let mut stmt = c.prepare_cached(&sql)?;
153 let mut rows = stmt.query(rusqlite::params![id])?;
154 let Some(r) = rows.next()? else {
155 return Ok(None);
156 };
157 let summary = row_to_summary(r)?;
158 let notes: Option<String> = r.get(SUMMARY_COLS_LEN)?;
159 if let Some(want) = expected_title.as_ref() {
160 if summary.title != *want {
161 return Ok(None);
162 }
163 }
164 if let Some(want) = expected_notes.as_ref() {
165 if notes.as_deref() != Some(want.as_str()) {
166 return Ok(None);
167 }
168 }
169 Ok(Some(summary))
170 }
171 VerifyPredicate::StatusChange { id, want } => {
172 let sql = format!(
173 r#"
174 SELECT {SUMMARY_COLS}
175 FROM TMTask AS t
176 WHERE t.uuid = ? AND t.trashed = 0
177 LIMIT 1
178 "#
179 );
180 let mut stmt = c.prepare_cached(&sql)?;
181 let mut rows = stmt.query(rusqlite::params![id])?;
182 let Some(r) = rows.next()? else {
183 return Ok(None);
184 };
185 let summary = row_to_summary(r)?;
186 if summary.status == *want {
187 Ok(Some(summary))
188 } else {
189 Ok(None)
190 }
191 }
192 VerifyPredicate::MoveById { id, expected_list_id } => {
193 let sql = format!(
194 r#"
195 SELECT {SUMMARY_COLS}
196 FROM TMTask AS t
197 WHERE t.uuid = ? AND t.trashed = 0
198 LIMIT 1
199 "#
200 );
201 let mut stmt = c.prepare_cached(&sql)?;
202 let mut rows = stmt.query(rusqlite::params![id])?;
203 let Some(r) = rows.next()? else {
204 return Ok(None);
205 };
206 let summary = row_to_summary(r)?;
207 let matches = match expected_list_id.as_deref() {
208 None => summary.project_id.is_none() && summary.area_id.is_none(),
209 Some(want) => {
210 summary.project_id.as_deref() == Some(want)
211 || summary.area_id.as_deref() == Some(want)
212 }
213 };
214 if matches {
215 Ok(Some(summary))
216 } else {
217 Ok(None)
218 }
219 }
220 VerifyPredicate::TagOnTodoById { id, tag, present } => {
221 let has_tag_sql = r#"
224 SELECT EXISTS (
225 SELECT 1
226 FROM TMTaskTag AS tt
227 JOIN TMTag AS g ON g.uuid = tt.tags
228 WHERE tt.tasks = ? AND g.title = ?
229 )
230 "#;
231 let mut stmt = c.prepare_cached(has_tag_sql)?;
232 let has_tag: bool = stmt
233 .query_row(rusqlite::params![id, tag], |r| {
234 r.get::<_, i64>(0).map(|n| n != 0)
235 })?;
236 if has_tag != *present {
237 return Ok(None);
238 }
239 let summary_sql = format!(
241 r#"
242 SELECT {SUMMARY_COLS}
243 FROM TMTask AS t
244 WHERE t.uuid = ? AND t.trashed = 0
245 LIMIT 1
246 "#
247 );
248 let mut summary_stmt = c.prepare_cached(&summary_sql)?;
249 let mut rows = summary_stmt.query(rusqlite::params![id])?;
250 let Some(r) = rows.next()? else { return Ok(None) };
251 row_to_summary(r).map(Some)
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::core::reader::fixture::build_fixture;
260 use tempfile::tempdir;
261
262 fn cfg() -> (Duration, Duration) {
263 (Duration::from_millis(500), Duration::from_millis(20))
264 }
265
266 async fn open_pool() -> (tempfile::TempDir, ReaderPool) {
267 let tmp = tempdir().unwrap();
268 let path = tmp.path().join("p.sqlite");
269 build_fixture(&path).unwrap();
270 let pool = ReaderPool::new(path, 2).await.unwrap();
271 (tmp, pool)
272 }
273
274 #[tokio::test]
275 async fn verify_create_by_title_finds_existing_row() {
276 let (_tmp, pool) = open_pool().await;
277 let (timeout, interval) = cfg();
278 let out = verify(
280 &pool,
281 VerifyPredicate::CreateByTitle {
282 title: "Buy milk".into(),
283 since_unix: 0.0,
284 kind: TaskKind::Todo,
285 },
286 timeout,
287 interval,
288 )
289 .await
290 .unwrap();
291 match out {
292 VerifyOutcome::Verified { row, .. } => assert_eq!(row.title, "Buy milk"),
293 other => panic!("expected Verified, got {:?}", other),
294 }
295 }
296
297 #[tokio::test]
298 async fn verify_create_by_title_times_out_when_title_absent() {
299 let (_tmp, pool) = open_pool().await;
300 let (timeout, interval) = cfg();
301 let out = verify(
302 &pool,
303 VerifyPredicate::CreateByTitle {
304 title: "Nothing in the fixture matches this".into(),
305 since_unix: 0.0,
306 kind: TaskKind::Todo,
307 },
308 timeout,
309 interval,
310 )
311 .await
312 .unwrap();
313 assert!(matches!(out, VerifyOutcome::Timeout { .. }));
314 }
315
316 #[tokio::test]
317 async fn verify_update_by_id_matches_when_fields_align() {
318 let (_tmp, pool) = open_pool().await;
319 let (timeout, interval) = cfg();
320 let out = verify(
321 &pool,
322 VerifyPredicate::UpdateById {
323 id: "todo-1".into(),
324 expected_title: Some("Buy milk".into()),
325 expected_notes: None,
326 },
327 timeout,
328 interval,
329 )
330 .await
331 .unwrap();
332 match out {
333 VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-1"),
334 other => panic!("expected Verified, got {:?}", other),
335 }
336 }
337
338 #[tokio::test]
339 async fn verify_update_by_id_not_found_short_circuits() {
340 let (_tmp, pool) = open_pool().await;
341 let (timeout, interval) = cfg();
342 let start = std::time::Instant::now();
343 let out = verify(
344 &pool,
345 VerifyPredicate::UpdateById {
346 id: "does-not-exist".into(),
347 expected_title: None,
348 expected_notes: None,
349 },
350 timeout,
351 interval,
352 )
353 .await
354 .unwrap();
355 assert!(matches!(out, VerifyOutcome::NotFound { .. }));
356 assert!(
358 start.elapsed() < Duration::from_millis(200),
359 "expected NotFound to short-circuit, took {:?}",
360 start.elapsed()
361 );
362 }
363
364 #[tokio::test]
365 async fn verify_status_change_matches_when_status_equals_want() {
366 let (_tmp, pool) = open_pool().await;
367 let (timeout, interval) = cfg();
368 let out = verify(
370 &pool,
371 VerifyPredicate::StatusChange {
372 id: "todo-3".into(),
373 want: TaskStatus::Completed,
374 },
375 timeout,
376 interval,
377 )
378 .await
379 .unwrap();
380 match out {
381 VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-3"),
382 other => panic!("expected Verified, got {:?}", other),
383 }
384 }
385
386 #[tokio::test]
387 async fn verify_move_by_id_matches_when_row_under_expected_list() {
388 let (_tmp, pool) = open_pool().await;
389 let (timeout, interval) = cfg();
390 let out = verify(
392 &pool,
393 VerifyPredicate::MoveById {
394 id: "todo-4".into(),
395 expected_list_id: Some("proj-1".into()),
396 },
397 timeout,
398 interval,
399 )
400 .await
401 .unwrap();
402 match out {
403 VerifyOutcome::Verified { row, .. } => {
404 assert_eq!(row.id, "todo-4");
405 assert_eq!(row.project_id.as_deref(), Some("proj-1"));
406 }
407 other => panic!("expected Verified, got {:?}", other),
408 }
409 }
410
411 #[tokio::test]
412 async fn verify_move_by_id_inbox_matches_when_both_parent_columns_null() {
413 let (_tmp, pool) = open_pool().await;
414 let (timeout, interval) = cfg();
415 let out = verify(
417 &pool,
418 VerifyPredicate::MoveById {
419 id: "todo-1".into(),
420 expected_list_id: None,
421 },
422 timeout,
423 interval,
424 )
425 .await
426 .unwrap();
427 assert!(matches!(out, VerifyOutcome::Verified { .. }));
428 }
429
430 #[tokio::test]
431 async fn verify_tag_on_todo_by_id_matches_when_present_true_and_tag_set() {
432 let (_tmp, pool) = open_pool().await;
433 let (timeout, interval) = cfg();
434 let out = verify(
436 &pool,
437 VerifyPredicate::TagOnTodoById {
438 id: "todo-2".into(),
439 tag: "Errand".into(),
440 present: true,
441 },
442 timeout,
443 interval,
444 )
445 .await
446 .unwrap();
447 match out {
448 VerifyOutcome::Verified { row, .. } => assert_eq!(row.id, "todo-2"),
449 other => panic!("expected Verified, got {:?}", other),
450 }
451 }
452
453 #[tokio::test]
454 async fn verify_tag_on_todo_by_id_matches_when_present_false_and_tag_absent() {
455 let (_tmp, pool) = open_pool().await;
456 let (timeout, interval) = cfg();
457 let out = verify(
459 &pool,
460 VerifyPredicate::TagOnTodoById {
461 id: "todo-1".into(),
462 tag: "Errand".into(),
463 present: false,
464 },
465 timeout,
466 interval,
467 )
468 .await
469 .unwrap();
470 assert!(matches!(out, VerifyOutcome::Verified { .. }));
471 }
472}