//! Iteration-5 integration tests: `export` / `import` //! (ADR-0015 §11 + ADR-0007 amendment 1). //! //! Command parsing is exercised at the App layer (synthetic //! events). Filesystem-level export and import semantics are //! tested against the public `archive` helpers without booting //! a Tokio loop. use std::fs; use std::path::PathBuf; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::App; use rdbms_playground::archive::{ default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence, resolve_import_target, }; use rdbms_playground::event::AppEvent; use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML}; const fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, }) } fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); } } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf { let p = root.join(name); fs::create_dir_all(&p).unwrap(); fs::write( p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n", ) .unwrap(); fs::create_dir_all(p.join("data")).unwrap(); fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap(); fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap(); fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap(); p } // --- Command-parsing tests ------------------------------------- #[test] fn export_with_no_arg_emits_default_action() { let mut app = App::new(); type_str(&mut app, "export"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); match &actions[0] { Action::Export { target, source } => { assert!(target.is_none()); assert_eq!(source, "export"); } other => panic!("expected Export, got {other:?}"), } } #[test] fn export_with_path_argument_passes_through_target() { let mut app = App::new(); type_str(&mut app, "export backups/MyExport.zip"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); match &actions[0] { Action::Export { target, .. } => { assert_eq!(target.as_deref(), Some("backups/MyExport.zip")); } other => panic!("expected Export, got {other:?}"), } } #[test] fn export_with_only_whitespace_after_keyword_errors() { let mut app = App::new(); type_str(&mut app, "export "); let actions = submit(&mut app); // Trailing whitespace is trimmed by submit() before // dispatch, so "export " trims to "export" and emits // the default Export action — exactly the same outcome // as a bare `export`. That is the desired behaviour. assert_eq!(actions.len(), 1); match &actions[0] { Action::Export { target, .. } => assert!(target.is_none()), other => panic!("expected Export, got {other:?}"), } } #[test] fn import_without_arg_emits_error() { let mut app = App::new(); type_str(&mut app, "import"); let actions = submit(&mut app); assert!(actions.is_empty()); let last = app.output.back().unwrap(); assert!(last.text.contains("usage: import"), "got: {}", last.text); } #[test] fn import_with_zip_path_emits_action_without_target() { let mut app = App::new(); type_str(&mut app, "import some/file.zip"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); match &actions[0] { Action::Import { zip_path, as_target, .. } => { assert_eq!(zip_path, "some/file.zip"); assert!(as_target.is_none()); } other => panic!("expected Import, got {other:?}"), } } #[test] fn import_with_zip_and_as_target_emits_both() { let mut app = App::new(); type_str(&mut app, "import some/file.zip as MyImported"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); match &actions[0] { Action::Import { zip_path, as_target, .. } => { assert_eq!(zip_path, "some/file.zip"); assert_eq!(as_target.as_deref(), Some("MyImported")); } other => panic!("expected Import, got {other:?}"), } } #[test] fn import_grammar_only_splits_on_space_around_as() { // A zip path that *contains* the substring "as" without // surrounding spaces must NOT be split — the separator // is " as " (space-as-space) only. let mut app = App::new(); type_str(&mut app, "import path/asfile.zip"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); match &actions[0] { Action::Import { zip_path, as_target, .. } => { assert_eq!(zip_path, "path/asfile.zip"); assert!(as_target.is_none()); } other => panic!("expected Import, got {other:?}"), } } #[test] fn import_with_empty_target_after_as_errors() { let mut app = App::new(); type_str(&mut app, "import foo.zip as "); let actions = submit(&mut app); // "as " trailing whitespace is trimmed by .split_once + .trim, // making the as-target empty. We surface this as a usage // error rather than silently importing without a target. assert!(actions.is_empty()); let last = app.output.back().unwrap(); assert!( last.text.contains("import") && last.text.contains("target"), "got: {}", last.text, ); } #[test] fn help_lists_export_and_import() { let mut app = App::new(); type_str(&mut app, "help"); submit(&mut app); let body = app .output .iter() .map(|l| l.text.as_str()) .collect::>() .join("\n"); assert!(body.contains("export"), "help missing export: {body}"); assert!(body.contains("import"), "help missing import: {body}"); } // --- Filesystem-level export/import semantics ------------------ #[test] fn full_round_trip_export_then_extract() { let tmp = tempdir(); let project = make_demo_project(tmp.path(), "MyDemo"); let zip = tmp.path().join("MyDemo-export-01.zip"); export_project(&project, "MyDemo", &zip).unwrap(); let inspect = inspect_zip(&zip).unwrap(); assert_eq!(inspect.top_folder, "MyDemo"); let target = tmp.path().join("imported"); extract_into(&zip, &target, &inspect.top_folder).unwrap(); assert!(target.join(PROJECT_YAML).exists()); assert!(target.join("data").join("Customers.csv").exists()); // history.log and playground.db were excluded from the zip, // so neither lands in the imported project. assert!(!target.join(HISTORY_LOG).exists()); assert!(!target.join(PLAYGROUND_DB).exists()); } #[test] fn next_export_sequence_increments_per_existing_file() { let tmp = tempdir(); let date = rdbms_playground::project::naming::today_local(); let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap(); assert_eq!(n1, 1); fs::write(tmp.path().join(&n1_name), "").unwrap(); let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap(); assert_eq!(n2, 2); assert_eq!(n2_name, default_export_filename(&date, "Demo", 2)); } #[test] fn resolve_import_target_auto_suffixes_on_collision() { let tmp = tempdir(); fs::create_dir(tmp.path().join("Imported")).unwrap(); let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap(); assert_eq!(resolved, tmp.path().join("Imported-02")); assert_eq!(suffix, 2); } #[test] fn resolve_import_target_uses_direct_name_when_free() { let tmp = tempdir(); let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap(); assert_eq!(resolved, tmp.path().join("Fresh")); assert_eq!(suffix, 0); } // --- End-to-end: real Project → export → import → rebuild ---- #[test] fn end_to_end_export_then_import_real_project() { use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, Type, Value}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project; fn rt() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio rt") } let data = tempdir(); // Build a populated source project. let src_path = { let p = project::Project::create_named(&data.path().join("Source")).unwrap(); let db = Database::open_with_persistence( p.db_path(), Persistence::new(p.path().to_path_buf()), ) .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ ColumnSpec { name: "id".to_string(), ty: Type::Serial }, ColumnSpec { name: "Name".to_string(), ty: Type::Text }, ], vec!["id".to_string()], Some("create table Customers with pk id:serial".to_string()), ) .await .unwrap(); db.insert( "Customers".to_string(), None, // Serial id auto-fills, so the values list // covers the non-serial columns only. vec![Value::Text("Alice".to_string())], Some("insert into Customers values ('Alice')".to_string()), ) .await .unwrap(); }); let path = p.path().to_path_buf(); drop(db); drop(p); path }; // Export. let zip_path = data.path().join("Source-export.zip"); export_project(&src_path, "Source", &zip_path).unwrap(); assert!(zip_path.exists()); // Inspect: top folder is the project name we exported with. let inspect = inspect_zip(&zip_path).unwrap(); assert_eq!(inspect.top_folder, "Source"); // Import to a fresh location and rebuild from text. let dst = data.path().join("Imported"); extract_into(&zip_path, &dst, &inspect.top_folder).unwrap(); assert!(dst.join(PROJECT_YAML).exists()); // playground.db is excluded from the export, so the // imported project starts without one — exactly the // scenario rebuild_from_text is designed for. assert!(!dst.join(PLAYGROUND_DB).exists()); let imported = project::Project::open(&dst).unwrap(); let imported_db = Database::open_with_persistence( imported.db_path(), Persistence::new(imported.path().to_path_buf()), ) .unwrap(); rt().block_on(async { imported_db .rebuild_from_text(imported.path().to_path_buf(), None) .await .expect("rebuild"); }); // Round-trip: the inserted row is back. let data_view = rt() .block_on(async { imported_db.query_data("Customers".to_string(), None).await }) .expect("query data"); assert_eq!(data_view.rows.len(), 1); // Serial id auto-filled to 1; Name was the inserted value. let cells: Vec> = data_view.rows[0].iter().map(|c| c.as_deref()).collect(); assert_eq!(cells, vec![Some("1"), Some("Alice")]); }