Skip to main content

max / goingson

31.7 KB · 896 lines History Blame Raw
1 //! Import, export and backups, driven through the router against a real
2 //! database and real files on disk.
3 //!
4 //! Same standard as the screens before it: no Tauri runtime and no window, the
5 //! description asserted, and the markup only where the markup is the point.
6 //! Every finding this port recorded is asserted here rather than left to be
7 //! noticed, so closing one is a test that has to change.
8 //!
9 //! Files are real because the writes are: every route on this screen reads a
10 //! path off disk, and a test that faked the read would be testing a different
11 //! function than the one that runs.
12
13 use std::sync::Arc;
14
15 use quasi_http::Serves as _;
16 use quasi_router::{Outcome, Params, Request, Response};
17 use tempfile::TempDir;
18
19 use super::super::router;
20 use crate::state::{AppState, DESKTOP_USER_ID};
21
22 /// A state whose data directory is this test's own, so the backups one test
23 /// writes are invisible to the next.
24 ///
25 /// `test_utils` hands out `/tmp/goingson-test` for every test at once, which is
26 /// fine for a path nothing reads and wrong for this screen: the backups list is
27 /// a directory walk.
28 async fn state() -> (Arc<AppState>, TempDir) {
29 let (mut state, _) = crate::test_utils::setup_test_state().await;
30 let dir = tempfile::tempdir().unwrap();
31
32 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
33 state
34 .db
35 .conn()
36 .unwrap()
37 .execute(
38 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
39 VALUES (?, ?, ?, ?, ?)",
40 rusqlite::params![
41 DESKTOP_USER_ID.to_string(),
42 "desktop@localhost",
43 "x",
44 "Desktop User",
45 &now,
46 ],
47 )
48 .unwrap();
49
50 Arc::get_mut(&mut state).expect("sole owner").data_dir = dir.path().to_path_buf();
51 (state, dir)
52 }
53
54 fn get(state: &AppState, path: &str) -> Response {
55 router()
56 .handle(state, Request::get(path))
57 .expect("the route answers")
58 }
59
60 fn post(state: &AppState, path: &str, params: Params) -> Response {
61 router()
62 .handle(state, Request::post(path).sending(params))
63 .expect("the route answers")
64 }
65
66 fn html(response: Response) -> String {
67 match response.outcome {
68 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
69 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
70 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
71 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
72 Outcome::Anchored { .. } => {
73 panic!("expected content, got a screen drawn at a point on it")
74 }
75 Outcome::Suggestions { field, .. } => {
76 panic!("expected content, got a suggestion list for `{field}`")
77 }
78 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
79 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
80 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
81 // not content, and not a place either.
82 Outcome::Started { region, .. } => {
83 panic!("expected content, got work started in `{region}`")
84 }
85 }
86 }
87
88 /// The file an answer handed over, or a panic naming what it did instead.
89 fn handed(response: Response) -> (String, quasi_router::Accepted, Vec<u8>) {
90 match response.outcome {
91 Outcome::File { name, kind, bytes } => (name, kind, bytes),
92 other => panic!("expected a file, got {other:?}"),
93 }
94 }
95
96 /// What the response says in a toast, if it says anything.
97 fn said(response: &Response) -> String {
98 response
99 .notice
100 .as_ref()
101 .map(|notice| notice.text.clone())
102 .unwrap_or_default()
103 }
104
105 /// Write a file into this test's directory and answer its path.
106 fn file(dir: &TempDir, name: &str, content: &str) -> String {
107 let path = dir.path().join(name);
108 std::fs::write(&path, content).unwrap();
109 path.to_string_lossy().into_owned()
110 }
111
112 /// A picked file, as the `FieldKind::File` control submits one.
113 fn picked(path: &str) -> Params {
114 Params::new().with("file", path)
115 }
116
117 const TASKS_CSV: &str = "description,priority,project\n\
118 Describe the import screens,High,GoingsOn\n\
119 Write the tests,Medium,GoingsOn\n";
120
121 const ONE_CARD: &str = "BEGIN:VCARD\r\n\
122 VERSION:3.0\r\n\
123 FN:Jane Smith\r\n\
124 EMAIL;TYPE=WORK:jane@example.com\r\n\
125 ORG:Acme Corp\r\n\
126 END:VCARD\r\n";
127
128 const ONE_EVENT: &str = "BEGIN:VCALENDAR\r\n\
129 VERSION:2.0\r\n\
130 BEGIN:VEVENT\r\n\
131 UID:one@example.com\r\n\
132 SUMMARY:Team Meeting\r\n\
133 DTSTART:20260415T100000Z\r\n\
134 DTEND:20260415T110000Z\r\n\
135 LOCATION:Conference Room A\r\n\
136 END:VEVENT\r\n\
137 END:VCALENDAR\r\n";
138
139 #[tokio::test]
140 async fn the_screen_offers_the_three_imports_and_says_nothing_it_cannot_do() {
141 let (state, _dir) = state().await;
142 let page = html(get(&state, "/data"));
143
144 assert!(page.contains("CSV or TSV file"));
145 assert!(page.contains("vCard file"));
146 assert!(page.contains("iCalendar file"));
147 assert!(page.contains("/data/import/csv/preview"));
148
149 // Nothing is left out of this screen as of 2026-08-29. The three exports
150 // came back when `67881a88` was ruled and Create Backup when `dc2f2b46`
151 // was, each asserted in a test of its own below rather than left as a hole
152 // here: `the_screen_offers_all_three_exports` and
153 // `create_hands_the_backup_off_and_answers_that_it_started`.
154 assert!(page.contains("Create Backup"));
155 }
156
157 #[tokio::test]
158 async fn a_csv_preview_says_what_is_in_the_file_and_creates_nothing() {
159 let (state, dir) = state().await;
160 let path = file(&dir, "tasks.csv", TASKS_CSV);
161
162 let page = html(post(&state, "/data/import/csv/preview", picked(&path)));
163
164 assert!(page.contains("2 tasks"));
165 assert!(page.contains("Describe the import screens"));
166 assert!(page.contains("Write the tests"));
167 // The kind is read off the header, not off the extension, so the confirm
168 // control can name what it is about to make.
169 assert!(page.contains("Import 2 tasks"));
170 // A dry run. Nothing exists yet.
171 assert_eq!(state.tasks.list_all(DESKTOP_USER_ID).unwrap().len(), 0);
172 }
173
174 #[tokio::test]
175 async fn importing_a_csv_creates_the_rows_and_clears_the_preview() {
176 let (state, dir) = state().await;
177 let path = file(&dir, "tasks.csv", TASKS_CSV);
178
179 let response = post(&state, "/data/import/csv", picked(&path));
180 assert!(said(&response).contains("Imported 2"));
181
182 let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap();
183 assert_eq!(tasks.len(), 2);
184
185 // The preview region is answered emptied: the rows have gone somewhere else,
186 // and a preview of a file that has been imported is a stale answer.
187 let page = html(response);
188 assert!(page.contains("Pick a file above"));
189 assert!(!page.contains("Describe the import screens"));
190 }
191
192 #[tokio::test]
193 async fn a_vcard_preview_offers_the_duplicate_choice_only_when_there_are_duplicates() {
194 // Finding 5, asserted from both sides.
195 let (state, dir) = state().await;
196 let path = file(&dir, "one.vcf", ONE_CARD);
197
198 let page = html(post(&state, "/data/import/contacts/preview", picked(&path)));
199 assert!(page.contains("1 contact"));
200 assert!(page.contains("Jane Smith"));
201 assert!(!page.contains("already here"), "nothing to ask about yet");
202
203 // Import it, then preview the same file again: now every card matches.
204 post(&state, "/data/import/contacts", picked(&path));
205 let page = html(post(&state, "/data/import/contacts/preview", picked(&path)));
206
207 assert!(page.contains("1 contact is already here"));
208 assert!(page.contains("Merge into the existing contact"));
209 assert!(page.contains("Skip them"));
210 assert!(page.contains("Import them as new contacts"));
211 // And it says which contact was matched, which the shipped table hides in a
212 // title attribute.
213 assert!(page.contains("Matches Jane Smith"));
214 }
215
216 #[tokio::test]
217 async fn the_duplicate_choice_is_what_the_import_does() {
218 let (state, dir) = state().await;
219 let path = file(&dir, "one.vcf", ONE_CARD);
220
221 post(&state, "/data/import/contacts", picked(&path));
222 assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1);
223
224 // Skip leaves the contact alone.
225 let response = post(
226 &state,
227 "/data/import/contacts",
228 picked(&path).with("duplicates", "skip"),
229 );
230 assert!(said(&response).contains("1 already here"));
231 assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1);
232
233 // Import as new makes a second one.
234 post(
235 &state,
236 "/data/import/contacts",
237 picked(&path).with("duplicates", "importAsNew"),
238 );
239 assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 2);
240 }
241
242 #[tokio::test]
243 async fn a_calendar_file_previews_and_imports() {
244 let (state, dir) = state().await;
245 let path = file(&dir, "one.ics", ONE_EVENT);
246
247 let page = html(post(&state, "/data/import/calendar/preview", picked(&path)));
248 assert!(page.contains("1 event"));
249 assert!(page.contains("Team Meeting"));
250 assert!(page.contains("Conference Room A"));
251
252 let response = post(&state, "/data/import/calendar", picked(&path));
253 assert!(said(&response).contains("1 imported"));
254 assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1);
255
256 // Twice is once: the UID is the dedup key, and the sentence says so rather
257 // than claiming another import happened.
258 let response = post(&state, "/data/import/calendar", picked(&path));
259 assert!(said(&response).contains("1 already here"));
260 assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1);
261 }
262
263 #[tokio::test]
264 async fn an_empty_file_says_so_and_offers_no_way_to_import_it() {
265 let (state, dir) = state().await;
266 let path = file(&dir, "empty.vcf", "");
267
268 let page = html(post(&state, "/data/import/contacts/preview", picked(&path)));
269 assert!(page.contains("No contacts in that file."));
270 assert!(!page.contains("/data/import/contacts\""));
271 }
272
273 #[tokio::test]
274 async fn a_csv_the_parser_complains_about_keeps_its_warnings() {
275 let (state, dir) = state().await;
276 // A row with no description is a row the task importer cannot use.
277 let path = file(
278 &dir,
279 "partial.csv",
280 "description,priority\nA real task,High\n,Low\n",
281 );
282
283 let page = html(post(&state, "/data/import/csv/preview", picked(&path)));
284 assert!(page.contains("A real task"));
285 assert!(
286 page.contains("Row"),
287 "the parser's warning is carried: {page}"
288 );
289 }
290
291 #[tokio::test]
292 async fn a_request_with_no_file_on_it_is_refused() {
293 let (state, _dir) = state().await;
294
295 let error = router()
296 .handle(
297 &state,
298 Request::post("/data/import/csv/preview").sending(Params::new().with("file", " ")),
299 )
300 .expect_err("an untouched control sends nothing");
301 assert_eq!(error.class.http_status(), 404);
302 }
303
304 #[tokio::test]
305 async fn a_kind_nothing_imports_is_a_not_found() {
306 let (state, _dir) = state().await;
307
308 let error = router()
309 .handle(
310 &state,
311 Request::post("/data/import/spreadsheet/preview").sending(picked("/tmp/x")),
312 )
313 .expect_err("three kinds and no others");
314 assert_eq!(error.class.http_status(), 404);
315 }
316
317 #[tokio::test]
318 async fn a_file_that_is_not_there_is_an_error_rather_than_an_empty_preview() {
319 let (state, dir) = state().await;
320 let missing = dir.path().join("nothing.csv");
321
322 let error = router()
323 .handle(
324 &state,
325 Request::post("/data/import/csv/preview").sending(picked(&missing.to_string_lossy())),
326 )
327 .expect_err("the importer cannot open it");
328 assert_eq!(error.class.http_status(), 500);
329 }
330
331 #[tokio::test]
332 async fn with_no_backups_the_list_says_so_rather_than_being_blank() {
333 let (state, _dir) = state().await;
334 let page = html(get(&state, "/data"));
335
336 assert!(page.contains("No backups yet"));
337 }
338
339 /// Put a file in the backup directory that looks like a backup.
340 ///
341 /// Enough for the list, the delete and the addressing. The restore test writes a
342 /// real one, because that is the only route that reads the contents.
343 fn seed_backup(state: &AppState, name: &str) -> std::path::PathBuf {
344 let dir = crate::backup_scheduler::backup_dir(state);
345 std::fs::create_dir_all(&dir).unwrap();
346 let path = dir.join(name);
347 std::fs::write(&path, b"not really gzip").unwrap();
348 path
349 }
350
351 #[tokio::test]
352 async fn a_backup_is_listed_with_what_it_is_and_what_can_be_done_to_it() {
353 let (state, _dir) = state().await;
354 seed_backup(&state, "goingson-backup-20260816-120000-abcd1234.json.gz");
355
356 let page = html(get(&state, "/data"));
357
358 assert!(page.contains("goingson-backup-20260816-120000-abcd1234.json.gz"));
359 assert!(page.contains("bytes"));
360 // Both destructive controls carry their question, which is Act::confirm
361 // rather than a JS helper at the call site.
362 assert!(page.contains("Restore from this backup?"));
363 assert!(page.contains("Delete this backup?"));
364 // Addressed by name. The absolute path the shipped screen puts on every
365 // button is never in the markup.
366 assert!(!page.contains(&state.data_dir.to_string_lossy().into_owned()));
367 }
368
369 #[tokio::test]
370 async fn deleting_a_backup_removes_it_and_answers_with_the_list() {
371 let (state, _dir) = state().await;
372 let name = "goingson-backup-20260816-120000-abcd1234.json.gz";
373 let path = seed_backup(&state, name);
374
375 let response = post(
376 &state,
377 &format!("/data/backups/{name}/delete"),
378 Params::new(),
379 );
380 assert!(said(&response).contains("Deleted"));
381 assert!(!path.exists());
382
383 let page = html(response);
384 assert!(page.contains("No backups yet"));
385 }
386
387 #[tokio::test]
388 async fn a_backup_that_is_already_gone_is_said_rather_than_claimed() {
389 let (state, _dir) = state().await;
390 let name = "goingson-backup-20260816-120000-abcd1234.json.gz";
391
392 let response = post(
393 &state,
394 &format!("/data/backups/{name}/delete"),
395 Params::new(),
396 );
397 assert!(said(&response).contains("already gone"));
398 }
399
400 #[tokio::test]
401 async fn a_name_that_is_not_a_backup_in_the_backup_directory_is_refused() {
402 let (state, _dir) = state().await;
403 // A neighbour of the backup directory, reached the way a hand-typed request
404 // would reach it. Both halves of safe_name: the traversal and the suffix.
405 let outside = state.data_dir.join("goingson.db");
406 std::fs::write(&outside, b"the database").unwrap();
407
408 for name in [
409 "../goingson.db",
410 "..%2Fgoingson.db",
411 "goingson.db",
412 "notes.txt",
413 ] {
414 let error = router()
415 .handle(
416 &state,
417 Request::post(format!("/data/backups/{name}/delete")),
418 )
419 .expect_err("only backups, only in the backup directory");
420 assert_eq!(error.class.http_status(), 404, "should refuse {name}");
421 }
422
423 assert!(
424 outside.exists(),
425 "nothing outside the directory was touched"
426 );
427 }
428
429 #[tokio::test(flavor = "multi_thread")]
430 async fn a_real_backup_restores_and_says_how_much_came_back() {
431 // Multi-thread because the writer bridges the fetches onto the blocking
432 // pool, which is the same reason the scheduler's own round-trip test does.
433 let (state, _dir) = state().await;
434 let project = state
435 .projects
436 .create(
437 DESKTOP_USER_ID,
438 goingson_core::NewProject {
439 name: "Restored project".into(),
440 description: String::new(),
441 project_type: goingson_core::ProjectType::SideProject,
442 status: goingson_core::ProjectStatus::Active,
443 },
444 )
445 .unwrap();
446
447 let dir = crate::backup_scheduler::backup_dir(&state);
448 let name = "goingson-backup-20260816-130000-beefcafe.json.gz";
449 crate::backup_scheduler::write_streaming_backup(
450 &state,
451 DESKTOP_USER_ID,
452 dir.clone(),
453 dir.join(name),
454 chrono::Utc::now(),
455 )
456 .await
457 .expect("the backup writes");
458
459 // Take the project away, so the restore has something to put back.
460 state.projects.delete(project.id, DESKTOP_USER_ID).unwrap();
461 assert!(
462 state
463 .projects
464 .get_by_id(project.id, DESKTOP_USER_ID)
465 .unwrap()
466 .is_none()
467 );
468
469 let response = post(
470 &state,
471 &format!("/data/backups/{name}/restore"),
472 Params::new(),
473 );
474 assert!(said(&response).contains("Restored"), "{}", said(&response));
475 assert!(
476 state
477 .projects
478 .get_by_id(project.id, DESKTOP_USER_ID)
479 .unwrap()
480 .is_some()
481 );
482 }
483
484 #[tokio::test]
485 async fn restoring_a_backup_that_is_not_there_is_a_not_found() {
486 let (state, _dir) = state().await;
487
488 let error = router()
489 .handle(
490 &state,
491 Request::post("/data/backups/goingson-backup-nope.json.gz/restore"),
492 )
493 .expect_err("no such backup");
494 assert_eq!(error.class.http_status(), 404);
495 }
496
497 #[tokio::test]
498 async fn the_automatic_settings_show_what_is_in_force_and_write_what_is_chosen() {
499 let (state, _dir) = state().await;
500 let page = html(get(&state, "/data"));
501
502 // The defaults the app falls back to when nobody has chosen: on, every 15
503 // minutes, keep one.
504 assert!(page.contains("Take backups automatically"));
505 assert!(page.contains("Every 15 minutes (recommended)"));
506 assert!(page.contains("No backups yet."));
507
508 let response = post(
509 &state,
510 "/data/backups/automatic",
511 Params::new()
512 .with("enabled", "on")
513 .with("frequency", "60")
514 .with("retention", "7"),
515 );
516 assert!(said(&response).contains("saved"));
517
518 let saved = state
519 .backup_settings
520 .get(DESKTOP_USER_ID)
521 .unwrap()
522 .expect("written");
523 assert!(saved.auto_backup_enabled);
524 assert_eq!(saved.backup_frequency_minutes, 60);
525 assert_eq!(saved.max_backups_to_keep, 7);
526 }
527
528 #[tokio::test]
529 async fn an_unticked_checkbox_is_how_automatic_backups_are_turned_off() {
530 // A checkbox submits nothing when it is not ticked, on every host and in the
531 // vocabulary, so absence is the whole of how "off" arrives. Asserted because
532 // reading it as "unchanged" would make the switch one-way.
533 let (state, _dir) = state().await;
534
535 post(
536 &state,
537 "/data/backups/automatic",
538 Params::new()
539 .with("enabled", "on")
540 .with("frequency", "15")
541 .with("retention", "1"),
542 );
543 post(
544 &state,
545 "/data/backups/automatic",
546 Params::new().with("frequency", "15").with("retention", "1"),
547 );
548
549 let saved = state
550 .backup_settings
551 .get(DESKTOP_USER_ID)
552 .unwrap()
553 .expect("written");
554 assert!(!saved.auto_backup_enabled);
555 }
556
557 #[tokio::test]
558 async fn the_floor_the_command_enforces_is_still_enforced_through_the_screen() {
559 // The clamping lives in the write path rather than in the screen, so a value
560 // the controls cannot offer is still refused. Same arrangement the settings
561 // screen has with its closed key set.
562 let (state, _dir) = state().await;
563
564 post(
565 &state,
566 "/data/backups/automatic",
567 Params::new()
568 .with("enabled", "on")
569 .with("frequency", "0")
570 .with("retention", "-4"),
571 );
572
573 let saved = state
574 .backup_settings
575 .get(DESKTOP_USER_ID)
576 .unwrap()
577 .expect("written");
578 assert_eq!(saved.backup_frequency_minutes, 1);
579 assert_eq!(saved.max_backups_to_keep, 0);
580 }
581
582 #[tokio::test]
583 async fn nothing_in_a_file_someone_else_wrote_can_become_markup() {
584 // Every string on this screen came off disk: a contact's name, an event's
585 // title, a CSV cell, a backup's file name. None of it is seen by anything
586 // that validates, and the renderer is what makes it safe.
587 let (state, dir) = state().await;
588 let path = file(
589 &dir,
590 "hostile.vcf",
591 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:<script>alert(1)</script>\r\nEND:VCARD\r\n",
592 );
593
594 let page = html(post(&state, "/data/import/contacts/preview", picked(&path)));
595
596 assert!(page.contains("&lt;script&gt;"));
597 assert!(!page.contains("<script>alert"));
598 }
599
600 // The three exports. `67881a88`, ruled 2026-08-21.
601
602 #[tokio::test]
603 async fn every_export_answers_with_a_file_and_leaves_the_screen_alone() {
604 // The ruling's whole shape, asserted three times: the route answers with the
605 // file, the description names no path, and nothing on screen is replaced.
606 let (state, _dir) = state().await;
607
608 for (slug, suffix, media_type) in [
609 ("json", ".json", "application/json"),
610 ("tasks", ".csv", "text/csv"),
611 ("calendar", ".ics", "text/calendar"),
612 ] {
613 let answer = post(&state, &format!("/data/export/{slug}"), Params::new());
614 // Not a fragment and not a place: what the user was looking at stays.
615 assert_eq!(answer.target(), None, "{slug}");
616 assert_eq!(answer.destination(), None, "{slug}");
617
618 let (name, kind, _bytes) = handed(answer);
619 assert!(name.starts_with("goingson-"), "{slug}: {name}");
620 assert!(name.ends_with(suffix), "{slug}: {name}");
621 // Dated, the way `export.js` dated it.
622 assert!(
623 name.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()),
624 "{slug}: {name}"
625 );
626 assert_eq!(
627 kind,
628 quasi_router::Accepted::Type(media_type.to_owned()),
629 "{slug}"
630 );
631 }
632 }
633
634 #[tokio::test]
635 async fn the_json_export_is_the_whole_document_the_command_writes() {
636 // The described route and the Tauri command share one body, so this is the
637 // assertion that the lift did not change what comes out: valid JSON with
638 // the top-level keys a restore reads back.
639 let (state, _dir) = state().await;
640
641 let (_name, _kind, bytes) = handed(post(&state, "/data/export/json", Params::new()));
642 let parsed: serde_json::Value =
643 serde_json::from_slice(&bytes).expect("the export is valid JSON");
644 assert!(parsed.get("tasks").is_some());
645 assert!(parsed.get("projects").is_some());
646 }
647
648 #[tokio::test]
649 async fn an_export_says_how_many_things_it_holds() {
650 // The shipped toast, kept: `export.js` said "Exported N items to JSON" from
651 // the command's `itemCount`, and the sentence is the description's now.
652 let (state, _dir) = state().await;
653
654 let answer = post(&state, "/data/export/json", Params::new());
655 assert!(said(&answer).starts_with("Exported "), "{}", said(&answer));
656 assert!(said(&answer).ends_with(" to JSON"), "{}", said(&answer));
657 }
658
659 #[tokio::test]
660 async fn a_format_nothing_exports_is_a_404_rather_than_an_empty_file() {
661 let (state, _dir) = state().await;
662 let refused = router()
663 .handle(&state, Request::post("/data/export/pdf"))
664 .expect_err("nothing exports that");
665 assert_eq!(refused.class, quasi_router::Class::NotFound);
666 }
667
668 #[tokio::test]
669 async fn the_screen_offers_all_three_exports() {
670 // Finding 1 said these were absent because a destination could not be
671 // described. It can now, so their absence would be a regression rather than
672 // a limit -- which is what this asserts.
673 let (state, _dir) = state().await;
674 let page = html(get(&state, "/data"));
675
676 for label in [
677 "Export All (JSON)",
678 "Export Tasks (CSV)",
679 "Export Calendar (ICS)",
680 ] {
681 assert!(page.contains(label), "{label} is not on the screen");
682 }
683 // And Create Backup beside them since `dc2f2b46`, which answered finding 2
684 // separately: the handler is still synchronous and hands the gzip off.
685 assert!(page.contains("Create Backup"));
686 }
687
688 /// Picking a file is the host's, and the control says so.
689 ///
690 /// This was a `FieldKind::File` inside a form until 2026-08-22, and it could not
691 /// work: the field renders `<input type="file">`, htmx submits urlencoded, and a
692 /// browser reports a masked filename rather than a path — while
693 /// `quasi_http::is_form` refuses multipart outright, on the grounds that a
694 /// description has no word for a byte stream. So the handler, which reads
695 /// `payload["file"]` as a path, could never receive one. The tests did not see
696 /// it because they hand the handler a real path, which is a value the renderer
697 /// would never send.
698 #[tokio::test]
699 async fn picking_a_file_is_the_hosts_call_and_carries_no_transport() {
700 let (state, _dir) = state().await;
701 let markup = html(get(&state, "/data"));
702
703 // No file input anywhere: the control is an act the host performs.
704 assert!(!markup.contains("type=\"file\""), "{markup}");
705
706 // `data-sends` and no verb, which is what `Action::by_host` emits: the
707 // address is the half the description knows, and how the file gets there is
708 // the host's.
709 assert!(
710 markup.contains("data-sends=\"/data/import/csv/preview\""),
711 "{markup}"
712 );
713
714 // The accept list travels with it, because a native dialog needs to know
715 // what it is asking for and the description is what says.
716 assert!(markup.contains("csv,tsv"), "{markup}");
717 assert!(markup.contains("vcf,vcard"), "{markup}");
718 assert!(markup.contains("ics,ical"), "{markup}");
719 }
720
721 /// The host half is loaded, or none of the above does anything.
722 ///
723 /// `data-sends` carries no transport by design, so a document without
724 /// `host.js` draws three buttons that look pressable and are not. That is the
725 /// exact failure this whole change is fixing, so it is worth its own assertion
726 /// against the shell the app actually serves.
727 #[tokio::test]
728 async fn the_document_loads_the_host_half() {
729 let (state, _dir) = state().await;
730 let response = get(&state, "/data");
731 let quasi_router::Outcome::Screen(screen) = &response.outcome else {
732 panic!("the data screen answers a screen");
733 };
734 let markup = quasi_webview::Webview::new()
735 .with_shell(crate::quasi::document_shell())
736 .screen(screen);
737 assert!(markup.contains("/static/host.js"), "{markup}");
738 }
739
740 // -- Create Backup, and the started answer it hands back ---------------------
741 //
742 // quasicoherent `dc2f2b46`. Finding 2 in this module's header was open until
743 // there was a word for handing work off; these are what closed it.
744
745 /// The region a started answer aims at has to be re-asking already, because the
746 /// started answer deliberately sends no cadence of its own. If this stops being
747 /// true, "Creating backup…" stands on the screen forever.
748 #[tokio::test]
749 async fn the_backups_region_re_asks_on_its_own_so_a_started_backup_can_report() {
750 let (state, _dir) = state().await;
751 let response = get(&state, "/data");
752 let quasi_router::Outcome::Screen(screen) = &response.outcome else {
753 panic!("the data screen answers a screen");
754 };
755 let region = screen
756 .slots
757 .iter()
758 .find(|slot| slot.id == "data-backups")
759 .expect("the backups region is on the screen");
760
761 assert!(region.live, "the backups list changes without the reader");
762 assert!(
763 region.fed_by.is_some(),
764 "and a live region with nowhere to ask re-asks nothing"
765 );
766 }
767
768 /// The whole of it: press the button, get told it started, and find the file
769 /// afterwards -- with the handler still synchronous the entire time.
770 #[tokio::test]
771 async fn create_hands_the_backup_off_and_answers_that_it_started() {
772 let (state, _dir) = state().await;
773 AppState::install_offload(&state, tokio::runtime::Handle::current());
774
775 let response = post(&state, "/data/backups/create", Params::default());
776
777 let quasi_router::Outcome::Started { region, message } = &response.outcome else {
778 panic!("expected work started, got {:?}", response.outcome);
779 };
780 assert_eq!(region, "data-backups");
781 assert_eq!(message, "Creating backup…");
782
783 // The answer came back before the work did, which is the point.
784 let finished = settle(&state).await;
785 assert!(finished.0, "the backup worked: {}", finished.1);
786 assert!(
787 finished.1.contains("goingson-backup-"),
788 "it names the file it wrote: {}",
789 finished.1
790 );
791 }
792
793 /// Wait for the handed-off run to land, and answer what it said.
794 ///
795 /// Polled rather than awaited on a handle: the route keeps no handle, which is
796 /// the arrangement being tested. A described screen learns the same way, on its
797 /// own cadence.
798 async fn settle(state: &AppState) -> (bool, String) {
799 for _ in 0..200 {
800 if let Some(outcome) = state.take_finished_backup() {
801 return outcome;
802 }
803 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
804 }
805 panic!("the backup never finished");
806 }
807
808 /// A finished run is reported to whoever asks next, and reported once.
809 #[tokio::test]
810 async fn a_finished_run_is_said_once_and_then_the_region_settles() {
811 let (state, _dir) = state().await;
812 state.set_backup_run(crate::state::BackupRun::Finished {
813 ok: true,
814 said: "Backup created: goingson-backup-20260829-120000-abcd1234.json.gz.".into(),
815 });
816
817 let first = get(&state, "/data/backups");
818 assert!(said(&first).contains("Backup created"), "{}", said(&first));
819
820 // The next poll is a plain list. A success notice redrawn every cadence is
821 // a screen that never stops congratulating itself.
822 let second = get(&state, "/data/backups");
823 assert_eq!(said(&second), "");
824 }
825
826 /// A failure is reported rather than looking like an idle screen, which is the
827 /// one thing a fire-and-forget offload could plausibly get wrong.
828 #[tokio::test]
829 async fn a_failed_run_is_reported_rather_than_going_quiet() {
830 let (state, _dir) = state().await;
831 state.set_backup_run(crate::state::BackupRun::Finished {
832 ok: false,
833 said: "Backup failed: the disk is full".into(),
834 });
835
836 let response = get(&state, "/data/backups");
837 assert!(
838 said(&response).contains("Backup failed"),
839 "{}",
840 said(&response)
841 );
842 assert_eq!(
843 response.notice.as_ref().map(|notice| notice.tone),
844 Some(makeover_layout::Tone::Danger)
845 );
846 }
847
848 /// Two full gzip streams over one database buys nothing, and the filename is
849 /// collision-safe rather than idempotent, so the second would land as its own
850 /// file. Refused, and said.
851 #[tokio::test]
852 async fn a_second_create_while_one_is_running_is_refused_and_said() {
853 let (state, _dir) = state().await;
854 AppState::install_offload(&state, tokio::runtime::Handle::current());
855 state.set_backup_run(crate::state::BackupRun::Running);
856
857 let response = post(&state, "/data/backups/create", Params::default());
858
859 assert!(
860 matches!(response.outcome, Outcome::Fragment { .. }),
861 "a refusal is the region back, not a second start"
862 );
863 assert!(
864 said(&response).contains("already being written"),
865 "{}",
866 said(&response)
867 );
868 }
869
870 /// While one is running the region says so, so a reader who reloads the screen
871 /// mid-backup is not shown a list that looks finished.
872 #[tokio::test]
873 async fn while_a_backup_runs_the_region_says_so() {
874 let (state, _dir) = state().await;
875 state.set_backup_run(crate::state::BackupRun::Running);
876
877 let page = html(get(&state, "/data/backups"));
878 assert!(page.contains("Creating backup…"), "{page}");
879 }
880
881 /// A host that installed no runtime gets an error, not a button that reports
882 /// success and does nothing.
883 #[tokio::test]
884 async fn without_a_runtime_to_hand_the_work_to_the_button_refuses() {
885 let (state, _dir) = state().await;
886
887 let error = router()
888 .handle(&state, Request::post("/data/backups/create"))
889 .expect_err("no runtime was installed");
890
891 assert!(
892 format!("{error:?}").contains("background"),
893 "it says what is missing: {error:?}"
894 );
895 }
896