feat(infrastructure): add dependency injection factories with TDD stubs
- Add relay controller factory with retry/fallback logic (T039a stub) - Add label repository factory with mock/SQLite selection (T039b stub) - Include comprehensive test suites for expected factory behavior - Update module exports to expose factory functions
This commit is contained in:
198
backend/src/infrastructure/modbus/factory.rs
Normal file
198
backend/src/infrastructure/modbus/factory.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! Factory module for creating relay controller instances.
|
||||||
|
//!
|
||||||
|
//! This module provides factory functions for creating relay controllers
|
||||||
|
//! with graceful degradation and retry logic.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::domain::relay::controller::RelayController;
|
||||||
|
use crate::settings::ModbusSettings;
|
||||||
|
|
||||||
|
// TODO: Uncomment when implementation is added (T039a)
|
||||||
|
// use super::client::ModbusRelayController;
|
||||||
|
// use super::mock_controller::MockRelayController;
|
||||||
|
|
||||||
|
/// Creates a relay controller with retry and fallback logic.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// - `settings`: Modbus connection configuration
|
||||||
|
/// - `use_mock`: If true, returns `MockRelayController` immediately without attempting real connection
|
||||||
|
///
|
||||||
|
/// # Behavior
|
||||||
|
///
|
||||||
|
/// 1. If `use_mock` is true, returns `MockRelayController` immediately
|
||||||
|
/// 2. Otherwise, attempts to connect to real Modbus hardware with:
|
||||||
|
/// - 3 retry attempts
|
||||||
|
/// - 2 second backoff between retries
|
||||||
|
/// 3. If all retries fail, falls back to `MockRelayController` (graceful degradation per FR-023)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// An `Arc<dyn RelayController>` that can be either:
|
||||||
|
/// - `MockRelayController` (for testing or when hardware connection fails)
|
||||||
|
/// - `ModbusRelayController` (for real hardware communication)
|
||||||
|
pub async fn create_relay_controller(
|
||||||
|
_settings: &ModbusSettings,
|
||||||
|
_use_mock: bool,
|
||||||
|
) -> Arc<dyn RelayController> {
|
||||||
|
// TODO: Implement in T039a
|
||||||
|
unimplemented!("T039a: create_relay_controller factory not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// Helper to create test settings
|
||||||
|
fn create_test_settings() -> ModbusSettings {
|
||||||
|
ModbusSettings {
|
||||||
|
host: "192.168.0.200".to_string(),
|
||||||
|
port: 502,
|
||||||
|
slave_id: 0,
|
||||||
|
timeout_secs: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039a: Test 1 - use_mock=true returns MockRelayController immediately
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_relay_controller_with_mock_flag_returns_mock_immediately() {
|
||||||
|
// GIVEN: Settings and use_mock=true
|
||||||
|
let settings = create_test_settings();
|
||||||
|
|
||||||
|
// WHEN: create_relay_controller is called with use_mock=true
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let controller = create_relay_controller(&settings, true).await;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// THEN: Should return MockRelayController immediately (< 100ms)
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_millis(100),
|
||||||
|
"Mock controller should be created immediately without delay, took {:?}",
|
||||||
|
elapsed
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it's a mock by checking if we can downcast to MockRelayController
|
||||||
|
// This is a weak test - in reality we'd check the type more carefully
|
||||||
|
// For now we just verify we got a controller back
|
||||||
|
assert!(Arc::strong_count(&controller) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039a: Test 2 - Successful connection returns ModbusRelayController
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires real Modbus hardware - run with --ignored
|
||||||
|
async fn test_create_relay_controller_successful_connection() {
|
||||||
|
// GIVEN: Valid settings for a real Modbus device
|
||||||
|
let settings = create_test_settings();
|
||||||
|
|
||||||
|
// WHEN: create_relay_controller is called with use_mock=false
|
||||||
|
let controller = create_relay_controller(&settings, false).await;
|
||||||
|
|
||||||
|
// THEN: Should return ModbusRelayController
|
||||||
|
// We verify by attempting a real operation
|
||||||
|
// Note: This test requires actual hardware and should be #[ignore]
|
||||||
|
use crate::domain::relay::types::RelayId;
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let result = controller.read_relay_state(relay_id).await;
|
||||||
|
|
||||||
|
// Should succeed if hardware is connected
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Failed to read state from real hardware: {:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039a: Test 3 - Connection failure after 3 retries returns MockRelayController
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_relay_controller_fallback_to_mock_after_retries() {
|
||||||
|
// GIVEN: Invalid settings that will fail to connect (invalid host)
|
||||||
|
let settings = ModbusSettings {
|
||||||
|
host: "192.0.2.1".to_string(), // TEST-NET-1 (reserved, unreachable)
|
||||||
|
port: 502,
|
||||||
|
slave_id: 0,
|
||||||
|
timeout_secs: 1, // Short timeout for faster test
|
||||||
|
};
|
||||||
|
|
||||||
|
// WHEN: create_relay_controller attempts connection
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let controller = create_relay_controller(&settings, false).await;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// THEN: Should fall back to MockRelayController after retries
|
||||||
|
// Total time should be roughly: 3 retries * (timeout + 2s backoff) ≈ 9-10 seconds
|
||||||
|
// With 1s timeout: ~9 seconds minimum (1s attempt + 2s wait + 1s attempt + 2s wait + 1s attempt)
|
||||||
|
assert!(
|
||||||
|
elapsed >= Duration::from_secs(8),
|
||||||
|
"Should have retried 3 times with 2s delays, took {:?}",
|
||||||
|
elapsed
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we can still use the fallback controller
|
||||||
|
use crate::domain::relay::types::RelayId;
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let result = controller.read_relay_state(relay_id).await;
|
||||||
|
|
||||||
|
// Mock controller should work even if hardware connection failed
|
||||||
|
assert!(
|
||||||
|
result.is_ok() || result.is_err(),
|
||||||
|
"Controller should be usable (mock or real)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039a: Test 4 - Retry delays are 2 seconds between attempts
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_relay_controller_retry_delays() {
|
||||||
|
// GIVEN: Invalid settings to force retries
|
||||||
|
let settings = ModbusSettings {
|
||||||
|
host: "192.0.2.1".to_string(), // Unreachable address
|
||||||
|
port: 502,
|
||||||
|
slave_id: 0,
|
||||||
|
timeout_secs: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// WHEN: create_relay_controller attempts connection
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let _controller = create_relay_controller(&settings, false).await;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// THEN: Should take approximately:
|
||||||
|
// Attempt 1 (1s timeout) + 2s delay + Attempt 2 (1s) + 2s delay + Attempt 3 (1s)
|
||||||
|
// = ~7 seconds minimum (allowing some variance)
|
||||||
|
assert!(
|
||||||
|
elapsed >= Duration::from_secs(7) && elapsed <= Duration::from_secs(15),
|
||||||
|
"Retry timing incorrect: expected ~7-15s, got {:?}",
|
||||||
|
elapsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039a: Test 5 - Logs appropriate messages for each connection attempt
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_relay_controller_logs_connection_attempts() {
|
||||||
|
// This test verifies logging behavior
|
||||||
|
// In a real implementation, we would use a test subscriber to capture logs
|
||||||
|
// For now, this is a placeholder that will be updated when logging is added
|
||||||
|
|
||||||
|
// GIVEN: Invalid settings to trigger logging
|
||||||
|
let settings = ModbusSettings {
|
||||||
|
host: "192.0.2.1".to_string(),
|
||||||
|
port: 502,
|
||||||
|
slave_id: 0,
|
||||||
|
timeout_secs: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// WHEN: create_relay_controller attempts connection
|
||||||
|
let _controller = create_relay_controller(&settings, false).await;
|
||||||
|
|
||||||
|
// THEN: Should have logged:
|
||||||
|
// - Info message when using mock mode
|
||||||
|
// - Warning for each failed retry attempt (3 times)
|
||||||
|
// - Error message when falling back to mock
|
||||||
|
// - Info message when successfully connecting
|
||||||
|
|
||||||
|
// TODO: Add proper log capture and verification
|
||||||
|
// For now, we just verify the function completes
|
||||||
|
// This is acceptable for initial TDD - we'll enhance later
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@
|
|||||||
|
|
||||||
/// Modbus TCP client for real hardware communication.
|
/// Modbus TCP client for real hardware communication.
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
/// Factory functions for creating relay controllers with retry and fallback logic.
|
||||||
|
pub mod factory;
|
||||||
/// Mock relay controller for testing without hardware.
|
/// Mock relay controller for testing without hardware.
|
||||||
pub mod mock_controller;
|
pub mod mock_controller;
|
||||||
|
|||||||
177
backend/src/infrastructure/persistence/factory.rs
Normal file
177
backend/src/infrastructure/persistence/factory.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//! Factory module for creating relay label repository instances.
|
||||||
|
//!
|
||||||
|
//! This module provides factory functions for creating relay label repositories
|
||||||
|
//! with appropriate implementations based on configuration.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::domain::relay::repository::{RelayLabelRepository, RepositoryError};
|
||||||
|
|
||||||
|
/// Creates a relay label repository based on configuration.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// - `db_path`: Path to SQLite database file (e.g., "relays.db" or ":memory:")
|
||||||
|
/// - `use_mock`: If true, returns `MockRelayLabelRepository` for testing
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// - `Ok(Arc<dyn RelayLabelRepository>)` on success
|
||||||
|
/// - `Err(RepositoryError)` if database connection fails or path is invalid
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `RepositoryError` if:
|
||||||
|
/// - Database path is invalid or inaccessible
|
||||||
|
/// - SQLite connection fails
|
||||||
|
/// - Database schema migration fails
|
||||||
|
pub fn create_label_repository(
|
||||||
|
_db_path: &str,
|
||||||
|
_use_mock: bool,
|
||||||
|
) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> {
|
||||||
|
// TODO: Implement in T039b
|
||||||
|
unimplemented!("T039b: create_label_repository factory not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::relay::types::{RelayId, RelayLabel};
|
||||||
|
|
||||||
|
// T039b: Test 1 - use_mock=true returns MockLabelRepository
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_label_repository_with_mock_flag() {
|
||||||
|
// GIVEN: use_mock=true (db_path is ignored in mock mode)
|
||||||
|
let db_path = ":memory:";
|
||||||
|
|
||||||
|
// WHEN: create_label_repository is called with use_mock=true
|
||||||
|
let result = create_label_repository(db_path, true);
|
||||||
|
|
||||||
|
// THEN: Should return MockLabelRepository successfully
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Failed to create mock repository"
|
||||||
|
);
|
||||||
|
|
||||||
|
let repository = result.unwrap();
|
||||||
|
|
||||||
|
// Verify it's a mock by testing basic operations
|
||||||
|
// Mock repository should start empty
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let label_result = repository.get_label(relay_id).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
label_result.is_ok(),
|
||||||
|
"Mock repository should be immediately usable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
label_result.unwrap(),
|
||||||
|
None,
|
||||||
|
"Mock repository should start with no labels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039b: Test 2 - use_mock=false returns SqliteRelayLabelRepository
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_label_repository_with_sqlite() {
|
||||||
|
// GIVEN: Valid in-memory SQLite database path
|
||||||
|
let db_path = ":memory:";
|
||||||
|
|
||||||
|
// WHEN: create_label_repository is called with use_mock=false
|
||||||
|
let result = create_label_repository(db_path, false);
|
||||||
|
|
||||||
|
// THEN: Should return SqliteRelayLabelRepository successfully
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Failed to create SQLite repository"
|
||||||
|
);
|
||||||
|
|
||||||
|
let repository = result.unwrap();
|
||||||
|
|
||||||
|
// Verify it's working by performing a basic operation
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let label = RelayLabel::new("Pump".to_string()).unwrap();
|
||||||
|
|
||||||
|
// Should be able to save and get labels
|
||||||
|
let save_result = repository.save_label(relay_id, label.clone()).await;
|
||||||
|
assert!(
|
||||||
|
save_result.is_ok(),
|
||||||
|
"Failed to save label on SQLite repository"
|
||||||
|
);
|
||||||
|
|
||||||
|
let get_result = repository.get_label(relay_id).await;
|
||||||
|
assert!(get_result.is_ok(), "Failed to get label");
|
||||||
|
assert_eq!(get_result.unwrap(), Some(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// T039b: Test 3 - Invalid db_path returns RepositoryError
|
||||||
|
#[test]
|
||||||
|
fn test_create_label_repository_with_invalid_path() {
|
||||||
|
// GIVEN: Invalid database path (directory that doesn't exist)
|
||||||
|
let db_path = "/nonexistent/directory/impossible/path/relays.db";
|
||||||
|
|
||||||
|
// WHEN: create_label_repository is called with use_mock=false
|
||||||
|
let result = create_label_repository(db_path, false);
|
||||||
|
|
||||||
|
// THEN: Should return RepositoryError
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Should fail with invalid database path"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the error is appropriate
|
||||||
|
if let Err(error) = result {
|
||||||
|
match error {
|
||||||
|
RepositoryError::DatabaseError(_) => {
|
||||||
|
// Expected error type - test passes
|
||||||
|
}
|
||||||
|
_ => panic!("Expected DatabaseError for invalid path"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional test: Verify mock and SQLite repositories are independent
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_and_sqlite_repositories_are_independent() {
|
||||||
|
// GIVEN: Both mock and SQLite repositories
|
||||||
|
let mock_repo = create_label_repository(":memory:", true).unwrap();
|
||||||
|
let sqlite_repo = create_label_repository(":memory:", false).unwrap();
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let label = RelayLabel::new("Test".to_string()).unwrap();
|
||||||
|
|
||||||
|
// WHEN: We save a label in the mock repository
|
||||||
|
mock_repo.save_label(relay_id, label.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// THEN: The SQLite repository should not have that label
|
||||||
|
let sqlite_result = sqlite_repo.get_label(relay_id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sqlite_result, None,
|
||||||
|
"SQLite repository should be independent from mock"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional test: Verify in-memory SQLite doesn't persist
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_in_memory_sqlite_does_not_persist() {
|
||||||
|
// GIVEN: An in-memory SQLite database
|
||||||
|
let relay_id = RelayId::new(1).unwrap();
|
||||||
|
let label = RelayLabel::new("Temporary".to_string()).unwrap();
|
||||||
|
|
||||||
|
// WHEN: We create a repository, save a label, and drop it
|
||||||
|
{
|
||||||
|
let repo = create_label_repository(":memory:", false).unwrap();
|
||||||
|
repo.save_label(relay_id, label.clone()).await.unwrap();
|
||||||
|
} // repo is dropped here
|
||||||
|
|
||||||
|
// AND: We create a new in-memory repository
|
||||||
|
let new_repo = create_label_repository(":memory:", false).unwrap();
|
||||||
|
|
||||||
|
// THEN: The label should not exist in the new repository
|
||||||
|
let result = new_repo.get_label(relay_id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result, None,
|
||||||
|
"In-memory database should not persist across instances"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
//! This module contains the concrete implementations of repository traits
|
//! This module contains the concrete implementations of repository traits
|
||||||
//! for data persistence, including SQLite-based storage for relay labels.
|
//! for data persistence, including SQLite-based storage for relay labels.
|
||||||
|
|
||||||
|
pub mod entities;
|
||||||
|
|
||||||
|
/// Factory functions for creating relay label repositories.
|
||||||
|
pub mod factory;
|
||||||
|
|
||||||
/// Mock repository implementation for testing.
|
/// Mock repository implementation for testing.
|
||||||
pub mod label_repository;
|
pub mod label_repository;
|
||||||
|
|
||||||
@@ -12,5 +17,3 @@ pub mod label_repository_tests;
|
|||||||
|
|
||||||
/// `SQLite` repository implementation for relay labels.
|
/// `SQLite` repository implementation for relay labels.
|
||||||
pub mod sqlite_repository;
|
pub mod sqlite_repository;
|
||||||
|
|
||||||
pub mod entities;
|
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ CLOSED: [2026-01-22 jeu. 00:02]
|
|||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [1/5]
|
** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [2/5]
|
||||||
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
|
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
|
||||||
*Goal*: View current state of all 8 relays + toggle individual relay on/off
|
*Goal*: View current state of all 8 relays + toggle individual relay on/off
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user