//! Iteration-2 integration tests: per-command write-through //! to `project.yaml`, `data/.csv`, and `history.log` //! (ADR-0015 §3-§6). //! //! These tests exercise the full path from //! `Database::open_with_persistence` through a successful //! command into the on-disk text targets. They use //! `Database::open_with_persistence(...)` so the worker //! thread runs the persistence callbacks the runtime would. use std::fs; use std::path::Path; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project::{ self, DATA_DIR, PROJECT_YAML, }; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } fn rt() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio rt") } /// Open a project under a fresh data root and return the /// `Database` (with persistence wired) plus the path so the /// test can inspect on-disk state. The project is held alive /// implicitly via the leaked `TempDir` returned alongside. fn open_project( data: &tempfile::TempDir, ) -> (project::Project, Database, std::path::PathBuf) { let project = project::open_or_create(None, Some(data.path())).expect("open project"); let path = project.path().to_path_buf(); let persistence = Persistence::new(path.clone()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); (project, db, path) } fn read_yaml(project_path: &Path) -> String { fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml") } fn read_csv(project_path: &Path, table: &str) -> Option { fs::read_to_string(project_path.join(DATA_DIR).join(format!("{table}.csv"))).ok() } #[test] fn create_table_writes_yaml_and_history() { let data = tempdir(); let (_p, db, path) = open_project(&data); 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(); }); let yaml = read_yaml(&path); assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}"); assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}"); assert!(yaml.contains("type: serial"), "yaml: {yaml}"); assert!(yaml.contains("type: text"), "yaml: {yaml}"); // ADR-0052: journaling moved to the dispatch layer (the worker no // longer writes history.log); this test verifies only the yaml state. // Journaling is covered by the history.rs/app.rs/replay tests. } #[test] fn insert_writes_csv_and_history() { let data = tempdir(); let (_p, db, path) = open_project(&data); 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, vec![Value::Text("Alice".to_string())], Some("insert into Customers ('Alice')".to_string()), ) .await .unwrap(); }); let csv = read_csv(&path, "Customers").expect("Customers.csv missing"); let lines: Vec<&str> = csv.trim_end().lines().collect(); assert_eq!(lines[0], "id,Name"); assert_eq!(lines[1], "1,Alice"); // ADR-0052: journaling moved off the worker; this test verifies the // csv state only (journaling covered elsewhere). } #[test] fn drop_table_removes_its_csv() { let data = tempdir(); let (_p, db, path) = open_project(&data); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) .await .unwrap(); db.insert( "Customers".to_string(), Some(vec!["id".to_string()]), vec![Value::Number("42".to_string())], Some("insert into Customers (id) values (42)".to_string()), ) .await .unwrap(); // The CSV exists before drop. assert!(read_csv(&path, "Customers").is_some()); db.drop_table( "Customers".to_string(), Some("drop table Customers".to_string()), ) .await .unwrap(); }); assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted"); let yaml = read_yaml(&path); assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}"); } #[test] fn delete_with_cascade_rewrites_both_csvs() { let data = tempdir(); let (_p, db, path) = open_project(&data); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![ ColumnSpec::new("id".to_string(), Type::Serial), ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], Some("create table Orders with pk id(serial), CustId(int)".to_string()), ) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), vec!["id".to_string()], "Orders".to_string(), vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, Some( "add 1:n relationship from Customers.id to Orders.CustId on delete cascade" .to_string(), ), ) .await .unwrap(); // Customers has only a serial PK; long-form INSERT with // an explicit id keeps the test independent of short-form // semantics for "all-auto-generated" tables. db.insert( "Customers".to_string(), Some(vec!["id".to_string()]), vec![Value::Number("1".to_string())], Some("insert into Customers (id) values (1)".to_string()), ) .await .unwrap(); db.insert( "Orders".to_string(), Some(vec!["CustId".to_string()]), vec![Value::Number("1".to_string())], Some("insert into Orders (CustId) values (1)".to_string()), ) .await .unwrap(); // Cascade delete from Customers should also clean Orders. let result = db .delete( "Customers".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), Some("delete from Customers where id=1".to_string()), ) .await .unwrap(); assert_eq!(result.rows_affected, 1); }); // Both CSVs should be gone after the cascade leaves both // tables empty: empty table -> no CSV (the rule from // Persistence::write_table_data; see ADR-0015 §4 commentary). assert!( read_csv(&path, "Customers").is_none(), "Customers.csv should be gone after cascade leaves it empty", ); assert!( read_csv(&path, "Orders").is_none(), "Orders.csv should be gone after cascade leaves it empty", ); } #[test] fn create_table_does_not_write_csv_for_empty_table() { let data = tempdir(); let (_p, db, path) = open_project(&data); 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(); }); // Schema landed in YAML. let yaml = read_yaml(&path); assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}"); // ...but no CSV until there's data. assert!( read_csv(&path, "Customers").is_none(), "no CSV should exist for an empty table", ); } #[test] fn delete_all_rows_removes_csv() { let data = tempdir(); let (_p, db, path) = open_project(&data); 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, vec![Value::Text("Alice".to_string())], Some("insert into Customers ('Alice')".to_string()), ) .await .unwrap(); // CSV exists once there's data. assert!(read_csv(&path, "Customers").is_some()); db.delete( "Customers".to_string(), RowFilter::AllRows, Some("delete from Customers --all-rows".to_string()), ) .await .unwrap(); }); assert!( read_csv(&path, "Customers").is_none(), "CSV should be removed when the table becomes empty", ); } #[test] fn failed_command_does_not_append_history_or_change_yaml() { let data = tempdir(); let (_p, db, path) = open_project(&data); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) .await .unwrap(); let yaml_before = read_yaml(&path); // Same name again — should fail. let err = db .create_table( "Customers".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) .await .expect_err("must fail"); let _ = err; let yaml_after = read_yaml(&path); assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml"); }); // ADR-0052: journaling moved off the worker; this test now verifies // only that a failed command does not change the yaml state. } #[test] fn project_yaml_carries_relationship_after_add() { let data = tempdir(); let (_p, db, path) = open_project(&data); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], None, ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![ ColumnSpec::new("id".to_string(), Type::Serial), ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], None, ) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), vec!["id".to_string()], "Orders".to_string(), vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, Some( "add 1:n relationship from Customers.id to Orders.CustId on delete cascade" .to_string(), ), ) .await .unwrap(); }); let yaml = read_yaml(&path); assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}"); assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}"); assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}"); }