Files
rdbms-playground/tests/it/iteration5_export_import.rs
claude@clouddev1 e8fa859ab9 refactor(db): unwind vestigial worker source plumbing (ADR-0052 follow-up)
ADR-0052 moved success journaling out of the worker to the dispatch
layer, leaving the `source` that handlers threaded purely for the
worker's old history.log write dead. Remove it:

- drop `_source` from finalize_persistence and do_rebuild_from_text
- inline + delete the three read-only *_request wrappers
- drop the now-unused `source` param from the ~30 forwarding worker
  handlers (leaf + composite), compiler-guided
- remove the `source` field from the DescribeTable/QueryData/RunSelect
  requests and their DatabaseHandle methods (call sites updated)

The only worker `source` left is the snapshot/undo label
(snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical,
no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean.
2026-06-14 13:47:49 +00:00

372 lines
12 KiB
Rust

//! 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<Action> {
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. The
// failed line is journalled `err` (ADR-0034) but no import
// dispatches.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no import dispatch; got {actions:?}",
);
// The friendly parse-error rendering produces multiple
// output lines (caret, message, usage). Scan for the anchor
// phrase rather than asserting on the final line. The
// round-5 refactor moved this error from `handle_import_command`
// (single note) into the parser's pre-chumsky path (multi-
// line rendering via dispatch_dsl).
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("import") && l.text.contains("target"));
assert!(
anywhere,
"expected 'import' + 'target' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
}
#[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::<Vec<_>>()
.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::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), 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, 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<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
assert_eq!(cells, vec![Some("1"), Some("Alice")]);
}