chore: extract tests from sqlite_* files into their own module

This commit is contained in:
2025-10-29 07:41:39 +01:00
parent f5f0f189fa
commit a2ad264b39
15 changed files with 596 additions and 594 deletions

View File

@@ -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<String> = 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<String> = 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());
}
}

View File

@@ -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();
}
}

View File

@@ -27,81 +27,3 @@ pub fn run(path: &path::Path, entity_class: &str) -> Result<Vec<Entity>, 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());
}
}

View File

@@ -22,85 +22,3 @@ pub fn run(path: &path::Path, entity_id: &str) -> Result<Option<Entity>, 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();
}
}

View File

@@ -20,78 +20,3 @@ pub fn run(row: &rusqlite::Row) -> rusqlite::Result<Attribute> {
};
Ok(Attribute { id, value })
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::{Connection, Result};
fn get_row_from_query(conn: &Connection, query: &str) -> Result<Attribute, rusqlite::Error> {
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());
}
}

View File

@@ -15,53 +15,3 @@ pub fn run(row: &rusqlite::Row) -> rusqlite::Result<Entity> {
};
Ok(entity)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::{Connection, Result};
fn get_entity_from_query(conn: &Connection, query: &str) -> Result<Entity, rusqlite::Error> {
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());
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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<String> = 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<String> = 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());
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,74 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
fn get_row_from_query(conn: &Connection, query: &str) -> Result<Attribute, rusqlite::Error> {
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());
}
}

View File

@@ -0,0 +1,49 @@
#[cfg(test)]
mod tests {
use crate::*;
use rusqlite::*;
fn get_entity_from_query(conn: &Connection, query: &str) -> Result<Entity, rusqlite::Error> {
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());
}
}

View File

@@ -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();
}
}