From 6903d7668282b1659e65f957405bdbfa56418526 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 1 Jan 2026 17:35:58 +0100 Subject: [PATCH] feat(persistence): initialize SQLite database module - Add domain types: RelayId newtype and RepositoryError enum - Implement SqliteRelayLabelRepository with in-memory test support - Create relay_labels migration with SQLx compile-time verification - Add comprehensive integration test suite (266 lines) Ref: T006 (specs/001-modbus-relay-control) --- Cargo.lock | 1 + Cargo.toml | 3 + build.rs | 5 + specs/001-modbus-relay-control/tasks.md | 2 +- src/domain/mod.rs | 2 + src/domain/relay/mod.rs | 9 + src/domain/relay/repository.rs | 15 + src/domain/relay/types.rs | 14 + src/infrastructure/mod.rs | 2 + src/infrastructure/persistence/mod.rs | 7 + .../persistence/sqlite_repository.rs | 64 +++++ tests/sqlite_repository_test.rs | 266 ++++++++++++++++++ 12 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 build.rs create mode 100644 src/domain/relay/mod.rs create mode 100644 src/domain/relay/repository.rs create mode 100644 src/domain/relay/types.rs create mode 100644 src/infrastructure/persistence/mod.rs create mode 100644 src/infrastructure/persistence/sqlite_repository.rs create mode 100644 tests/sqlite_repository_test.rs diff --git a/Cargo.lock b/Cargo.lock index 1aa919b..85fe294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2640,6 +2640,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror", "tokio", "tokio-modbus", diff --git a/Cargo.toml b/Cargo.toml index 871d2f9..a07955a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,5 +31,8 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] } +[dev-dependencies] +tempfile = "3.15.0" + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d506869 --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index fcb30a6..cbb0bed 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -41,7 +41,7 @@ - **Test**: Schema file syntax is valid SQL - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T006** [P] [Setup] [TDD] Initialize SQLite database module +- [x] **T006** [P] [Setup] [TDD] Initialize SQLite database module - Create infrastructure/persistence/mod.rs - Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct - Implement SqliteRelayLabelRepository::new(path) using SqlitePool diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 91189cc..e38a835 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -34,3 +34,5 @@ //! - Architecture: `specs/constitution.md` - Hexagonal Architecture principles //! - Type design: `specs/001-modbus-relay-control/types-design.md` //! - Domain specification: `specs/001-modbus-relay-control/spec.md` + +pub mod relay; diff --git a/src/domain/relay/mod.rs b/src/domain/relay/mod.rs new file mode 100644 index 0000000..0f64921 --- /dev/null +++ b/src/domain/relay/mod.rs @@ -0,0 +1,9 @@ +//! Relay domain module. +//! +//! This module contains the core domain logic for relay control and management, +//! including relay types, repository abstractions, and business rules. + +/// Repository trait and error types for relay persistence. +pub mod repository; +/// Domain types for relay identification and control. +pub mod types; diff --git a/src/domain/relay/repository.rs b/src/domain/relay/repository.rs new file mode 100644 index 0000000..d3c2ded --- /dev/null +++ b/src/domain/relay/repository.rs @@ -0,0 +1,15 @@ +use super::types::RelayId; + +/// Errors that can occur during repository operations. +/// +/// This enum provides structured error handling for all data persistence +/// operations related to relay management. +#[derive(Debug, thiserror::Error)] +pub enum RepositoryError { + /// A database operation failed with the given error message. + #[error("Database error: {0}")] + DatabaseError(String), + /// The requested relay was not found in the repository. + #[error("Relay not found: {0}")] + NotFound(RelayId), +} diff --git a/src/domain/relay/types.rs b/src/domain/relay/types.rs new file mode 100644 index 0000000..bc0eedd --- /dev/null +++ b/src/domain/relay/types.rs @@ -0,0 +1,14 @@ +/// Unique identifier for a relay in the system. +/// +/// Uses the newtype pattern to provide type safety and prevent mixing relay IDs +/// with other numeric values. Valid values range from 0-255, corresponding to +/// individual relay channels in the Modbus controller. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct RelayId(u8); + +impl std::fmt::Display for RelayId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 18449f5..a7d3f08 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -74,3 +74,5 @@ //! - Architecture: `specs/constitution.md` - Dependency Inversion Principle //! - Implementation: `specs/001-modbus-relay-control/plan.md` - Infrastructure tasks //! - Modbus docs: `docs/Modbus_POE_ETH_Relay.md` - Hardware protocol specification + +pub mod persistence; diff --git a/src/infrastructure/persistence/mod.rs b/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..2602149 --- /dev/null +++ b/src/infrastructure/persistence/mod.rs @@ -0,0 +1,7 @@ +//! Persistence layer implementations. +//! +//! This module contains the concrete implementations of repository traits +//! for data persistence, including SQLite-based storage for relay labels. + +/// `SQLite` repository implementation for relay labels. +pub mod sqlite_repository; diff --git a/src/infrastructure/persistence/sqlite_repository.rs b/src/infrastructure/persistence/sqlite_repository.rs new file mode 100644 index 0000000..bfeadfc --- /dev/null +++ b/src/infrastructure/persistence/sqlite_repository.rs @@ -0,0 +1,64 @@ +use sqlx::SqlitePool; + +use crate::domain::relay::repository::RepositoryError; + +/// `SQLite` implementation of the relay label repository. +/// +/// This repository manages persistent storage of relay labels using `SQLite`, +/// with automatic schema migrations via `SQLx`. +pub struct SqliteRelayLabelRepository { + /// The `SQLite` connection pool for database operations. + pool: SqlitePool, +} + +impl SqliteRelayLabelRepository { + /// Creates a new `SQLite` relay label repository. + /// + /// # Arguments + /// + /// * `db_path` - The `SQLite` database path or connection string (e.g., `"sqlite://data.db"`) + /// + /// # Errors + /// + /// Returns `RepositoryError::DatabaseError` if the connection fails or migrations cannot be applied. + pub async fn new(db_path: &str) -> Result { + let pool = SqlitePool::connect(db_path) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + let repo = Self { pool }; + repo.run_migrations().await?; + Ok(repo) + } + + /// Returns a reference to the underlying connection pool. + /// + /// This is primarily used for testing to verify schema and constraints. + #[must_use] + pub const fn pool(&self) -> &SqlitePool { + &self.pool + } + + /// Creates a new in-memory `SQLite` relay label repository. + /// + /// This is useful for testing and ephemeral data storage. + /// + /// # Errors + /// + /// Returns `RepositoryError::DatabaseError` if the in-memory database cannot be created. + pub async fn in_memory() -> Result { + Self::new("sqlite::memory:").await + } + + /// Runs all pending database migrations. + /// + /// # Errors + /// + /// Returns `RepositoryError::DatabaseError` if migrations fail to apply. + async fn run_migrations(&self) -> Result<(), RepositoryError> { + sqlx::migrate!() + .run(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + Ok(()) + } +} diff --git a/tests/sqlite_repository_test.rs b/tests/sqlite_repository_test.rs new file mode 100644 index 0000000..a65a670 --- /dev/null +++ b/tests/sqlite_repository_test.rs @@ -0,0 +1,266 @@ +//! Integration tests for `SqliteRelayLabelRepository`. +//! +//! These tests verify that the SQLite repository correctly: +//! - Creates an in-memory database +//! - Applies schema migrations +//! - Validates table structure and constraints + +use sta::domain::relay::repository::RepositoryError; +use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository; + +/// Test that `in_memory()` successfully creates an in-memory database. +/// +/// **T006 Requirement**: `SqliteRelayLabelRepository::in_memory()` creates in-memory DB with schema +#[tokio::test] +async fn test_in_memory_creates_database() { + let result = SqliteRelayLabelRepository::in_memory().await; + assert!( + result.is_ok(), + "Failed to create in-memory database: {:?}", + result.err() + ); +} + +/// Test that the schema migration creates the `RelayLabels` table. +/// +/// **T006 Requirement**: Verify schema is applied correctly +#[tokio::test] +async fn test_in_memory_applies_schema() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Verify the table exists by querying it + let result: Result<(String,), sqlx::Error> = + sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name='RelayLabels'") + .fetch_one(repo.pool()) + .await; + + assert!( + result.is_ok(), + "RelayLabels table should exist after migration" + ); +} + +/// Test that the `RelayLabels` table has the correct schema. +/// +/// **T006 Requirement**: Verify table structure matches migration +#[tokio::test] +async fn test_relay_labels_table_structure() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Query table info to verify column structure + let columns: Vec<(String, String)> = + sqlx::query_as("SELECT name, type FROM pragma_table_info('RelayLabels') ORDER BY cid") + .fetch_all(repo.pool()) + .await + .expect("Failed to query table structure"); + + assert_eq!(columns.len(), 2, "RelayLabels table should have 2 columns"); + + // Verify relay_id column + assert_eq!(columns[0].0, "relay_id", "First column should be relay_id"); + assert_eq!(columns[0].1, "INTEGER", "relay_id should be INTEGER"); + + // Verify label column + assert_eq!(columns[1].0, "label", "Second column should be label"); + assert_eq!(columns[1].1, "TEXT", "label should be TEXT"); +} + +/// Test that `relay_id` is the primary key. +/// +/// **T006 Requirement**: Verify primary key constraint +#[tokio::test] +async fn test_relay_id_primary_key() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Insert first row with relay_id = 1 + let insert1: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Test')") + .execute(repo.pool()) + .await; + assert!(insert1.is_ok(), "First insert should succeed"); + + // Try to insert duplicate relay_id = 1 + let insert2: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Duplicate')") + .execute(repo.pool()) + .await; + assert!( + insert2.is_err(), + "Duplicate relay_id should fail due to PRIMARY KEY constraint" + ); +} + +/// Test that `relay_id` must be between 1 and 8. +/// +/// **T006 Requirement**: Verify CHECK constraint on relay_id range +#[tokio::test] +async fn test_relay_id_range_constraint() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Valid range: 1-8 should succeed + for id in 1..=8 { + let result: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (?, ?)") + .bind(id) + .bind(format!("Relay {}", id)) + .execute(repo.pool()) + .await; + assert!( + result.is_ok(), + "relay_id {} should be valid (range 1-8)", + id + ); + } + + // Below valid range: 0 should fail + let result_below: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (0, 'Invalid')") + .execute(repo.pool()) + .await; + assert!( + result_below.is_err(), + "relay_id = 0 should fail CHECK constraint" + ); + + // Above valid range: 9 should fail + let result_above: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (9, 'Invalid')") + .execute(repo.pool()) + .await; + assert!( + result_above.is_err(), + "relay_id = 9 should fail CHECK constraint" + ); +} + +/// Test that `label` cannot exceed 50 characters. +/// +/// **T006 Requirement**: Verify CHECK constraint on label length +#[tokio::test] +async fn test_label_length_constraint() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Valid length: 50 characters should succeed + let label_50 = "A".repeat(50); + let result_valid: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, ?)") + .bind(&label_50) + .execute(repo.pool()) + .await; + assert!( + result_valid.is_ok(), + "Label with 50 characters should be valid" + ); + + // Invalid length: 51 characters should fail + let label_51 = "B".repeat(51); + let result_invalid: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (2, ?)") + .bind(&label_51) + .execute(repo.pool()) + .await; + assert!( + result_invalid.is_err(), + "Label with 51 characters should fail CHECK constraint" + ); +} + +/// Test that `label` cannot be NULL. +/// +/// **T006 Requirement**: Verify NOT NULL constraint on label +#[tokio::test] +async fn test_label_not_null_constraint() { + let repo = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create in-memory database"); + + // Attempt to insert NULL label + let result: Result = + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, NULL)") + .execute(repo.pool()) + .await; + assert!( + result.is_err(), + "NULL label should fail NOT NULL constraint" + ); +} + +/// Test that multiple in-memory repositories are isolated. +/// +/// **T006 Requirement**: Verify in-memory instances are independent +#[tokio::test] +async fn test_multiple_in_memory_instances_isolated() { + let repo1 = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create first in-memory database"); + + let repo2 = SqliteRelayLabelRepository::in_memory() + .await + .expect("Failed to create second in-memory database"); + + // Insert data into repo1 + sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Repo1')") + .execute(repo1.pool()) + .await + .expect("Failed to insert into repo1"); + + // Verify repo2 is empty (no data from repo1) + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM RelayLabels") + .fetch_one(repo2.pool()) + .await + .expect("Failed to query repo2"); + + assert_eq!( + count.0, 0, + "Second in-memory instance should be isolated from first" + ); +} + +/// Test that `new()` with file path creates a persistent database. +/// +/// **T006 Requirement**: Verify file-based database creation +#[tokio::test] +async fn test_new_creates_file_database() { + let temp_db = tempfile::NamedTempFile::new().expect("Failed to create temp file"); + let db_path = format!("sqlite://{}", temp_db.path().to_str().unwrap()); + + let result = SqliteRelayLabelRepository::new(&db_path).await; + assert!( + result.is_ok(), + "Failed to create file-based database: {:?}", + result.err() + ); + + // Verify the file exists and has content + let metadata = std::fs::metadata(temp_db.path()).expect("Database file should exist"); + assert!(metadata.len() > 0, "Database file should not be empty"); +} + +/// Test that `new()` with invalid path returns error. +/// +/// **T006 Requirement**: Verify error handling for invalid paths +#[tokio::test] +async fn test_new_invalid_path_returns_error() { + let result = + SqliteRelayLabelRepository::new("sqlite:///invalid/path/that/does/not/exist/db.sqlite") + .await; + + assert!(result.is_err(), "Invalid database path should return error"); + + match result { + Err(RepositoryError::DatabaseError(_)) => { + // Expected error type + } + _ => panic!("Expected RepositoryError::DatabaseError for invalid path"), + } +}