From a2ad264b392369ada53c7d4d47286abe4449bef1 Mon Sep 17 00:00:00 2001 From: davidemazzocchi Date: Wed, 29 Oct 2025 07:41:39 +0100 Subject: [PATCH] chore: extract tests from sqlite_* files into their own module --- 01.workspace/heave/src/fun/sqlite_init_db.rs | 54 ------ .../heave/src/fun/sqlite_load_attributes.rs | 95 ----------- .../heave/src/fun/sqlite_load_by_class.rs | 78 --------- .../heave/src/fun/sqlite_load_by_id.rs | 82 --------- .../src/fun/sqlite_map_row_to_attribute.rs | 75 -------- .../heave/src/fun/sqlite_map_row_to_entity.rs | 50 ------ .../heave/src/fun/sqlite_persist_catalog.rs | 160 ------------------ 01.workspace/heave/src/tst/mod.rs | 7 + 01.workspace/heave/src/tst/sqlite_init_db.rs | 54 ++++++ .../heave/src/tst/sqlite_load_attributes.rs | 94 ++++++++++ .../heave/src/tst/sqlite_load_by_class.rs | 77 +++++++++ .../heave/src/tst/sqlite_load_by_id.rs | 81 +++++++++ .../src/tst/sqlite_map_row_to_attribute.rs | 74 ++++++++ .../heave/src/tst/sqlite_map_row_to_entity.rs | 49 ++++++ .../heave/src/tst/sqlite_persist_catalog.rs | 160 ++++++++++++++++++ 15 files changed, 596 insertions(+), 594 deletions(-) create mode 100644 01.workspace/heave/src/tst/sqlite_init_db.rs create mode 100644 01.workspace/heave/src/tst/sqlite_load_attributes.rs create mode 100644 01.workspace/heave/src/tst/sqlite_load_by_class.rs create mode 100644 01.workspace/heave/src/tst/sqlite_load_by_id.rs create mode 100644 01.workspace/heave/src/tst/sqlite_map_row_to_attribute.rs create mode 100644 01.workspace/heave/src/tst/sqlite_map_row_to_entity.rs create mode 100644 01.workspace/heave/src/tst/sqlite_persist_catalog.rs diff --git a/01.workspace/heave/src/fun/sqlite_init_db.rs b/01.workspace/heave/src/fun/sqlite_init_db.rs index 9e4c44c..c99cb50 100644 --- a/01.workspace/heave/src/fun/sqlite_init_db.rs +++ b/01.workspace/heave/src/fun/sqlite_init_db.rs @@ -30,57 +30,3 @@ pub fn run(path: &path::Path) -> result::Result<(), FailedTo> { .map_err(|_| sqlite::FailedTo::ExecuteBatch)?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::Path; - #[test] - fn init_db_should_create_tables_and_indexes_on_new_db() { - // This test verifies that a new database is correctly initialized with the necessary tables ('entity', 'attribute') and indexes. - let db_path = Path::new("test_new.db"); - let _ = fs::remove_file(db_path); // Ensure the file doesn't exist - assert!(run(db_path).is_ok()); - let conn = Connection::open(db_path).unwrap(); - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entity', 'attribute')") - .unwrap(); - let tables: Vec = stmt - .query_map([], |row| row.get(0)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(tables.len(), 2); - assert!(tables.contains(&"entity".to_string())); - assert!(tables.contains(&"attribute".to_string())); - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name IN ('entity_class', 'attribute_id')") - .unwrap(); - let indexes: Vec = stmt - .query_map([], |row| row.get(0)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(indexes.len(), 2); - assert!(indexes.contains(&"entity_class".to_string())); - assert!(indexes.contains(&"attribute_id".to_string())); - let _ = fs::remove_file(db_path); - } - #[test] - fn init_db_should_be_idempotent_and_not_fail_on_existing_db() { - // This test ensures that initializing an already existing and initialized database does not cause errors. - let db_path = Path::new("test_idempotent.db"); - let _ = fs::remove_file(db_path); - assert!(run(db_path).is_ok()); - assert!(run(db_path).is_ok()); - let _ = fs::remove_file(db_path); - } - #[test] - fn init_db_should_fail_gracefully_on_invalid_path() { - // This test checks that the function returns an error when provided with an invalid or inaccessible file path. - // An invalid path, like a directory, should cause an error - let invalid_path = Path::new("/"); - assert!(run(invalid_path).is_err()); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_load_attributes.rs b/01.workspace/heave/src/fun/sqlite_load_attributes.rs index 959b7de..4b7cee0 100644 --- a/01.workspace/heave/src/fun/sqlite_load_attributes.rs +++ b/01.workspace/heave/src/fun/sqlite_load_attributes.rs @@ -19,98 +19,3 @@ pub fn run(transaction: &Transaction, entity: &mut Entity) -> Result<(), FailedT } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Entity, Value, fun}; - use std::{collections::HashMap, fs, path::Path}; - fn setup_db(db_path: &Path) -> Connection { - let _ = fs::remove_file(db_path); - fun::sqlite_init_db::run(db_path).unwrap(); - Connection::open(db_path).unwrap() - } - #[test] - fn load_attributes_should_populate_entity_from_db() { - // Verifies that attributes for a given entity are correctly loaded from the database and added to the entity's attributes map. - let db_path = Path::new("test_load_attributes.db"); - let mut conn = setup_db(db_path); - let mut entity = Entity { - id: "entity1".to_string(), - class: "class1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: Default::default(), - ref_date: None, - }; - conn.execute( - "INSERT INTO entity (id, class) VALUES (?1, ?2)", - params![&entity.id, &entity.class], - ) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_text) VALUES ('attr1', 'entity1', 'hello')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_int) VALUES ('attr2', 'entity1', 42)", - [], - ) - .unwrap(); - let tx = conn.transaction().unwrap(); - assert!(run(&tx, &mut entity).is_ok()); - tx.commit().unwrap(); - assert_eq!(entity.attributes.len(), 2); - let attr1 = entity.attributes.get("attr1").unwrap(); - assert_eq!(attr1.id, "attr1"); - assert_eq!(attr1.value, Value::Text("hello".to_string())); - let attr2 = entity.attributes.get("attr2").unwrap(); - assert_eq!(attr2.id, "attr2"); - assert_eq!(attr2.value, Value::SignedInt(42)); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_attributes_should_handle_entities_with_no_attributes() { - // Ensures that the function completes without error and without adding attributes for an entity that has none in the database. - let db_path = Path::new("test_no_attributes.db"); - let mut conn = setup_db(db_path); - let mut entity = Entity { - id: "entity2".to_string(), - class: "class1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: Default::default(), - ref_date: None, - }; - conn.execute( - "INSERT INTO entity (id, class) VALUES (?1, ?2)", - params![&entity.id, &entity.class], - ) - .unwrap(); - let tx = conn.transaction().unwrap(); - assert!(run(&tx, &mut entity).is_ok()); - tx.commit().unwrap(); - assert!(entity.attributes.is_empty()); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_attributes_should_return_error_on_query_failure() { - // Checks that an error is returned if the database query to select attributes fails. - let db_path = Path::new("test_query_failure.db"); - let mut conn = setup_db(db_path); - conn.execute("DROP TABLE attribute", []).unwrap(); - let mut entity = Entity { - id: "entity3".to_string(), - class: "class1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: Default::default(), - ref_date: None, - }; - let tx = conn.transaction().unwrap(); - let result = run(&tx, &mut entity); - assert!(result.is_err()); - fs::remove_file(db_path).unwrap(); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_load_by_class.rs b/01.workspace/heave/src/fun/sqlite_load_by_class.rs index b8fed1f..81fc509 100644 --- a/01.workspace/heave/src/fun/sqlite_load_by_class.rs +++ b/01.workspace/heave/src/fun/sqlite_load_by_class.rs @@ -27,81 +27,3 @@ pub fn run(path: &path::Path, entity_class: &str) -> Result, FailedT } Ok(entities) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{fun, str::value::Value}; - use std::{fs, path::Path}; - fn setup_db(db_path: &Path) { - let _ = fs::remove_file(db_path); - fun::sqlite_init_db::run(db_path).unwrap(); - let conn = Connection::open(db_path).unwrap(); - // Class 'c1' - conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c1', 'c1')", []) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1_c1', 'v1')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1_c1', 1)", - [], - ) - .unwrap(); - conn.execute("INSERT INTO entity (id, class) VALUES ('e2_c1', 'c1')", []) - .unwrap(); - // Class 'c2' - conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c2', 'c2')", []) - .unwrap(); - } - #[test] - fn load_by_class_should_fetch_all_entities_for_a_given_class() { - // Verifies that all entities belonging to a specific class are retrieved from the database. - let db_path = Path::new("test_fetch_by_class.db"); - setup_db(db_path); - let result = run(db_path, "c1"); - assert!(result.is_ok()); - let entities = result.unwrap(); - assert_eq!(entities.len(), 2); - let ids: Vec<_> = entities.iter().map(|e| e.id.clone()).collect(); - assert!(ids.contains(&"e1_c1".to_string())); - assert!(ids.contains(&"e2_c1".to_string())); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_class_should_return_empty_vec_for_non_existent_class() { - // Ensures that an empty vector is returned when querying for a class that has no entities in the database. - let db_path = Path::new("test_non_existent_class.db"); - setup_db(db_path); - let result = run(db_path, "non_existent"); - assert!(result.is_ok()); - let entities = result.unwrap(); - assert!(entities.is_empty()); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_class_should_fully_load_entities_with_attributes() { - // Checks that the retrieved entities are complete, including all their associated attributes. - let db_path = Path::new("test_fully_load.db"); - setup_db(db_path); - let entities = run(db_path, "c1").unwrap(); - let entity1 = entities.iter().find(|e| e.id == "e1_c1").unwrap(); - assert_eq!(entity1.attributes.len(), 2); - assert!(entity1.attributes.contains_key("a1")); - assert!(entity1.attributes.contains_key("a2")); - let attr1 = entity1.attributes.get("a1").unwrap(); - assert_eq!(attr1.value, Value::Text("v1".to_string())); - let attr2 = entity1.attributes.get("a2").unwrap(); - assert_eq!(attr2.value, Value::SignedInt(1)); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_class_should_fail_gracefully_on_db_connection_error() { - // Tests that the function returns an appropriate error if the database connection cannot be established. - let invalid_path = Path::new("/"); // A directory is not a valid database file - let result = run(invalid_path, "any_class"); - assert!(result.is_err()); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_load_by_id.rs b/01.workspace/heave/src/fun/sqlite_load_by_id.rs index e9cc326..9ff0501 100644 --- a/01.workspace/heave/src/fun/sqlite_load_by_id.rs +++ b/01.workspace/heave/src/fun/sqlite_load_by_id.rs @@ -22,85 +22,3 @@ pub fn run(path: &path::Path, entity_id: &str) -> Result, FailedT } Ok(entity) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{fun, str::value::Value}; - use std::{fs, path::Path}; - fn setup_db(db_path: &Path) { - let _ = fs::remove_file(db_path); - fun::sqlite_init_db::run(db_path).unwrap(); - let conn = Connection::open(db_path).unwrap(); - conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1', 'v1')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1', 100)", - [], - ) - .unwrap(); - conn.execute("INSERT INTO entity (id, class) VALUES ('e2', 'c2')", []) - .unwrap(); - } - #[test] - fn load_by_id_should_fetch_correct_entity() { - // Verifies that the correct entity is retrieved from the database when a valid ID is provided. - let db_path = Path::new("test_fetch_by_id.db"); - setup_db(db_path); - let result = run(db_path, "e1"); - assert!(result.is_ok()); - let entity_opt = result.unwrap(); - assert!(entity_opt.is_some()); - let entity = entity_opt.unwrap(); - assert_eq!(entity.id, "e1"); - assert_eq!(entity.class, "c1"); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_id_should_return_none_for_non_existent_id() { - // Ensures that `Ok(None)` is returned when querying for an ID that does not exist in the database. - let db_path = Path::new("test_non_existent_id.db"); - setup_db(db_path); - let result = run(db_path, "non_existent"); - assert!(result.is_ok()); - let entity_opt = result.unwrap(); - assert!(entity_opt.is_none()); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_id_should_load_entity_with_all_attributes() { - // Checks that the retrieved entity includes all of its associated attributes. - let db_path = Path::new("test_load_with_attributes.db"); - setup_db(db_path); - let entity = run(db_path, "e1").unwrap().unwrap(); - assert_eq!(entity.attributes.len(), 2); - let attr1 = entity.attributes.get("a1").unwrap(); - assert_eq!(attr1.value, Value::Text("v1".to_string())); - let attr2 = entity.attributes.get("a2").unwrap(); - assert_eq!(attr2.value, Value::SignedInt(100)); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn load_by_id_should_fail_gracefully_on_db_error() { - // Tests that an error is returned if the database query fails for any reason. - // Test case 1: Invalid path - let invalid_path = Path::new("/"); - let result = run(invalid_path, "any_id"); - assert!(result.is_err()); - // Test case 2: Query error - let db_path = Path::new("test_db_error.db"); - let _ = fs::remove_file(db_path); - fun::sqlite_init_db::run(db_path).unwrap(); - let conn = Connection::open(db_path).unwrap(); - conn.execute("DROP TABLE entity", []).unwrap(); - drop(conn); // Close connection before `run` tries to open it. - let result = run(db_path, "any_id"); - assert!(result.is_err()); - fs::remove_file(db_path).unwrap(); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_map_row_to_attribute.rs b/01.workspace/heave/src/fun/sqlite_map_row_to_attribute.rs index a773774..6aa1712 100644 --- a/01.workspace/heave/src/fun/sqlite_map_row_to_attribute.rs +++ b/01.workspace/heave/src/fun/sqlite_map_row_to_attribute.rs @@ -20,78 +20,3 @@ pub fn run(row: &rusqlite::Row) -> rusqlite::Result { }; Ok(Attribute { id, value }) } - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::{Connection, Result}; - fn get_row_from_query(conn: &Connection, query: &str) -> Result { - let mut stmt = conn.prepare(query).unwrap(); - stmt.query_row([], run) - } - #[test] - fn map_row_to_attribute_with_signed_int_value() { - // Verifies that a database row with a signed integer value is correctly mapped to an Attribute with a Value::SignedInt. - let conn = Connection::open_in_memory().unwrap(); - let attr = - get_row_from_query(&conn, "SELECT 'a1', 'e1', -42, NULL, NULL, NULL, NULL").unwrap(); - assert_eq!(attr.id, "a1"); - assert_eq!(attr.value, Value::SignedInt(-42)); - } - #[test] - fn map_row_to_attribute_with_unsigned_int_value() { - // Verifies that a database row with an unsigned integer value is correctly mapped to an Attribute with a Value::UnsignedInt. - let conn = Connection::open_in_memory().unwrap(); - let attr = - get_row_from_query(&conn, "SELECT 'a2', 'e1', NULL, 42, NULL, NULL, NULL").unwrap(); - assert_eq!(attr.id, "a2"); - assert_eq!(attr.value, Value::UnsignedInt(42)); - } - #[test] - fn map_row_to_attribute_with_real_value() { - // Verifies that a database row with a real (float) value is correctly mapped to an Attribute with a Value::Real. - let conn = Connection::open_in_memory().unwrap(); - let attr = get_row_from_query(&conn, "SELECT 'a3', 'e1', NULL, NULL, 12.2345, NULL, NULL") - .unwrap(); - assert_eq!(attr.id, "a3"); - assert_eq!(attr.value, Value::Real(12.2345)); - } - #[test] - fn map_row_to_attribute_with_text_value() { - // Verifies that a database row with a text value is correctly mapped to an Attribute with a Value::Text. - let conn = Connection::open_in_memory().unwrap(); - let attr = get_row_from_query(&conn, "SELECT 'a4', 'e1', NULL, NULL, NULL, 'hello', NULL") - .unwrap(); - assert_eq!(attr.id, "a4"); - assert_eq!(attr.value, Value::Text("hello".to_string())); - } - #[test] - fn map_row_to_attribute_with_bool_value() { - // Verifies that a database row with a boolean value is correctly mapped to an Attribute with a Value::Bool. - let conn = Connection::open_in_memory().unwrap(); - let attr = - get_row_from_query(&conn, "SELECT 'a5', 'e1', NULL, NULL, NULL, NULL, 1").unwrap(); - assert_eq!(attr.id, "a5"); - assert_eq!(attr.value, Value::Bool(true)); - } - #[test] - #[should_panic] - fn map_row_to_attribute_should_panic_on_multiple_values() { - // Ensures that the function panics if a row contains data in more than one 'value' column, which indicates data corruption. - let conn = Connection::open_in_memory().unwrap(); - // This should panic because both signed_int and text have values. - get_row_from_query(&conn, "SELECT 'a6', 'e1', -42, NULL, NULL, 'hello', NULL").unwrap(); - } - #[test] - fn map_row_to_attribute_should_return_error_on_type_mismatch() { - // Checks that a rusqlite::Error is returned if a column's type does not match the expected schema (e.g., text in an integer column). - let conn = Connection::open_in_memory().unwrap(); - // Trying to get a text value as an integer should fail. - let result = get_row_from_query( - &conn, - // The value 'not a number' is in the signed_int column position. - "SELECT 'a7', 'e1', 'not a number', NULL, NULL, NULL, NULL", - ); - assert!(result.is_err()); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_map_row_to_entity.rs b/01.workspace/heave/src/fun/sqlite_map_row_to_entity.rs index 2839341..79321bc 100644 --- a/01.workspace/heave/src/fun/sqlite_map_row_to_entity.rs +++ b/01.workspace/heave/src/fun/sqlite_map_row_to_entity.rs @@ -15,53 +15,3 @@ pub fn run(row: &rusqlite::Row) -> rusqlite::Result { }; Ok(entity) } - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::{Connection, Result}; - fn get_entity_from_query(conn: &Connection, query: &str) -> Result { - let mut stmt = conn.prepare(query).unwrap(); - stmt.query_row([], run) - } - #[test] - fn map_row_to_entity_should_correctly_map_valid_row() { - // Verifies that a valid database row is correctly mapped to an Entity struct with all fields populated. - let conn = Connection::open_in_memory().unwrap(); - let entity = get_entity_from_query(&conn, "SELECT 'e1', 'c1', 's1', 12345").unwrap(); - assert_eq!(entity.id, "e1"); - assert_eq!(entity.class, "c1"); - assert_eq!(entity.subclass, Some("s1".to_string())); - assert_eq!(entity.ref_date, Some(12345)); - assert_eq!(entity.state, EntityState::Loaded); - assert!(entity.attributes.is_empty()); - } - #[test] - fn map_row_to_entity_should_handle_null_ref_date() { - // Ensures that a row with a NULL 'ref_date' is successfully mapped to an Entity with 'ref_date' as None. - let conn = Connection::open_in_memory().unwrap(); - let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', 's2', NULL").unwrap(); - assert_eq!(entity.id, "e2"); - assert_eq!(entity.class, "c2"); - assert_eq!(entity.subclass, Some("s2".to_string())); - assert_eq!(entity.ref_date, None); - } - #[test] - fn map_row_to_entity_should_handle_null_subclass() { - // Ensures that a row with a NULL 'subclass' is successfully mapped to an Entity with 'subclass' as None. - let conn = Connection::open_in_memory().unwrap(); - let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', NULL, NULL").unwrap(); - assert_eq!(entity.id, "e2"); - assert_eq!(entity.class, "c2"); - assert_eq!(entity.subclass, None); - assert_eq!(entity.ref_date, None); - } - #[test] - fn map_row_to_entity_should_return_error_on_type_mismatch() { - // Checks that a rusqlite::Error is returned if a column's type does not match the expected schema. - let conn = Connection::open_in_memory().unwrap(); - // Trying to get an integer as a string for the class should fail. - let result = get_entity_from_query(&conn, "SELECT 'e3', 123, NULL"); - assert!(result.is_err()); - } -} diff --git a/01.workspace/heave/src/fun/sqlite_persist_catalog.rs b/01.workspace/heave/src/fun/sqlite_persist_catalog.rs index 3e74822..9174e85 100644 --- a/01.workspace/heave/src/fun/sqlite_persist_catalog.rs +++ b/01.workspace/heave/src/fun/sqlite_persist_catalog.rs @@ -85,163 +85,3 @@ pub fn run(path: &path::Path, catalog: &Catalog) -> result::Result<(), FailedTo> .map_err(|_| sqlite::FailedTo::CommitTransaction)?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::{collections::HashMap, fs, path::Path}; - fn setup_db(db_path: &Path) -> Connection { - let _ = fs::remove_file(db_path); - fun::sqlite_init_db::run(db_path).unwrap(); - Connection::open(db_path).unwrap() - } - fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> i64 { - let mut stmt = conn - .prepare(&format!( - "SELECT COUNT(*) FROM {} WHERE {}", - table, where_clause - )) - .unwrap(); - stmt.query_row([], |row| row.get(0)).unwrap() - } - #[test] - fn persist_should_insert_new_entities_and_attributes() { - // Verifies that entities marked as 'New' in the catalog are inserted into the database, along with all their attributes. - let db_path = Path::new("test_insert.db"); - let conn = setup_db(db_path); - let mut catalog = Catalog::new(db_path.to_str().unwrap()); - let mut entity = Entity { - id: "e1".to_string(), - class: "c1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::New, - ref_date: None, - }; - entity.attributes.insert( - "a1".to_string(), - Attribute { - id: "a1".to_string(), - value: Value::Text("v1".to_string()), - }, - ); - catalog.items.insert("e1".to_string(), entity); - assert!(run(db_path, &catalog).is_ok()); - assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1); - assert_eq!(count_rows(&conn, "attribute", "entity_id = 'e1'"), 1); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn persist_should_delete_entities_marked_for_deletion() { - // Ensures that entities marked as 'ToDelete' are correctly removed from the database. - let db_path = Path::new("test_delete.db"); - let conn = setup_db(db_path); - conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) - .unwrap(); - let mut catalog = Catalog::new(db_path.to_str().unwrap()); - let entity = Entity { - id: "e1".to_string(), - class: "c1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::ToDelete, - ref_date: None, - }; - catalog.items.insert("e1".to_string(), entity); - assert!(run(db_path, &catalog).is_ok()); - assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn persist_should_handle_a_mix_of_new_and_deleted_entities() { - // Tests the function's ability to handle a batch operation involving both new entities to be inserted and existing ones to be deleted. - let db_path = Path::new("test_mix.db"); - let conn = setup_db(db_path); - conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) - .unwrap(); - let mut catalog = Catalog::new(db_path.to_str().unwrap()); - let to_delete = Entity { - id: "e1".to_string(), - class: "c1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::ToDelete, - ref_date: None, - }; - let to_add = Entity { - id: "e2".to_string(), - class: "c2".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::New, - ref_date: None, - }; - catalog.items.insert("e1".to_string(), to_delete); - catalog.items.insert("e2".to_string(), to_add); - assert!(run(db_path, &catalog).is_ok()); - assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0); - assert_eq!(count_rows(&conn, "entity", "id = 'e2'"), 1); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn persist_should_not_affect_unmodified_entities() { - // Verifies that entities in the catalog that are not marked as 'New' or 'ToDelete' remain untouched in the database. - let db_path = Path::new("test_unmodified.db"); - let conn = setup_db(db_path); - conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) - .unwrap(); - let mut catalog = Catalog::new(db_path.to_str().unwrap()); - let unmodified = Entity { - id: "e1".to_string(), - class: "c1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::Loaded, - ref_date: None, - }; - catalog.items.insert("e1".to_string(), unmodified); - assert!(run(db_path, &catalog).is_ok()); - assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1); - fs::remove_file(db_path).unwrap(); - } - #[test] - fn persist_should_rollback_transaction_on_failure() { - // Ensures that if any part of the persistence process fails, the entire transaction is rolled back, leaving the database state unchanged. - let db_path = Path::new("test_rollback.db"); - let conn = setup_db(db_path); - conn.execute( - "INSERT INTO entity (id, class) VALUES ('e_existing', 'c1')", - [], - ) - .unwrap(); - let mut catalog = Catalog::new(db_path.to_str().unwrap()); - let mut new_entity = Entity { - id: "e_new".to_string(), - class: "c1".to_string(), - subclass: None, - attributes: HashMap::new(), - state: EntityState::New, - ref_date: None, - }; - new_entity.attributes.insert( - "a1".to_string(), - Attribute { - id: "a1".to_string(), - value: Value::Text("v1".to_string()), - }, - ); - catalog.items.insert("e_new".to_string(), new_entity); - // Corrupt the DB to cause a failure during the transaction - conn.execute("DROP TABLE attribute", []).unwrap(); - drop(conn); - let result = run(db_path, &catalog); - assert!(result.is_err()); - // Re-open connection to check state - let conn = Connection::open(db_path).unwrap(); - // The new entity should not have been inserted due to rollback - assert_eq!(count_rows(&conn, "entity", "id = 'e_new'"), 0); - // The existing entity should still be there - assert_eq!(count_rows(&conn, "entity", "id = 'e_existing'"), 1); - fs::remove_file(db_path).unwrap(); - } -} diff --git a/01.workspace/heave/src/tst/mod.rs b/01.workspace/heave/src/tst/mod.rs index 8a88c9a..2ff9e1c 100644 --- a/01.workspace/heave/src/tst/mod.rs +++ b/01.workspace/heave/src/tst/mod.rs @@ -11,3 +11,10 @@ pub mod catalog_load_by_id; pub mod catalog_new; pub mod catalog_persist; pub mod catalog_upsert; +pub mod sqlite_init_db; +pub mod sqlite_load_attributes; +pub mod sqlite_load_by_class; +pub mod sqlite_load_by_id; +pub mod sqlite_map_row_to_attribute; +pub mod sqlite_map_row_to_entity; +pub mod sqlite_persist_catalog; diff --git a/01.workspace/heave/src/tst/sqlite_init_db.rs b/01.workspace/heave/src/tst/sqlite_init_db.rs new file mode 100644 index 0000000..5c84384 --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_init_db.rs @@ -0,0 +1,54 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + use std::fs; + use std::path::Path; + #[test] + fn init_db_should_create_tables_and_indexes_on_new_db() { + // This test verifies that a new database is correctly initialized with the necessary tables ('entity', 'attribute') and indexes. + let db_path = Path::new("test_new.db"); + let _ = fs::remove_file(db_path); // Ensure the file doesn't exist + assert!(sqlite::init::db(db_path).is_ok()); + let conn = Connection::open(db_path).unwrap(); + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entity', 'attribute')") + .unwrap(); + let tables: Vec = stmt + .query_map([], |row| row.get(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!(tables.len(), 2); + assert!(tables.contains(&"entity".to_string())); + assert!(tables.contains(&"attribute".to_string())); + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name IN ('entity_class', 'attribute_id')") + .unwrap(); + let indexes: Vec = stmt + .query_map([], |row| row.get(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!(indexes.len(), 2); + assert!(indexes.contains(&"entity_class".to_string())); + assert!(indexes.contains(&"attribute_id".to_string())); + let _ = fs::remove_file(db_path); + } + #[test] + fn init_db_should_be_idempotent_and_not_fail_on_existing_db() { + // This test ensures that initializing an already existing and initialized database does not cause errors. + let db_path = Path::new("test_idempotent.db"); + let _ = fs::remove_file(db_path); + assert!(sqlite::init::db(db_path).is_ok()); + assert!(sqlite::init::db(db_path).is_ok()); + let _ = fs::remove_file(db_path); + } + #[test] + fn init_db_should_fail_gracefully_on_invalid_path() { + // This test checks that the function returns an error when provided with an invalid or inaccessible file path. + // An invalid path, like a directory, should cause an error + let invalid_path = Path::new("/"); + assert!(sqlite::init::db(invalid_path).is_err()); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_load_attributes.rs b/01.workspace/heave/src/tst/sqlite_load_attributes.rs new file mode 100644 index 0000000..6a5d61d --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_load_attributes.rs @@ -0,0 +1,94 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + use std::{collections::HashMap, fs, path::Path}; + fn setup_db(db_path: &Path) -> Connection { + let _ = fs::remove_file(db_path); + sqlite::init::db(db_path).unwrap(); + Connection::open(db_path).unwrap() + } + #[test] + fn load_attributes_should_populate_entity_from_db() { + // Verifies that attributes for a given entity are correctly loaded from the database and added to the entity's attributes map. + let db_path = Path::new("test_load_attributes.db"); + let mut conn = setup_db(db_path); + let mut entity = Entity { + id: "entity1".to_string(), + class: "class1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: Default::default(), + ref_date: None, + }; + conn.execute( + "INSERT INTO entity (id, class) VALUES (?1, ?2)", + params![&entity.id, &entity.class], + ) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_text) VALUES ('attr1', 'entity1', 'hello')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_int) VALUES ('attr2', 'entity1', 42)", + [], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + assert!(sqlite::load::attributes(&tx, &mut entity).is_ok()); + tx.commit().unwrap(); + assert_eq!(entity.attributes.len(), 2); + let attr1 = entity.attributes.get("attr1").unwrap(); + assert_eq!(attr1.id, "attr1"); + assert_eq!(attr1.value, Value::Text("hello".to_string())); + let attr2 = entity.attributes.get("attr2").unwrap(); + assert_eq!(attr2.id, "attr2"); + assert_eq!(attr2.value, Value::SignedInt(42)); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_attributes_should_handle_entities_with_no_attributes() { + // Ensures that the function completes without error and without adding attributes for an entity that has none in the database. + let db_path = Path::new("test_no_attributes.db"); + let mut conn = setup_db(db_path); + let mut entity = Entity { + id: "entity2".to_string(), + class: "class1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: Default::default(), + ref_date: None, + }; + conn.execute( + "INSERT INTO entity (id, class) VALUES (?1, ?2)", + params![&entity.id, &entity.class], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + assert!(sqlite::load::attributes(&tx, &mut entity).is_ok()); + tx.commit().unwrap(); + assert!(entity.attributes.is_empty()); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_attributes_should_return_error_on_query_failure() { + // Checks that an error is returned if the database query to select attributes fails. + let db_path = Path::new("test_query_failure.db"); + let mut conn = setup_db(db_path); + conn.execute("DROP TABLE attribute", []).unwrap(); + let mut entity = Entity { + id: "entity3".to_string(), + class: "class1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: Default::default(), + ref_date: None, + }; + let tx = conn.transaction().unwrap(); + let result = sqlite::load::attributes(&tx, &mut entity); + assert!(result.is_err()); + fs::remove_file(db_path).unwrap(); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_load_by_class.rs b/01.workspace/heave/src/tst/sqlite_load_by_class.rs new file mode 100644 index 0000000..c21f86d --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_load_by_class.rs @@ -0,0 +1,77 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + use std::{fs, path::Path}; + fn setup_db(db_path: &Path) { + let _ = fs::remove_file(db_path); + sqlite::init::db(db_path).unwrap(); + let conn = Connection::open(db_path).unwrap(); + // Class 'c1' + conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c1', 'c1')", []) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1_c1', 'v1')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1_c1', 1)", + [], + ) + .unwrap(); + conn.execute("INSERT INTO entity (id, class) VALUES ('e2_c1', 'c1')", []) + .unwrap(); + // Class 'c2' + conn.execute("INSERT INTO entity (id, class) VALUES ('e1_c2', 'c2')", []) + .unwrap(); + } + #[test] + fn load_by_class_should_fetch_all_entities_for_a_given_class() { + // Verifies that all entities belonging to a specific class are retrieved from the database. + let db_path = Path::new("test_fetch_by_class.db"); + setup_db(db_path); + let result = sqlite::load::by_class(db_path, "c1"); + assert!(result.is_ok()); + let entities = result.unwrap(); + assert_eq!(entities.len(), 2); + let ids: Vec<_> = entities.iter().map(|e| e.id.clone()).collect(); + assert!(ids.contains(&"e1_c1".to_string())); + assert!(ids.contains(&"e2_c1".to_string())); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_class_should_return_empty_vec_for_non_existent_class() { + // Ensures that an empty vector is returned when querying for a class that has no entities in the database. + let db_path = Path::new("test_non_existent_class.db"); + setup_db(db_path); + let result = sqlite::load::by_class(db_path, "non_existent"); + assert!(result.is_ok()); + let entities = result.unwrap(); + assert!(entities.is_empty()); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_class_should_fully_load_entities_with_attributes() { + // Checks that the retrieved entities are complete, including all their associated attributes. + let db_path = Path::new("test_fully_load.db"); + setup_db(db_path); + let entities = sqlite::load::by_class(db_path, "c1").unwrap(); + let entity1 = entities.iter().find(|e| e.id == "e1_c1").unwrap(); + assert_eq!(entity1.attributes.len(), 2); + assert!(entity1.attributes.contains_key("a1")); + assert!(entity1.attributes.contains_key("a2")); + let attr1 = entity1.attributes.get("a1").unwrap(); + assert_eq!(attr1.value, Value::Text("v1".to_string())); + let attr2 = entity1.attributes.get("a2").unwrap(); + assert_eq!(attr2.value, Value::SignedInt(1)); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_class_should_fail_gracefully_on_db_connection_error() { + // Tests that the function returns an appropriate error if the database connection cannot be established. + let invalid_path = Path::new("/"); // A directory is not a valid database file + let result = sqlite::load::by_class(invalid_path, "any_class"); + assert!(result.is_err()); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_load_by_id.rs b/01.workspace/heave/src/tst/sqlite_load_by_id.rs new file mode 100644 index 0000000..8c845e9 --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_load_by_id.rs @@ -0,0 +1,81 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + use std::{fs, path::Path}; + fn setup_db(db_path: &Path) { + let _ = fs::remove_file(db_path); + sqlite::init::db(db_path).unwrap(); + let conn = Connection::open(db_path).unwrap(); + conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_text) VALUES ('a1', 'e1', 'v1')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO attribute (id, entity_id, value_int) VALUES ('a2', 'e1', 100)", + [], + ) + .unwrap(); + conn.execute("INSERT INTO entity (id, class) VALUES ('e2', 'c2')", []) + .unwrap(); + } + #[test] + fn load_by_id_should_fetch_correct_entity() { + // Verifies that the correct entity is retrieved from the database when a valid ID is provided. + let db_path = Path::new("test_fetch_by_id.db"); + setup_db(db_path); + let result = sqlite::load::by_id(db_path, "e1"); + assert!(result.is_ok()); + let entity_opt = result.unwrap(); + assert!(entity_opt.is_some()); + let entity = entity_opt.unwrap(); + assert_eq!(entity.id, "e1"); + assert_eq!(entity.class, "c1"); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_id_should_return_none_for_non_existent_id() { + // Ensures that `Ok(None)` is returned when querying for an ID that does not exist in the database. + let db_path = Path::new("test_non_existent_id.db"); + setup_db(db_path); + let result = sqlite::load::by_id(db_path, "non_existent"); + assert!(result.is_ok()); + let entity_opt = result.unwrap(); + assert!(entity_opt.is_none()); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_id_should_load_entity_with_all_attributes() { + // Checks that the retrieved entity includes all of its associated attributes. + let db_path = Path::new("test_load_with_attributes.db"); + setup_db(db_path); + let entity = sqlite::load::by_id(db_path, "e1").unwrap().unwrap(); + assert_eq!(entity.attributes.len(), 2); + let attr1 = entity.attributes.get("a1").unwrap(); + assert_eq!(attr1.value, Value::Text("v1".to_string())); + let attr2 = entity.attributes.get("a2").unwrap(); + assert_eq!(attr2.value, Value::SignedInt(100)); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn load_by_id_should_fail_gracefully_on_db_error() { + // Tests that an error is returned if the database query fails for any reason. + // Test case 1: Invalid path + let invalid_path = Path::new("/"); + let result = sqlite::load::by_id(invalid_path, "any_id"); + assert!(result.is_err()); + // Test case 2: Query error + let db_path = Path::new("test_db_error.db"); + let _ = fs::remove_file(db_path); + sqlite::init::db(db_path).unwrap(); + let conn = Connection::open(db_path).unwrap(); + conn.execute("DROP TABLE entity", []).unwrap(); + drop(conn); // Close connection before `run` tries to open it. + let result = sqlite::load::by_id(db_path, "any_id"); + assert!(result.is_err()); + fs::remove_file(db_path).unwrap(); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_map_row_to_attribute.rs b/01.workspace/heave/src/tst/sqlite_map_row_to_attribute.rs new file mode 100644 index 0000000..ac10c8d --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_map_row_to_attribute.rs @@ -0,0 +1,74 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + fn get_row_from_query(conn: &Connection, query: &str) -> Result { + let mut stmt = conn.prepare(query).unwrap(); + stmt.query_row([], sqlite::map::row_to_attribute) + } + #[test] + fn map_row_to_attribute_with_signed_int_value() { + // Verifies that a database row with a signed integer value is correctly mapped to an Attribute with a Value::SignedInt. + let conn = Connection::open_in_memory().unwrap(); + let attr = + get_row_from_query(&conn, "SELECT 'a1', 'e1', -42, NULL, NULL, NULL, NULL").unwrap(); + assert_eq!(attr.id, "a1"); + assert_eq!(attr.value, Value::SignedInt(-42)); + } + #[test] + fn map_row_to_attribute_with_unsigned_int_value() { + // Verifies that a database row with an unsigned integer value is correctly mapped to an Attribute with a Value::UnsignedInt. + let conn = Connection::open_in_memory().unwrap(); + let attr = + get_row_from_query(&conn, "SELECT 'a2', 'e1', NULL, 42, NULL, NULL, NULL").unwrap(); + assert_eq!(attr.id, "a2"); + assert_eq!(attr.value, Value::UnsignedInt(42)); + } + #[test] + fn map_row_to_attribute_with_real_value() { + // Verifies that a database row with a real (float) value is correctly mapped to an Attribute with a Value::Real. + let conn = Connection::open_in_memory().unwrap(); + let attr = get_row_from_query(&conn, "SELECT 'a3', 'e1', NULL, NULL, 12.2345, NULL, NULL") + .unwrap(); + assert_eq!(attr.id, "a3"); + assert_eq!(attr.value, Value::Real(12.2345)); + } + #[test] + fn map_row_to_attribute_with_text_value() { + // Verifies that a database row with a text value is correctly mapped to an Attribute with a Value::Text. + let conn = Connection::open_in_memory().unwrap(); + let attr = get_row_from_query(&conn, "SELECT 'a4', 'e1', NULL, NULL, NULL, 'hello', NULL") + .unwrap(); + assert_eq!(attr.id, "a4"); + assert_eq!(attr.value, Value::Text("hello".to_string())); + } + #[test] + fn map_row_to_attribute_with_bool_value() { + // Verifies that a database row with a boolean value is correctly mapped to an Attribute with a Value::Bool. + let conn = Connection::open_in_memory().unwrap(); + let attr = + get_row_from_query(&conn, "SELECT 'a5', 'e1', NULL, NULL, NULL, NULL, 1").unwrap(); + assert_eq!(attr.id, "a5"); + assert_eq!(attr.value, Value::Bool(true)); + } + #[test] + #[should_panic] + fn map_row_to_attribute_should_panic_on_multiple_values() { + // Ensures that the function panics if a row contains data in more than one 'value' column, which indicates data corruption. + let conn = Connection::open_in_memory().unwrap(); + // This should panic because both signed_int and text have values. + get_row_from_query(&conn, "SELECT 'a6', 'e1', -42, NULL, NULL, 'hello', NULL").unwrap(); + } + #[test] + fn map_row_to_attribute_should_return_error_on_type_mismatch() { + // Checks that a rusqlite::Error is returned if a column's type does not match the expected schema (e.g., text in an integer column). + let conn = Connection::open_in_memory().unwrap(); + // Trying to get a text value as an integer should fail. + let result = get_row_from_query( + &conn, + // The value 'not a number' is in the signed_int column position. + "SELECT 'a7', 'e1', 'not a number', NULL, NULL, NULL, NULL", + ); + assert!(result.is_err()); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_map_row_to_entity.rs b/01.workspace/heave/src/tst/sqlite_map_row_to_entity.rs new file mode 100644 index 0000000..4b2e178 --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_map_row_to_entity.rs @@ -0,0 +1,49 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + fn get_entity_from_query(conn: &Connection, query: &str) -> Result { + let mut stmt = conn.prepare(query).unwrap(); + stmt.query_row([], sqlite::map::row_to_entity) + } + #[test] + fn map_row_to_entity_should_correctly_map_valid_row() { + // Verifies that a valid database row is correctly mapped to an Entity struct with all fields populated. + let conn = Connection::open_in_memory().unwrap(); + let entity = get_entity_from_query(&conn, "SELECT 'e1', 'c1', 's1', 12345").unwrap(); + assert_eq!(entity.id, "e1"); + assert_eq!(entity.class, "c1"); + assert_eq!(entity.subclass, Some("s1".to_string())); + assert_eq!(entity.ref_date, Some(12345)); + assert_eq!(entity.state, EntityState::Loaded); + assert!(entity.attributes.is_empty()); + } + #[test] + fn map_row_to_entity_should_handle_null_ref_date() { + // Ensures that a row with a NULL 'ref_date' is successfully mapped to an Entity with 'ref_date' as None. + let conn = Connection::open_in_memory().unwrap(); + let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', 's2', NULL").unwrap(); + assert_eq!(entity.id, "e2"); + assert_eq!(entity.class, "c2"); + assert_eq!(entity.subclass, Some("s2".to_string())); + assert_eq!(entity.ref_date, None); + } + #[test] + fn map_row_to_entity_should_handle_null_subclass() { + // Ensures that a row with a NULL 'subclass' is successfully mapped to an Entity with 'subclass' as None. + let conn = Connection::open_in_memory().unwrap(); + let entity = get_entity_from_query(&conn, "SELECT 'e2', 'c2', NULL, NULL").unwrap(); + assert_eq!(entity.id, "e2"); + assert_eq!(entity.class, "c2"); + assert_eq!(entity.subclass, None); + assert_eq!(entity.ref_date, None); + } + #[test] + fn map_row_to_entity_should_return_error_on_type_mismatch() { + // Checks that a rusqlite::Error is returned if a column's type does not match the expected schema. + let conn = Connection::open_in_memory().unwrap(); + // Trying to get an integer as a string for the class should fail. + let result = get_entity_from_query(&conn, "SELECT 'e3', 123, NULL"); + assert!(result.is_err()); + } +} diff --git a/01.workspace/heave/src/tst/sqlite_persist_catalog.rs b/01.workspace/heave/src/tst/sqlite_persist_catalog.rs new file mode 100644 index 0000000..e012f03 --- /dev/null +++ b/01.workspace/heave/src/tst/sqlite_persist_catalog.rs @@ -0,0 +1,160 @@ +#[cfg(test)] +mod tests { + use crate::*; + use rusqlite::*; + use std::{collections::HashMap, fs, path::Path}; + fn setup_db(db_path: &Path) -> Connection { + let _ = fs::remove_file(db_path); + fun::sqlite_init_db::run(db_path).unwrap(); + Connection::open(db_path).unwrap() + } + fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> i64 { + let mut stmt = conn + .prepare(&format!( + "SELECT COUNT(*) FROM {} WHERE {}", + table, where_clause + )) + .unwrap(); + stmt.query_row([], |row| row.get(0)).unwrap() + } + #[test] + fn persist_should_insert_new_entities_and_attributes() { + // Verifies that entities marked as 'New' in the catalog are inserted into the database, along with all their attributes. + let db_path = Path::new("test_insert.db"); + let conn = setup_db(db_path); + let mut catalog = Catalog::new(db_path.to_str().unwrap()); + let mut entity = Entity { + id: "e1".to_string(), + class: "c1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::New, + ref_date: None, + }; + entity.attributes.insert( + "a1".to_string(), + Attribute { + id: "a1".to_string(), + value: Value::Text("v1".to_string()), + }, + ); + catalog.items.insert("e1".to_string(), entity); + assert!(sqlite::persist::catalog(db_path, &catalog).is_ok()); + assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1); + assert_eq!(count_rows(&conn, "attribute", "entity_id = 'e1'"), 1); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn persist_should_delete_entities_marked_for_deletion() { + // Ensures that entities marked as 'ToDelete' are correctly removed from the database. + let db_path = Path::new("test_delete.db"); + let conn = setup_db(db_path); + conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) + .unwrap(); + let mut catalog = Catalog::new(db_path.to_str().unwrap()); + let entity = Entity { + id: "e1".to_string(), + class: "c1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::ToDelete, + ref_date: None, + }; + catalog.items.insert("e1".to_string(), entity); + assert!(sqlite::persist::catalog(db_path, &catalog).is_ok()); + assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn persist_should_handle_a_mix_of_new_and_deleted_entities() { + // Tests the function's ability to handle a batch operation involving both new entities to be inserted and existing ones to be deleted. + let db_path = Path::new("test_mix.db"); + let conn = setup_db(db_path); + conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) + .unwrap(); + let mut catalog = Catalog::new(db_path.to_str().unwrap()); + let to_delete = Entity { + id: "e1".to_string(), + class: "c1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::ToDelete, + ref_date: None, + }; + let to_add = Entity { + id: "e2".to_string(), + class: "c2".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::New, + ref_date: None, + }; + catalog.items.insert("e1".to_string(), to_delete); + catalog.items.insert("e2".to_string(), to_add); + assert!(sqlite::persist::catalog(db_path, &catalog).is_ok()); + assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 0); + assert_eq!(count_rows(&conn, "entity", "id = 'e2'"), 1); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn persist_should_not_affect_unmodified_entities() { + // Verifies that entities in the catalog that are not marked as 'New' or 'ToDelete' remain untouched in the database. + let db_path = Path::new("test_unmodified.db"); + let conn = setup_db(db_path); + conn.execute("INSERT INTO entity (id, class) VALUES ('e1', 'c1')", []) + .unwrap(); + let mut catalog = Catalog::new(db_path.to_str().unwrap()); + let unmodified = Entity { + id: "e1".to_string(), + class: "c1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::Loaded, + ref_date: None, + }; + catalog.items.insert("e1".to_string(), unmodified); + assert!(sqlite::persist::catalog(db_path, &catalog).is_ok()); + assert_eq!(count_rows(&conn, "entity", "id = 'e1'"), 1); + fs::remove_file(db_path).unwrap(); + } + #[test] + fn persist_should_rollback_transaction_on_failure() { + // Ensures that if any part of the persistence process fails, the entire transaction is rolled back, leaving the database state unchanged. + let db_path = Path::new("test_rollback.db"); + let conn = setup_db(db_path); + conn.execute( + "INSERT INTO entity (id, class) VALUES ('e_existing', 'c1')", + [], + ) + .unwrap(); + let mut catalog = Catalog::new(db_path.to_str().unwrap()); + let mut new_entity = Entity { + id: "e_new".to_string(), + class: "c1".to_string(), + subclass: None, + attributes: HashMap::new(), + state: EntityState::New, + ref_date: None, + }; + new_entity.attributes.insert( + "a1".to_string(), + Attribute { + id: "a1".to_string(), + value: Value::Text("v1".to_string()), + }, + ); + catalog.items.insert("e_new".to_string(), new_entity); + // Corrupt the DB to cause a failure during the transaction + conn.execute("DROP TABLE attribute", []).unwrap(); + drop(conn); + let result = sqlite::persist::catalog(db_path, &catalog); + assert!(result.is_err()); + // Re-open connection to check state + let conn = Connection::open(db_path).unwrap(); + // The new entity should not have been inserted due to rollback + assert_eq!(count_rows(&conn, "entity", "id = 'e_new'"), 0); + // The existing entity should still be there + assert_eq!(count_rows(&conn, "entity", "id = 'e_existing'"), 1); + fs::remove_file(db_path).unwrap(); + } +}