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 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"
);
}