test: consolidate 25 integration crates into one it binary
Each top-level tests/*.rs was its own crate → its own binary, each statically linking the bundled engine + every dep. 26 of them, so an edit to the lib relinked all 26. Moved the 25 standalone files into tests/it/ under one tests/it/main.rs (the pattern typing_surface already uses); cargo auto-detects it as the `it` target. End state: 2 integration-test binaries instead of 26. Result: target/debug/deps 1.5 GB → 629 MB (-58%). Build time barely moved (clean 22.9s→22.4s, lib-edit relink 13.3s→12.4s) — wall-clock is dominated by compiling, not linking, so this is a disk win, not a speed win (see docs/plans/20260602-test-consolidation.md). Tests unchanged at 2151/0/1; clippy clean; no fixups needed. typing_surface_matrix stays its own already-consolidated binary. Tradeoff: the 25 files now share one crate (a compile error fails the whole `it` binary; module-scoped namespaces, no clashes) — negligible for a solo project.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
//! Iteration-3 integration tests: rebuild from text on a
|
||||
//! missing `.db` (ADR-0015 §7).
|
||||
//!
|
||||
//! These tests:
|
||||
//!
|
||||
//! 1. Build a populated project via Iteration 2's write-through
|
||||
//! path so YAML and CSVs end up on disk.
|
||||
//! 2. Delete `playground.db`.
|
||||
//! 3. Re-open the project and call `rebuild_from_text`.
|
||||
//! 4. Verify the schema, relationships, and row data round-trip.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_restores_schema_only_project() {
|
||||
let data = tempdir();
|
||||
|
||||
// Phase 1: populate via write-through.
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.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();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
// Phase 2: delete the .db so the next open triggers rebuild.
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
// Phase 3: reopen and rebuild.
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
// Phase 4: confirm Customers exists with the right shape.
|
||||
let desc = rt()
|
||||
.block_on(async { db.describe_table("Customers".to_string(), None).await })
|
||||
.expect("describe_table");
|
||||
assert_eq!(desc.name, "Customers");
|
||||
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(cols, vec!["id", "Name"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_restores_rows_from_csv() {
|
||||
let data = tempdir();
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.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".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Text("Alice".to_string())],
|
||||
Some("insert".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Text("Bob".to_string())],
|
||||
Some("insert".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
let rows = rt()
|
||||
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
assert_eq!(rows.rows.len(), 2);
|
||||
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
|
||||
assert_eq!(names[0].as_deref(), Some("Alice"));
|
||||
assert_eq!(names[1].as_deref(), Some("Bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_restores_relationships_and_cascade_behaviour() {
|
||||
let data = tempdir();
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
Some("create".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".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_relationship(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
"id".to_string(),
|
||||
"Orders".to_string(),
|
||||
"CustId".to_string(),
|
||||
ReferentialAction::Cascade,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
Some("rel".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
Some(vec!["id".to_string()]),
|
||||
vec![Value::Number("1".to_string())],
|
||||
Some("insert".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Orders".to_string(),
|
||||
Some(vec!["CustId".to_string()]),
|
||||
vec![Value::Number("1".to_string())],
|
||||
Some("insert".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
// Relationship is back: cascade-delete from Customers
|
||||
// should also clean Orders.
|
||||
let result = rt()
|
||||
.block_on(async {
|
||||
db.delete(
|
||||
"Customers".to_string(),
|
||||
rdbms_playground::dsl::RowFilter::AllRows,
|
||||
Some("delete".to_string()),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.expect("delete");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
|
||||
assert_eq!(result.cascade[0].child_table, "Orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
||||
let data = tempdir();
|
||||
|
||||
// Create a project, populate, then corrupt the CSV.
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Numbers".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id".to_string(), Type::Serial),
|
||||
ColumnSpec::new("n".to_string(), Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
Some("create".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Numbers".to_string(),
|
||||
Some(vec!["n".to_string()]),
|
||||
vec![Value::Number("1".to_string())],
|
||||
Some("insert".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
// Hand-corrupt the CSV: replace the int with a non-number.
|
||||
let csv_path = project_path.join("data").join("Numbers.csv");
|
||||
let body = fs::read_to_string(&csv_path).unwrap();
|
||||
let corrupt = body.replace(",1\n", ",not-a-number\n");
|
||||
fs::write(&csv_path, corrupt).unwrap();
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
let err = rt()
|
||||
.block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await
|
||||
})
|
||||
.expect_err("must fail with row-level error");
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
|
||||
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
|
||||
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_preserves_created_at_from_yaml() {
|
||||
let data = tempdir();
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"T".to_string(),
|
||||
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
Some("create".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
// Substitute a recognizable timestamp into project.yaml.
|
||||
let yaml_path = project_path.join("project.yaml");
|
||||
let body = fs::read_to_string(&yaml_path).unwrap();
|
||||
let edited = body
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim_start().starts_with("created_at:") {
|
||||
" created_at: 2020-01-02T03:04:05Z".to_string()
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(&yaml_path, format!("{edited}\n")).unwrap();
|
||||
|
||||
// Delete the .db, rebuild from text.
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
// Trigger any successful command so project.yaml is
|
||||
// rewritten from the now-rebuilt db state.
|
||||
rt().block_on(async {
|
||||
db.describe_table("T".to_string(), Some("show table T".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
// describe is read-only; force a rewrite by adding a column.
|
||||
db.add_column(
|
||||
"T".to_string(),
|
||||
ColumnSpec::new("Note", Type::Text),
|
||||
Some("add column".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let final_yaml = fs::read_to_string(&yaml_path).unwrap();
|
||||
assert!(
|
||||
final_yaml.contains("created_at: 2020-01-02T03:04:05Z"),
|
||||
"yaml should preserve the edited created_at:\n{final_yaml}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Indexes round-trip through `project.yaml` and a full rebuild
|
||||
/// (ADR-0025): create an index, drop the `.db`, rebuild from
|
||||
/// text, confirm the index is back.
|
||||
#[test]
|
||||
fn rebuild_restores_indexes() {
|
||||
let data = tempdir();
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id".to_string(), Type::Serial),
|
||||
ColumnSpec::new("Email".to_string(), Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
Some("create table Customers with pk id(serial)".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_index(
|
||||
Some("idx_email".to_string()),
|
||||
"Customers".to_string(),
|
||||
vec!["Email".to_string()],
|
||||
Some("add index as idx_email on Customers (Email)".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
// The index must be recorded in project.yaml — the `.db` is
|
||||
// a derived artifact and gets discarded next.
|
||||
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
|
||||
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
let desc = rt()
|
||||
.block_on(async { db.describe_table("Customers".to_string(), None).await })
|
||||
.expect("describe_table");
|
||||
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
|
||||
assert_eq!(desc.indexes[0].name, "idx_email");
|
||||
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
|
||||
}
|
||||
Reference in New Issue
Block a user