mod common;
use common::TestEngine;
use hyperdb_mcp::engine::Engine;
use hyperdb_mcp::ingest::{ingest_json, IngestOptions};
use serde_json::Value;
fn query_resilient(engine: &Engine, sql: &str) -> Vec<Value> {
let first = engine.execute_query_to_json(sql).unwrap();
if first.is_empty() {
engine.execute_query_to_json(sql).unwrap()
} else {
first
}
}
#[test]
fn execute_in_transaction_rolls_back_on_error() {
use hyperdb_mcp::error::{ErrorCode, McpError};
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE direct (v INT)")
.unwrap();
let result: Result<(), McpError> = te.engine.execute_in_transaction(|engine| {
engine.execute_command("INSERT INTO direct VALUES (1)")?;
engine.execute_command("INSERT INTO direct VALUES (2)")?;
Err(McpError::new(ErrorCode::InternalError, "simulated failure"))
});
assert!(result.is_err());
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) as cnt FROM direct")
.unwrap();
let count = rows[0]["cnt"].as_i64().unwrap();
assert_eq!(count, 0, "rollback should leave zero rows");
}
#[test]
fn execute_in_transaction_commits_on_success() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE direct (v INT)")
.unwrap();
te.engine
.execute_in_transaction(|engine| {
engine.execute_command("INSERT INTO direct VALUES (10)")?;
engine.execute_command("INSERT INTO direct VALUES (20)")?;
Ok(())
})
.unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT COUNT(*) as cnt FROM direct")
.unwrap();
let count = rows[0]["cnt"].as_i64().unwrap();
assert_eq!(count, 2);
}
#[test]
fn execute_in_transaction_rolls_back_on_panic() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE direct (v INT)")
.unwrap();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
te.engine.execute_in_transaction::<_, ()>(|engine| {
engine.execute_command("INSERT INTO direct VALUES (1)")?;
panic!("simulated closure bug");
})
}));
assert!(outcome.is_err(), "panic should propagate out");
te.engine
.execute_command("INSERT INTO direct VALUES (42)")
.expect("engine must still be usable after panic inside tx");
let rows = query_resilient(&te.engine, "SELECT v FROM direct ORDER BY v");
assert_eq!(rows.len(), 1, "only the post-panic row should survive");
assert_eq!(rows[0]["v"].as_i64().unwrap(), 42);
}
#[test]
fn failed_json_ingest_rolls_back_partial_inserts() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE t (id INT NOT NULL, name TEXT)")
.unwrap();
let data = r#"[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
{"name": "NoId"}
]"#;
let opts = IngestOptions {
table: "t".into(),
mode: "append".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts);
assert!(result.is_err(), "ingest should fail on NOT NULL violation");
let rows = query_resilient(&te.engine, "SELECT COUNT(*) as cnt FROM t");
let count = rows[0]["cnt"].as_i64().expect("count should be int");
assert_eq!(
count, 0,
"expected rollback to leave zero rows, but found {count}"
);
}
#[test]
fn successful_replace_commits_atomically() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE t (id INT, name TEXT)")
.unwrap();
te.engine
.execute_command("INSERT INTO t VALUES (99, 'Old')")
.unwrap();
let data = r#"[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]"#;
let opts = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = query_resilient(&te.engine, "SELECT COUNT(*) as cnt FROM t");
assert_eq!(rows[0]["cnt"].as_i64().unwrap(), 2);
let rows = query_resilient(&te.engine, "SELECT COUNT(*) as cnt FROM t WHERE id = 99");
assert_eq!(rows[0]["cnt"].as_i64().unwrap(), 0);
}
#[test]
fn batched_upsert_inserts_when_row_missing() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE settings (key TEXT NOT NULL, value TEXT NOT NULL)")
.unwrap();
te.engine
.execute_in_transaction(|engine| {
engine.execute_command("UPDATE settings SET value = 'dark' WHERE key = 'theme'")?;
engine.execute_command(
"INSERT INTO settings (key, value) SELECT 'theme', 'dark' \
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'theme')",
)?;
Ok(())
})
.unwrap();
let rows = query_resilient(&te.engine, "SELECT value FROM settings WHERE key = 'theme'");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["value"].as_str().unwrap(), "dark");
}
#[test]
fn batched_upsert_updates_when_row_exists() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE settings (key TEXT NOT NULL, value TEXT NOT NULL)")
.unwrap();
te.engine
.execute_command("INSERT INTO settings (key, value) VALUES ('theme', 'light')")
.unwrap();
te.engine
.execute_in_transaction(|engine| {
engine.execute_command("UPDATE settings SET value = 'dark' WHERE key = 'theme'")?;
engine.execute_command(
"INSERT INTO settings (key, value) SELECT 'theme', 'dark' \
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'theme')",
)?;
Ok(())
})
.unwrap();
let rows = query_resilient(
&te.engine,
"SELECT COUNT(*) as cnt FROM settings WHERE key = 'theme'",
);
assert_eq!(rows[0]["cnt"].as_i64().unwrap(), 1);
let rows = query_resilient(&te.engine, "SELECT value FROM settings WHERE key = 'theme'");
assert_eq!(rows[0]["value"].as_str().unwrap(), "dark");
}
#[test]
fn batched_multi_table_mutation_rolls_back_on_second_failure() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE orders (id INT NOT NULL, customer_id INT)")
.unwrap();
te.engine
.execute_command("CREATE TABLE customers (id INT NOT NULL, total_orders INT NOT NULL)")
.unwrap();
te.engine
.execute_command("INSERT INTO customers VALUES (42, 0)")
.unwrap();
use hyperdb_mcp::error::McpError;
let result: Result<(), McpError> = te.engine.execute_in_transaction(|engine| {
engine.execute_command("INSERT INTO orders (id, customer_id) VALUES (1001, 42)")?;
engine.execute_command("INSERT INTO orders (customer_id) VALUES (42)")?;
engine.execute_command(
"UPDATE customers SET total_orders = total_orders + 2 WHERE id = 42",
)?;
Ok(())
});
assert!(result.is_err(), "batch should fail on NOT NULL violation");
let rows = query_resilient(&te.engine, "SELECT COUNT(*) as cnt FROM orders");
assert_eq!(
rows[0]["cnt"].as_i64().unwrap(),
0,
"first INSERT must have rolled back — atomicity violated"
);
let rows = query_resilient(
&te.engine,
"SELECT total_orders FROM customers WHERE id = 42",
);
assert_eq!(rows[0]["total_orders"].as_i64().unwrap(), 0);
}
#[test]
fn failed_replace_leaves_empty_table_not_partial() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE t (id INT, name TEXT)")
.unwrap();
te.engine
.execute_command("INSERT INTO t VALUES (100, 'Original')")
.unwrap();
let data = r#"[
{"id": 1},
{"id": "bogus"}
]"#;
let mut override_map = serde_json::Map::new();
override_map.insert("id".into(), serde_json::Value::String("INT".into()));
let opts = IngestOptions {
table: "t".into(),
mode: "replace".into(),
schema_override: Some(override_map),
merge_key: None,
target_db: None,
};
let result = ingest_json(&te.engine, data, &opts);
assert!(result.is_err(), "ingest should fail on type cast error");
let rows = query_resilient(&te.engine, "SELECT COUNT(*) as cnt FROM t");
let count = rows[0]["cnt"].as_i64().expect("count should be int");
assert_eq!(
count, 0,
"expected partial INSERTs to be rolled back, leaving 0 rows"
);
}