//! Import, export and backups, driven through the router against a real //! database and real files on disk. //! //! Same standard as the screens before it: no Tauri runtime and no window, the //! description asserted, and the markup only where the markup is the point. //! Every finding this port recorded is asserted here rather than left to be //! noticed, so closing one is a test that has to change. //! //! Files are real because the writes are: every route on this screen reads a //! path off disk, and a test that faked the read would be testing a different //! function than the one that runs. use std::sync::Arc; use quasi_http::Serves as _; use quasi_router::{Outcome, Params, Request, Response}; use tempfile::TempDir; use super::super::router; use crate::state::{AppState, DESKTOP_USER_ID}; /// A state whose data directory is this test's own, so the backups one test /// writes are invisible to the next. /// /// `test_utils` hands out `/tmp/goingson-test` for every test at once, which is /// fine for a path nothing reads and wrong for this screen: the backups list is /// a directory walk. async fn state() -> (Arc, TempDir) { let (mut state, _) = crate::test_utils::setup_test_state().await; let dir = tempfile::tempdir().unwrap(); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); state .db .conn() .unwrap() .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, ?, ?, ?, ?)", rusqlite::params![ DESKTOP_USER_ID.to_string(), "desktop@localhost", "x", "Desktop User", &now, ], ) .unwrap(); Arc::get_mut(&mut state).expect("sole owner").data_dir = dir.path().to_path_buf(); (state, dir) } fn get(state: &AppState, path: &str) -> Response { router() .handle(state, Request::get(path)) .expect("the route answers") } fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } fn html(response: Response) -> String { match response.outcome { Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), Outcome::Anchored { .. } => { panic!("expected content, got a screen drawn at a point on it") } Outcome::Suggestions { field, .. } => { panic!("expected content, got a suggestion list for `{field}`") } Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"), Outcome::Locate(_) => panic!("expected content, got a place on a map"), // `cb62a9dc`. Work that runs somewhere else and a region that says so: // not content, and not a place either. Outcome::Started { region, .. } => { panic!("expected content, got work started in `{region}`") } } } /// The file an answer handed over, or a panic naming what it did instead. fn handed(response: Response) -> (String, quasi_router::Accepted, Vec) { match response.outcome { Outcome::File { name, kind, bytes } => (name, kind, bytes), other => panic!("expected a file, got {other:?}"), } } /// What the response says in a toast, if it says anything. fn said(response: &Response) -> String { response .notice .as_ref() .map(|notice| notice.text.clone()) .unwrap_or_default() } /// Write a file into this test's directory and answer its path. fn file(dir: &TempDir, name: &str, content: &str) -> String { let path = dir.path().join(name); std::fs::write(&path, content).unwrap(); path.to_string_lossy().into_owned() } /// A picked file, as the `FieldKind::File` control submits one. fn picked(path: &str) -> Params { Params::new().with("file", path) } const TASKS_CSV: &str = "description,priority,project\n\ Describe the import screens,High,GoingsOn\n\ Write the tests,Medium,GoingsOn\n"; const ONE_CARD: &str = "BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Jane Smith\r\n\ EMAIL;TYPE=WORK:jane@example.com\r\n\ ORG:Acme Corp\r\n\ END:VCARD\r\n"; const ONE_EVENT: &str = "BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ UID:one@example.com\r\n\ SUMMARY:Team Meeting\r\n\ DTSTART:20260415T100000Z\r\n\ DTEND:20260415T110000Z\r\n\ LOCATION:Conference Room A\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; #[tokio::test] async fn the_screen_offers_the_three_imports_and_says_nothing_it_cannot_do() { let (state, _dir) = state().await; let page = html(get(&state, "/data")); assert!(page.contains("CSV or TSV file")); assert!(page.contains("vCard file")); assert!(page.contains("iCalendar file")); assert!(page.contains("/data/import/csv/preview")); // Nothing is left out of this screen as of 2026-08-29. The three exports // came back when `67881a88` was ruled and Create Backup when `dc2f2b46` // was, each asserted in a test of its own below rather than left as a hole // here: `the_screen_offers_all_three_exports` and // `create_hands_the_backup_off_and_answers_that_it_started`. assert!(page.contains("Create Backup")); } #[tokio::test] async fn a_csv_preview_says_what_is_in_the_file_and_creates_nothing() { let (state, dir) = state().await; let path = file(&dir, "tasks.csv", TASKS_CSV); let page = html(post(&state, "/data/import/csv/preview", picked(&path))); assert!(page.contains("2 tasks")); assert!(page.contains("Describe the import screens")); assert!(page.contains("Write the tests")); // The kind is read off the header, not off the extension, so the confirm // control can name what it is about to make. assert!(page.contains("Import 2 tasks")); // A dry run. Nothing exists yet. assert_eq!(state.tasks.list_all(DESKTOP_USER_ID).unwrap().len(), 0); } #[tokio::test] async fn importing_a_csv_creates_the_rows_and_clears_the_preview() { let (state, dir) = state().await; let path = file(&dir, "tasks.csv", TASKS_CSV); let response = post(&state, "/data/import/csv", picked(&path)); assert!(said(&response).contains("Imported 2")); let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap(); assert_eq!(tasks.len(), 2); // The preview region is answered emptied: the rows have gone somewhere else, // and a preview of a file that has been imported is a stale answer. let page = html(response); assert!(page.contains("Pick a file above")); assert!(!page.contains("Describe the import screens")); } #[tokio::test] async fn a_vcard_preview_offers_the_duplicate_choice_only_when_there_are_duplicates() { // Finding 5, asserted from both sides. let (state, dir) = state().await; let path = file(&dir, "one.vcf", ONE_CARD); let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); assert!(page.contains("1 contact")); assert!(page.contains("Jane Smith")); assert!(!page.contains("already here"), "nothing to ask about yet"); // Import it, then preview the same file again: now every card matches. post(&state, "/data/import/contacts", picked(&path)); let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); assert!(page.contains("1 contact is already here")); assert!(page.contains("Merge into the existing contact")); assert!(page.contains("Skip them")); assert!(page.contains("Import them as new contacts")); // And it says which contact was matched, which the shipped table hides in a // title attribute. assert!(page.contains("Matches Jane Smith")); } #[tokio::test] async fn the_duplicate_choice_is_what_the_import_does() { let (state, dir) = state().await; let path = file(&dir, "one.vcf", ONE_CARD); post(&state, "/data/import/contacts", picked(&path)); assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1); // Skip leaves the contact alone. let response = post( &state, "/data/import/contacts", picked(&path).with("duplicates", "skip"), ); assert!(said(&response).contains("1 already here")); assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1); // Import as new makes a second one. post( &state, "/data/import/contacts", picked(&path).with("duplicates", "importAsNew"), ); assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 2); } #[tokio::test] async fn a_calendar_file_previews_and_imports() { let (state, dir) = state().await; let path = file(&dir, "one.ics", ONE_EVENT); let page = html(post(&state, "/data/import/calendar/preview", picked(&path))); assert!(page.contains("1 event")); assert!(page.contains("Team Meeting")); assert!(page.contains("Conference Room A")); let response = post(&state, "/data/import/calendar", picked(&path)); assert!(said(&response).contains("1 imported")); assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1); // Twice is once: the UID is the dedup key, and the sentence says so rather // than claiming another import happened. let response = post(&state, "/data/import/calendar", picked(&path)); assert!(said(&response).contains("1 already here")); assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1); } #[tokio::test] async fn an_empty_file_says_so_and_offers_no_way_to_import_it() { let (state, dir) = state().await; let path = file(&dir, "empty.vcf", ""); let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); assert!(page.contains("No contacts in that file.")); assert!(!page.contains("/data/import/contacts\"")); } #[tokio::test] async fn a_csv_the_parser_complains_about_keeps_its_warnings() { let (state, dir) = state().await; // A row with no description is a row the task importer cannot use. let path = file( &dir, "partial.csv", "description,priority\nA real task,High\n,Low\n", ); let page = html(post(&state, "/data/import/csv/preview", picked(&path))); assert!(page.contains("A real task")); assert!( page.contains("Row"), "the parser's warning is carried: {page}" ); } #[tokio::test] async fn a_request_with_no_file_on_it_is_refused() { let (state, _dir) = state().await; let error = router() .handle( &state, Request::post("/data/import/csv/preview").sending(Params::new().with("file", " ")), ) .expect_err("an untouched control sends nothing"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn a_kind_nothing_imports_is_a_not_found() { let (state, _dir) = state().await; let error = router() .handle( &state, Request::post("/data/import/spreadsheet/preview").sending(picked("/tmp/x")), ) .expect_err("three kinds and no others"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn a_file_that_is_not_there_is_an_error_rather_than_an_empty_preview() { let (state, dir) = state().await; let missing = dir.path().join("nothing.csv"); let error = router() .handle( &state, Request::post("/data/import/csv/preview").sending(picked(&missing.to_string_lossy())), ) .expect_err("the importer cannot open it"); assert_eq!(error.class.http_status(), 500); } #[tokio::test] async fn with_no_backups_the_list_says_so_rather_than_being_blank() { let (state, _dir) = state().await; let page = html(get(&state, "/data")); assert!(page.contains("No backups yet")); } /// Put a file in the backup directory that looks like a backup. /// /// Enough for the list, the delete and the addressing. The restore test writes a /// real one, because that is the only route that reads the contents. fn seed_backup(state: &AppState, name: &str) -> std::path::PathBuf { let dir = crate::backup_scheduler::backup_dir(state); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join(name); std::fs::write(&path, b"not really gzip").unwrap(); path } #[tokio::test] async fn a_backup_is_listed_with_what_it_is_and_what_can_be_done_to_it() { let (state, _dir) = state().await; seed_backup(&state, "goingson-backup-20260816-120000-abcd1234.json.gz"); let page = html(get(&state, "/data")); assert!(page.contains("goingson-backup-20260816-120000-abcd1234.json.gz")); assert!(page.contains("bytes")); // Both destructive controls carry their question, which is Act::confirm // rather than a JS helper at the call site. assert!(page.contains("Restore from this backup?")); assert!(page.contains("Delete this backup?")); // Addressed by name. The absolute path the shipped screen puts on every // button is never in the markup. assert!(!page.contains(&state.data_dir.to_string_lossy().into_owned())); } #[tokio::test] async fn deleting_a_backup_removes_it_and_answers_with_the_list() { let (state, _dir) = state().await; let name = "goingson-backup-20260816-120000-abcd1234.json.gz"; let path = seed_backup(&state, name); let response = post( &state, &format!("/data/backups/{name}/delete"), Params::new(), ); assert!(said(&response).contains("Deleted")); assert!(!path.exists()); let page = html(response); assert!(page.contains("No backups yet")); } #[tokio::test] async fn a_backup_that_is_already_gone_is_said_rather_than_claimed() { let (state, _dir) = state().await; let name = "goingson-backup-20260816-120000-abcd1234.json.gz"; let response = post( &state, &format!("/data/backups/{name}/delete"), Params::new(), ); assert!(said(&response).contains("already gone")); } #[tokio::test] async fn a_name_that_is_not_a_backup_in_the_backup_directory_is_refused() { let (state, _dir) = state().await; // A neighbour of the backup directory, reached the way a hand-typed request // would reach it. Both halves of safe_name: the traversal and the suffix. let outside = state.data_dir.join("goingson.db"); std::fs::write(&outside, b"the database").unwrap(); for name in [ "../goingson.db", "..%2Fgoingson.db", "goingson.db", "notes.txt", ] { let error = router() .handle( &state, Request::post(format!("/data/backups/{name}/delete")), ) .expect_err("only backups, only in the backup directory"); assert_eq!(error.class.http_status(), 404, "should refuse {name}"); } assert!( outside.exists(), "nothing outside the directory was touched" ); } #[tokio::test(flavor = "multi_thread")] async fn a_real_backup_restores_and_says_how_much_came_back() { // Multi-thread because the writer bridges the fetches onto the blocking // pool, which is the same reason the scheduler's own round-trip test does. let (state, _dir) = state().await; let project = state .projects .create( DESKTOP_USER_ID, goingson_core::NewProject { name: "Restored project".into(), description: String::new(), project_type: goingson_core::ProjectType::SideProject, status: goingson_core::ProjectStatus::Active, }, ) .unwrap(); let dir = crate::backup_scheduler::backup_dir(&state); let name = "goingson-backup-20260816-130000-beefcafe.json.gz"; crate::backup_scheduler::write_streaming_backup( &state, DESKTOP_USER_ID, dir.clone(), dir.join(name), chrono::Utc::now(), ) .await .expect("the backup writes"); // Take the project away, so the restore has something to put back. state.projects.delete(project.id, DESKTOP_USER_ID).unwrap(); assert!( state .projects .get_by_id(project.id, DESKTOP_USER_ID) .unwrap() .is_none() ); let response = post( &state, &format!("/data/backups/{name}/restore"), Params::new(), ); assert!(said(&response).contains("Restored"), "{}", said(&response)); assert!( state .projects .get_by_id(project.id, DESKTOP_USER_ID) .unwrap() .is_some() ); } #[tokio::test] async fn restoring_a_backup_that_is_not_there_is_a_not_found() { let (state, _dir) = state().await; let error = router() .handle( &state, Request::post("/data/backups/goingson-backup-nope.json.gz/restore"), ) .expect_err("no such backup"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn the_automatic_settings_show_what_is_in_force_and_write_what_is_chosen() { let (state, _dir) = state().await; let page = html(get(&state, "/data")); // The defaults the app falls back to when nobody has chosen: on, every 15 // minutes, keep one. assert!(page.contains("Take backups automatically")); assert!(page.contains("Every 15 minutes (recommended)")); assert!(page.contains("No backups yet.")); let response = post( &state, "/data/backups/automatic", Params::new() .with("enabled", "on") .with("frequency", "60") .with("retention", "7"), ); assert!(said(&response).contains("saved")); let saved = state .backup_settings .get(DESKTOP_USER_ID) .unwrap() .expect("written"); assert!(saved.auto_backup_enabled); assert_eq!(saved.backup_frequency_minutes, 60); assert_eq!(saved.max_backups_to_keep, 7); } #[tokio::test] async fn an_unticked_checkbox_is_how_automatic_backups_are_turned_off() { // A checkbox submits nothing when it is not ticked, on every host and in the // vocabulary, so absence is the whole of how "off" arrives. Asserted because // reading it as "unchanged" would make the switch one-way. let (state, _dir) = state().await; post( &state, "/data/backups/automatic", Params::new() .with("enabled", "on") .with("frequency", "15") .with("retention", "1"), ); post( &state, "/data/backups/automatic", Params::new().with("frequency", "15").with("retention", "1"), ); let saved = state .backup_settings .get(DESKTOP_USER_ID) .unwrap() .expect("written"); assert!(!saved.auto_backup_enabled); } #[tokio::test] async fn the_floor_the_command_enforces_is_still_enforced_through_the_screen() { // The clamping lives in the write path rather than in the screen, so a value // the controls cannot offer is still refused. Same arrangement the settings // screen has with its closed key set. let (state, _dir) = state().await; post( &state, "/data/backups/automatic", Params::new() .with("enabled", "on") .with("frequency", "0") .with("retention", "-4"), ); let saved = state .backup_settings .get(DESKTOP_USER_ID) .unwrap() .expect("written"); assert_eq!(saved.backup_frequency_minutes, 1); assert_eq!(saved.max_backups_to_keep, 0); } #[tokio::test] async fn nothing_in_a_file_someone_else_wrote_can_become_markup() { // Every string on this screen came off disk: a contact's name, an event's // title, a CSV cell, a backup's file name. None of it is seen by anything // that validates, and the renderer is what makes it safe. let (state, dir) = state().await; let path = file( &dir, "hostile.vcf", "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:\r\nEND:VCARD\r\n", ); let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); assert!(page.contains("<script>")); assert!(!page.contains("