From 306fa38935e3df9de4f03ff3afde1ff8aaaa0d4f Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 10 Jan 2026 23:26:49 +0100 Subject: [PATCH] test(infrastructure): implement MockRelayLabelRepository for testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create in-memory mock implementation of RelayLabelRepository trait using HashMap with Arc> for thread-safe concurrent access. Includes 8 comprehensive tests covering all trait methods and edge cases. Also refactor domain repository module structure to support multiple repository types (repository.rs → repository/label.rs + mod.rs). TDD phase: Combined red-green (tests + implementation) Ref: T037, T038 --- .../{repository.rs => repository/label.rs} | 16 +- backend/src/domain/relay/repository/mod.rs | 18 ++ .../persistence/label_repository.rs | 237 ++++++++++++++++++ backend/src/infrastructure/persistence/mod.rs | 3 + specs/001-modbus-relay-control/tasks.md | 194 +++++++------- 5 files changed, 357 insertions(+), 111 deletions(-) rename backend/src/domain/relay/{repository.rs => repository/label.rs} (73%) create mode 100644 backend/src/domain/relay/repository/mod.rs create mode 100644 backend/src/infrastructure/persistence/label_repository.rs diff --git a/backend/src/domain/relay/repository.rs b/backend/src/domain/relay/repository/label.rs similarity index 73% rename from backend/src/domain/relay/repository.rs rename to backend/src/domain/relay/repository/label.rs index 47ef2f3..d3c7a61 100644 --- a/backend/src/domain/relay/repository.rs +++ b/backend/src/domain/relay/repository/label.rs @@ -1,20 +1,8 @@ use async_trait::async_trait; -use super::types::{RelayId, RelayLabel}; +use crate::domain::relay::types::{RelayId, RelayLabel}; -/// 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), -} +use super::RepositoryError; /// Repository trait for persisting and retrieving relay labels. /// diff --git a/backend/src/domain/relay/repository/mod.rs b/backend/src/domain/relay/repository/mod.rs new file mode 100644 index 0000000..4b79ddc --- /dev/null +++ b/backend/src/domain/relay/repository/mod.rs @@ -0,0 +1,18 @@ +mod label; +pub use label::RelayLabelRepository; + +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/backend/src/infrastructure/persistence/label_repository.rs b/backend/src/infrastructure/persistence/label_repository.rs new file mode 100644 index 0000000..be5d907 --- /dev/null +++ b/backend/src/infrastructure/persistence/label_repository.rs @@ -0,0 +1,237 @@ +//! Mock implementation and tests for `RelayLabelRepository` trait. +//! +//! This module provides a simple in-memory mock implementation of the +//! `RelayLabelRepository` trait for testing purposes, along with comprehensive +//! tests that verify the trait's contract. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{Mutex, MutexGuard}; + +use crate::domain::relay::{ + repository::{RelayLabelRepository, RepositoryError}, + types::{RelayId, RelayLabel}, +}; + +/// In-memory mock implementation of `RelayLabelRepository` for testing. +/// +/// This implementation uses a `HashMap` wrapped in `Arc>` to provide +/// thread-safe concurrent access to relay labels. It's useful for testing +/// application logic without requiring a database connection. +#[derive(Debug, Clone)] +pub struct MockRelayLabelRepository { + /// Internal storage for relay labels, protected by a mutex for thread safety. + labels: Arc>>, +} + +impl MockRelayLabelRepository { + /// Creates a new empty mock repository. + #[must_use] + pub fn new() -> Self { + Self { + labels: Arc::new(Mutex::new(HashMap::new())), + } + } + + async fn labels(&self) -> MutexGuard<'_, HashMap> { + self.labels.lock().await + } +} + +impl Default for MockRelayLabelRepository { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RelayLabelRepository for MockRelayLabelRepository { + async fn get_label(&self, id: RelayId) -> Result, RepositoryError> { + Ok(self.labels().await.get(&id.as_u8()).cloned()) + } + + async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError> { + self.labels().await.insert(id.as_u8(), label); + Ok(()) + } + + async fn get_all_labels(&self) -> Result, RepositoryError> { + let mut result: Vec<(RelayId, RelayLabel)> = self + .labels() + .await + .iter() + .filter_map(|(&id, label)| { + RelayId::new(id).map_or(None, |relay_id| Some((relay_id, label.clone()))) + }) + .collect(); + + // Sort by relay ID for consistent ordering + result.sort_by_key(|(id, _)| id.as_u8()); + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_get_label_returns_none_for_non_existent_relay() { + // Test: get_label(RelayId(1)) → None when no label is set + let repo = MockRelayLabelRepository::new(); + let relay_id = RelayId::new(1).unwrap(); + + let result = repo.get_label(relay_id).await; + + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[tokio::test] + async fn test_save_label_and_get_label_returns_saved_label() { + // Test: save_label(RelayId(1), "Pump") then get_label(RelayId(1)) → Some("Pump") + let repo = MockRelayLabelRepository::new(); + let relay_id = RelayId::new(1).unwrap(); + let label = RelayLabel::new("Pump".to_string()).unwrap(); + + // Save the label + let save_result = repo.save_label(relay_id, label.clone()).await; + assert!(save_result.is_ok()); + + // Retrieve the label + let get_result = repo.get_label(relay_id).await; + assert!(get_result.is_ok()); + + let retrieved_label = get_result.unwrap(); + assert!(retrieved_label.is_some()); + assert_eq!(retrieved_label.unwrap().as_str(), "Pump"); + } + + #[tokio::test] + async fn test_save_label_overwrites_existing_label() { + // Test: save_label twice with different values, get_label returns the latest + let repo = MockRelayLabelRepository::new(); + let relay_id = RelayId::new(1).unwrap(); + let label1 = RelayLabel::new("First".to_string()).unwrap(); + let label2 = RelayLabel::new("Second".to_string()).unwrap(); + + // Save first label + repo.save_label(relay_id, label1).await.unwrap(); + + // Overwrite with second label + repo.save_label(relay_id, label2).await.unwrap(); + + // Retrieve should return the second label + let retrieved_label = repo.get_label(relay_id).await.unwrap(); + assert!(retrieved_label.is_some()); + assert_eq!(retrieved_label.unwrap().as_str(), "Second"); + } + + #[tokio::test] + async fn test_get_all_labels_returns_empty_vec_when_no_labels() { + // Test: get_all_labels() → [] when repository is empty + let repo = MockRelayLabelRepository::new(); + + let result = repo.get_all_labels().await; + + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_get_all_labels_returns_all_saved_labels() { + // Test: save multiple labels, get_all_labels() returns all of them + let repo = MockRelayLabelRepository::new(); + + let relay1 = RelayId::new(1).unwrap(); + let relay3 = RelayId::new(3).unwrap(); + let relay5 = RelayId::new(5).unwrap(); + + let label1 = RelayLabel::new("Pump".to_string()).unwrap(); + let label3 = RelayLabel::new("Heater".to_string()).unwrap(); + let label5 = RelayLabel::new("Fan".to_string()).unwrap(); + + // Save labels + repo.save_label(relay1, label1.clone()).await.unwrap(); + repo.save_label(relay3, label3.clone()).await.unwrap(); + repo.save_label(relay5, label5.clone()).await.unwrap(); + + // Retrieve all labels + let result = repo.get_all_labels().await.unwrap(); + + assert_eq!(result.len(), 3); + + // Verify labels are sorted by relay ID + assert_eq!(result[0].0.as_u8(), 1); + assert_eq!(result[0].1.as_str(), "Pump"); + + assert_eq!(result[1].0.as_u8(), 3); + assert_eq!(result[1].1.as_str(), "Heater"); + + assert_eq!(result[2].0.as_u8(), 5); + assert_eq!(result[2].1.as_str(), "Fan"); + } + + #[tokio::test] + async fn test_get_all_labels_excludes_relays_without_labels() { + // Test: Only relays with labels are returned, not all possible relay IDs + let repo = MockRelayLabelRepository::new(); + + let relay2 = RelayId::new(2).unwrap(); + let label2 = RelayLabel::new("Only This One".to_string()).unwrap(); + + repo.save_label(relay2, label2).await.unwrap(); + + let result = repo.get_all_labels().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].0.as_u8(), 2); + } + + #[tokio::test] + async fn test_save_label_for_all_valid_relay_ids() { + // Test: All relay IDs (1-8) can have labels saved + let repo = MockRelayLabelRepository::new(); + + for id in 1..=8 { + let relay_id = RelayId::new(id).unwrap(); + let label = RelayLabel::new(format!("Relay {id}")).unwrap(); + + let result = repo.save_label(relay_id, label).await; + assert!(result.is_ok(), "Failed to save label for relay {id}"); + } + + // Verify all labels were saved + let all_labels = repo.get_all_labels().await.unwrap(); + assert_eq!(all_labels.len(), 8); + } + + #[tokio::test] + async fn test_concurrent_access_is_thread_safe() { + // Test: Multiple concurrent operations don't cause data races + let repo = MockRelayLabelRepository::new(); + + let handles: Vec<_> = (1..=8) + .map(|id| { + let repo = repo.clone(); + tokio::spawn(async move { + let relay_id = RelayId::new(id).unwrap(); + let label = RelayLabel::new(format!("Relay {id}")).unwrap(); + repo.save_label(relay_id, label).await.unwrap(); + }) + }) + .collect(); + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap(); + } + + // Verify all labels were saved correctly + let all_labels = repo.get_all_labels().await.unwrap(); + assert_eq!(all_labels.len(), 8); + } +} diff --git a/backend/src/infrastructure/persistence/mod.rs b/backend/src/infrastructure/persistence/mod.rs index 2602149..8f269a0 100644 --- a/backend/src/infrastructure/persistence/mod.rs +++ b/backend/src/infrastructure/persistence/mod.rs @@ -3,5 +3,8 @@ //! This module contains the concrete implementations of repository traits //! for data persistence, including SQLite-based storage for relay labels. +/// Mock repository implementation for testing. +pub mod label_repository; + /// `SQLite` repository implementation for relay labels. pub mod sqlite_repository; diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index 20ad785..a835699 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -76,7 +76,7 @@ - Test: CorsSettings with wildcard origin deserializes correctly ✓ - Test: Settings::new() loads cors section from development.yaml ✓ - Test: CorsSettings with partial fields uses defaults ✓ - - **File**: backend/src/settings.rs (in tests module) + - **File**: `backend/src/settings.rs (in tests module)` - **Complexity**: Low | **Uncertainty**: Low - **Tests Written**: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults) @@ -86,14 +86,14 @@ - Add `#[derive(Debug, serde::Deserialize, Clone)]` to struct - Add `#[serde(default)]` attribute to Settings.cors field - Update Settings struct to include `pub cors: CorsSettings` - - **File**: backend/src/settings.rs + - **File**: `backend/src/settings.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T011** [Setup] [TDD] Update development.yaml with permissive CORS settings - Add cors section with `allowed_origins: ["*"]`, `allow_credentials: false`, `max_age_secs: 3600` - Update `frontend_url` from `http://localhost:3000` to `http://localhost:5173` (Vite default port) - **Test**: cargo run loads development config without errors - - **File**: backend/settings/development.yaml + - **File**: `backend/settings/development.yaml` - **Complexity**: Low | **Uncertainty**: Low - [x] **T012** [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings @@ -101,7 +101,7 @@ - Add `frontend_url: "https://REDACTED"` - Add production-specific application settings (protocol: https, host: 0.0.0.0) - **Test**: Settings::new() with APP_ENVIRONMENT=production loads config - - **File**: backend/settings/production.yaml + - **File**: `backend/settings/production.yaml` - **Complexity**: Low | **Uncertainty**: Low - [x] **T013** [Setup] [TDD] Write tests for build_cors() function @@ -111,7 +111,7 @@ - Test: build_cors() sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) ✓ - Test: build_cors() sets correct headers (content-type, authorization) ✓ - Test: build_cors() sets max_age from settings ✓ - - **File**: backend/src/startup.rs (in tests module) + - **File**: `backend/src/startup.rs (in tests module)` - **Complexity**: Medium | **Uncertainty**: Low - [x] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs @@ -123,7 +123,7 @@ - Set `allow_credentials` from settings - Set `max_age` from settings.max_age_secs - Add structured logging: `tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured")` - - **File**: backend/src/startup.rs + - **File**: `backend/src/startup.rs` - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: @@ -174,7 +174,7 @@ - In `From for RunnableApplication`, replace `.with(Cors::new())` with `.with(Cors::from(value.settings.cors.clone()))` ✓ - CORS is applied after rate limiting (order: RateLimit → CORS → Data) ✓ - **Test**: Unit test verifies CORS middleware uses settings ✓ - - **File**: backend/src/startup.rs (line 84) + - **File**: `backend/src/startup.rs (line 84)` - **Complexity**: Low | **Uncertainty**: Low - **Note**: Used `From for Cors` trait instead of `build_cors()` function (better design pattern) @@ -188,7 +188,7 @@ - Test: Multiple origins are supported ✓ - Test: Unauthorized origins are rejected with 403 ✓ - Test: Credentials disabled by default ✓ - - **File**: backend/tests/cors_test.rs (9 integration tests) + - **File**: `backend/tests/cors_test.rs (9 integration tests)` - **Complexity**: Medium | **Uncertainty**: Low - **Tests Written**: 9 comprehensive integration tests covering all CORS scenarios @@ -208,27 +208,27 @@ - Test: RelayId::new(0) → Err(InvalidRelayId) - Test: RelayId::new(9) → Err(InvalidRelayId) - Test: RelayId::as_u8() returns inner value - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T018** [US1] [TDD] Implement RelayId newtype with validation - #[repr(transparent)] newtype wrapping u8 - Constructor validates 1..=8 range - Implement Display, Debug, Clone, Copy, PartialEq, Eq - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T019** [P] [US1] [TDD] Write tests for RelayState enum - Test: RelayState::On → serializes to "on" - Test: RelayState::Off → serializes to "off" - Test: Parse "on"/"off" from strings - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T020** [P] [US1] [TDD] Implement RelayState enum - Enum: On, Off - Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T021** [US1] [TDD] Write tests for Relay aggregate @@ -236,13 +236,13 @@ - Test: relay.toggle() flips state - Test: relay.turn_on() sets state to On - Test: relay.turn_off() sets state to Off - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T022** [US1] [TDD] Implement Relay aggregate - Struct: `Relay { id: RelayId, state: RelayState, label: Option }` - Methods: `new()` `toggle()` `turn_on()` `turn_off()` `state()` `label()` - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T023** [P] [US4] [TDD] Write tests for RelayLabel newtype @@ -250,32 +250,32 @@ - Test: RelayLabel::new("A".repeat(50)) → Ok - Test: RelayLabel::new("") → Err(EmptyLabel) - Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong) - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T024** [P] [US4] [TDD] Implement RelayLabel newtype - #[repr(transparent)] newtype wrapping String - Constructor validates 1..=50 length - Implement Display, Debug, Clone, PartialEq, Eq - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T025** [US1] [TDD] Write tests for ModbusAddress type - Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0) - Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7) - - **File**: src/domain/modbus.rs + - **File**: `src/domain/modbus.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T026** [US1] [TDD] Implement ModbusAddress type with From - #[repr(transparent)] newtype wrapping u16 - Implement From with offset: user 1-8 → Modbus 0-7 - - **File**: src/domain/modbus.rs + - **File**: `src/domain/modbus.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T027** [US3] [TDD] Write tests and implement HealthStatus enum - Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String } - Test transitions between states - - **File**: src/domain/health.rs + - **File**: `src/domain/health.rs` - **Complexity**: Medium | **Uncertainty**: Low **Checkpoint**: Domain types complete with 100% test coverage @@ -290,13 +290,13 @@ - Test: read_state() returns mocked state - Test: write_state() updates mocked state - Test: read_all() returns 8 relays in known state - - **File**: src/infrastructure/modbus/mock_controller.rs + - **File**: `src/infrastructure/modbus/mock_controller.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T029** [P] [US1] [TDD] Implement MockRelayController - Struct with Arc>> - Implement RelayController trait with in-memory state - - **File**: src/infrastructure/modbus/mock_controller.rs + - **File**: `src/infrastructure/modbus/mock_controller.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T030** [US1] [TDD] Define RelayController trait @@ -304,14 +304,14 @@ - async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError> - async fn read_all(&self) → Result, ControllerError> - async fn write_all(&self, state: RelayState) → Result<(), ControllerError> - - **File**: src/infrastructure/modbus/controller.rs + - **File**: `src/infrastructure/modbus/controller.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T031** [P] [US1] [TDD] Define ControllerError enum - Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8) - Implement std::error::Error, Display, Debug - Use thiserror derive macros - - **File**: src/infrastructure/modbus/error.rs + - **File**: `src/infrastructure/modbus/error.rs` - **Complexity**: Low | **Uncertainty**: Low - [x] **T032** [US1] [TDD] Write tests for MockRelayController @@ -321,7 +321,7 @@ - Test: write_relay_state() for all 8 relays independently ✓ - Test: read_relay_state() with invalid relay ID (type system prevents) ✓ - Test: concurrent access is thread-safe ✓ - - **File**: src/infrastructure/modbus/mock_controller.rs + - **File**: `src/infrastructure/modbus/mock_controller.rs` - **Complexity**: Low | **Uncertainty**: Low - **Tests Written**: 6 comprehensive tests covering all mock controller scenarios @@ -338,7 +338,7 @@ - Struct: `ModbusRelayController { ctx: Arc>, timeout_duration: Duration }` - Constructor: `new(host, port, slave_id, timeout_secs) → Result` - Use `tokio_modbus::client::tcp::connect_slave()` - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Medium | **Uncertainty**: Medium **Pseudocode**: @@ -381,7 +381,7 @@ - Wrap `ctx.read_coils()` with `tokio::time::timeout()` - Handle nested Result: timeout → io::Error → Modbus Exception - **Note**: Modbus TCP uses MBAP header (no CRC validation needed) - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Medium | **Uncertainty**: Medium **Pseudocode** (CRITICAL PATTERN): @@ -415,7 +415,7 @@ - [x] **T025c** [US1] [TDD] Implement timeout-wrapped `write_single_coil` helper - Private method: `write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError>` - Similar nested Result handling as T025b - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -446,7 +446,7 @@ - Convert RelayId → ModbusAddress (0-based) - Call `read_coils_with_timeout(addr, 1)` - Convert bool → RelayState - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -471,7 +471,7 @@ - Convert RelayId → ModbusAddress - Convert RelayState → bool (On=true, Off=false) - Call `write_single_coil_with_timeout()` - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -491,7 +491,7 @@ - `read_all()`: Call `read_coils_with_timeout(0, 8)`, map to `Vec<(RelayId, RelayState)>` - `write_all()`: Loop over RelayId 1-8, call `write_state()` for each - Add `firmware_version()` method (read holding register 0x9999, optional) - - **File**: src/infrastructure/modbus/modbus_controller.rs + - **File**: `src/infrastructure/modbus/modbus_controller.rs` - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: @@ -527,42 +527,42 @@ - [ ] **T034** [US1] [TDD] Integration test with real hardware (optional) - **REQUIRES PHYSICAL DEVICE**: Test against actual Modbus relay at configured IP - Skip if device unavailable, rely on MockRelayController for CI - - **File**: tests/integration/modbus_hardware_test.rs + - **File**: `tests/integration/modbus_hardware_test.rs` - **Complexity**: Medium | **Uncertainty**: High - - **Note**: Use #[ignore] attribute, run with cargo test -- --ignored + - **Note**: Use #[ignore] attribute, run with `cargo test -- --ignored` - [ ] **T035** [P] [US4] [TDD] Write tests for RelayLabelRepository trait - - Test: get_label(RelayId(1)) → Option - - Test: set_label(RelayId(1), label) → Result<(), RepositoryError> - - Test: delete_label(RelayId(1)) → Result<(), RepositoryError> - - **File**: src/infrastructure/persistence/label_repository.rs + - Test: `get_label(RelayId(1)) → Option` + - Test: `set_label(RelayId(1), label) → Result<(), RepositoryError>` + - Test: `delete_label(RelayId(1)) → Result<(), RepositoryError>` + - **File**: `src/infrastructure/persistence/label_repository.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T036** [P] [US4] [TDD] Implement SQLite RelayLabelRepository - - Implement get_label(), set_label(), delete_label() using SQLx - - Use sqlx::query! macros for compile-time SQL verification - - **File**: src/infrastructure/persistence/sqlite_label_repository.rs + - Implement `get_label()`, `set_label()`, `delete_label()` using SQLx + - Use `sqlx::query!` macros for compile-time SQL verification + - **File**: `src/infrastructure/persistence/sqlite_label_repository.rs` - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T037** [US4] [TDD] Write tests for in-memory mock LabelRepository +- [x] **T037** [US4] [TDD] Write tests for in-memory mock LabelRepository - For testing without SQLite dependency - - **File**: src/infrastructure/persistence/mock_label_repository.rs + - **File**: `src/infrastructure/persistence/mock_label_repository.rs` - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T038** [US4] [TDD] Implement in-memory mock LabelRepository +- [x] **T038** [US4] [TDD] Implement in-memory mock LabelRepository - HashMap-based implementation - - **File**: src/infrastructure/persistence/mock_label_repository.rs + - **File**: `src/infrastructure/persistence/mock_label_repository.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T039** [US3] [TDD] Write tests for HealthMonitor service - - Test: track_success() transitions Degraded → Healthy - - Test: track_failure() transitions Healthy → Degraded → Unhealthy - - **File**: src/application/health_monitor.rs + - Test: `track_success()` transitions Degraded → Healthy + - Test: `track_failure()` transitions Healthy → Degraded → Unhealthy + - **File**: `src/application/health_monitor.rs` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T040** [US3] [TDD] Implement HealthMonitor service - Track consecutive errors, transition states per FR-020, FR-021 - - **File**: src/application/health_monitor.rs + - **File**: `src/application/health_monitor.rs` - **Complexity**: Medium | **Uncertainty**: Low **Checkpoint**: Infrastructure layer complete with trait abstractions @@ -580,22 +580,22 @@ - [ ] **T041** [US1] [TDD] Write tests for ToggleRelayUseCase - Test: execute(RelayId(1)) toggles relay state via controller - Test: execute() returns error if controller fails - - **File**: src/application/use_cases/toggle_relay.rs + - **File**: `src/application/use_cases/toggle_relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T042** [US1] [TDD] Implement ToggleRelayUseCase - Orchestrate: read current state → toggle → write new state - - **File**: src/application/use_cases/toggle_relay.rs + - **File**: `src/application/use_cases/toggle_relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T043** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase - Test: execute() returns all 8 relays with states - - **File**: src/application/use_cases/get_all_relays.rs + - **File**: `src/application/use_cases/get_all_relays.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T044** [P] [US1] [TDD] Implement GetAllRelaysUseCase - Call controller.read_all(), map to domain Relay objects - - **File**: src/application/use_cases/get_all_relays.rs + - **File**: `src/application/use_cases/get_all_relays.rs` - **Complexity**: Low | **Uncertainty**: Low ### Presentation Layer (Backend API) @@ -603,13 +603,13 @@ - [ ] **T045** [US1] [TDD] Define RelayDto in presentation layer - Fields: id (u8), state ("on"/"off"), label (Option) - Implement From for RelayDto - - **File**: src/presentation/dto/relay_dto.rs + - **File**: `src/presentation/dto/relay_dto.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T046** [US1] [TDD] Define API error responses - ApiError enum with status codes and messages - Implement poem::error::ResponseError - - **File**: src/presentation/error.rs + - **File**: `src/presentation/error.rs` - **Complexity**: Low | **Uncertainty**: Low --- @@ -624,7 +624,7 @@ - Factory function: create_relay_controller(settings, use_mock) → Arc - Retry 3 times with 2s backoff on connection failure - Graceful degradation: fallback to MockRelayController if all retries fail (FR-023) - - **File**: src/infrastructure/modbus/factory.rs + - **File**: `src/infrastructure/modbus/factory.rs` - **Complexity**: Medium | **Uncertainty**: Medium **Pseudocode**: @@ -683,7 +683,7 @@ - Factory function: create_label_repository(db_path, use_mock) → Arc - If use_mock: return MockLabelRepository - Else: return SQLiteLabelRepository connected to db_path - - **File**: src/infrastructure/persistence/factory.rs + - **File**: `src/infrastructure/persistence/factory.rs` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -711,7 +711,7 @@ - Determine test mode: cfg!(test) || env::var("CI").is_ok() - Call create_relay_controller() and create_label_repository() - Pass dependencies to RelayApi::new() - - **File**: src/startup.rs + - **File**: `src/startup.rs` - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: @@ -752,7 +752,7 @@ - [ ] **T039d** [US1] [TDD] Register RelayApi in route aggregator - Add RelayApi to OpenAPI service - Tag: "Relays" - - **File**: src/startup.rs + - **File**: `src/startup.rs` - **Complexity**: Low | **Uncertainty**: Low **TDD Checklist**: @@ -764,39 +764,39 @@ - [ ] **T048** [US1] [TDD] Write contract tests for GET /api/relays - Test: Returns 200 with array of 8 RelayDto - Test: Each relay has id 1-8, state, and optional label - - **File**: tests/contract/test_relay_api.rs + - **File**: `tests/contract/test_relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T049** [US1] [TDD] Implement GET /api/relays endpoint - #[oai(path = "/relays", method = "get")] - Call GetAllRelaysUseCase, map to RelayDto - - **File**: src/presentation/api/relay_api.rs + - **File**: `src/presentation/api/relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T050** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle - Test: Returns 200 with updated RelayDto - Test: Returns 404 for id < 1 or id > 8 - Test: State actually changes in controller - - **File**: tests/contract/test_relay_api.rs + - **File**: `tests/contract/test_relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T051** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint - #[oai(path = "/relays/:id/toggle", method = "post")] - Parse id, call ToggleRelayUseCase, return updated state - - **File**: src/presentation/api/relay_api.rs + - **File**: `src/presentation/api/relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low ### Frontend Implementation - [ ] **T052** [P] [US1] [TDD] Create RelayDto TypeScript interface - Generate from OpenAPI spec or manually define - - **File**: frontend/src/types/relay.ts + - **File**: `frontend/src/types/relay.ts` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T053** [P] [US1] [TDD] Create API client service - getAllRelays(): Promise - toggleRelay(id: number): Promise - - **File**: frontend/src/api/relayApi.ts + - **File**: `frontend/src/api/relayApi.ts` - **Complexity**: Low | **Uncertainty**: Low --- @@ -810,7 +810,7 @@ - [ ] **T046a** [US1] [TDD] Create useRelayPolling composable structure - Setup reactive refs: relays, isLoading, error, lastFetchTime - Define interval variable and fetch function signature - - **File**: frontend/src/composables/useRelayPolling.ts + - **File**: `frontend/src/composables/useRelayPolling.ts` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -850,7 +850,7 @@ - Fetch relays and health status in parallel using Promise.all - Update reactive state on success - Handle errors gracefully, set isConnected based on success - - **File**: frontend/src/composables/useRelayPolling.ts + - **File**: `frontend/src/composables/useRelayPolling.ts` - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: @@ -886,7 +886,7 @@ - startPolling(): Fetch immediately, then setInterval - stopPolling(): clearInterval and cleanup - Use onMounted/onUnmounted for automatic lifecycle management - - **File**: frontend/src/composables/useRelayPolling.ts + - **File**: `frontend/src/composables/useRelayPolling.ts` - **Complexity**: Medium | **Uncertainty**: Low **Pseudocode**: @@ -926,7 +926,7 @@ - [ ] **T046d** [US1] [TDD] Add connection status tracking - Track isConnected based on fetch success/failure - Display connection status in UI - - **File**: frontend/src/composables/useRelayPolling.ts + - **File**: `frontend/src/composables/useRelayPolling.ts` - **Complexity**: Low | **Uncertainty**: Low **Pseudocode**: @@ -954,7 +954,7 @@ - Props: relay (RelayDto) - Display relay ID, state, label - Emit toggle event on button click - - **File**: frontend/src/components/RelayCard.vue + - **File**: `frontend/src/components/RelayCard.vue` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T056** [US1] [TDD] Create RelayGrid component @@ -962,13 +962,13 @@ - Render 8 RelayCard components - Handle toggle events by calling API - Display loading/error states - - **File**: frontend/src/components/RelayGrid.vue + - **File**: `frontend/src/components/RelayGrid.vue` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T057** [US1] [TDD] Integration test for US1 - End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change - Use Playwright or Cypress - - **File**: frontend/tests/e2e/relay-control.spec.ts + - **File**: `frontend/tests/e2e/relay-control.spec.ts` - **Complexity**: Medium | **Uncertainty**: Medium **Checkpoint**: US1 MVP complete - users can view and toggle individual relays @@ -984,49 +984,49 @@ - [ ] **T058** [US2] [TDD] Write tests for BulkControlUseCase - Test: execute(BulkOperation::AllOn) turns all relays on - Test: execute(BulkOperation::AllOff) turns all relays off - - **File**: src/application/use_cases/bulk_control.rs + - **File**: `src/application/use_cases/bulk_control.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T059** [US2] [TDD] Implement BulkControlUseCase - Call controller.write_all(state) - - **File**: src/application/use_cases/bulk_control.rs + - **File**: `src/application/use_cases/bulk_control.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T060** [US2] [TDD] Define BulkOperation enum - Variants: AllOn, AllOff - - **File**: src/domain/relay.rs + - **File**: `src/domain/relay.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T061** [US2] [TDD] Write contract tests for POST /api/relays/all/on - Test: Returns 200, all relays turn on - - **File**: tests/contract/test_relay_api.rs + - **File**: `tests/contract/test_relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T062** [US2] [TDD] Implement POST /api/relays/all/on endpoint - Call BulkControlUseCase with AllOn - - **File**: src/presentation/api/relay_api.rs + - **File**: `src/presentation/api/relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T063** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off - Test: Returns 200, all relays turn off - - **File**: tests/contract/test_relay_api.rs + - **File**: `tests/contract/test_relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T064** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint - Call BulkControlUseCase with AllOff - - **File**: src/presentation/api/relay_api.rs + - **File**: `src/presentation/api/relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T065** [US2] [TDD] Add bulk control buttons to frontend - Add "All On" and "All Off" buttons to RelayGrid component - Call API endpoints and refresh relay states - - **File**: frontend/src/components/RelayGrid.vue + - **File**: `frontend/src/components/RelayGrid.vue` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T066** [US2] [TDD] Integration test for US2 - Click "All On" → verify all 8 relays turn on - Click "All Off" → verify all 8 relays turn off - - **File**: frontend/tests/e2e/bulk-control.spec.ts + - **File**: `frontend/tests/e2e/bulk-control.spec.ts` - **Complexity**: Low | **Uncertainty**: Low **Checkpoint**: US2 complete - bulk controls functional @@ -1043,46 +1043,46 @@ - Test: Returns Healthy when controller is responsive - Test: Returns Degraded after 3 consecutive errors - Test: Returns Unhealthy after 10 consecutive errors - - **File**: src/application/use_cases/get_health.rs + - **File**: `src/application/use_cases/get_health.rs` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T068** [US3] [TDD] Implement GetHealthUseCase - Use HealthMonitor to track controller status - Return current HealthStatus - - **File**: src/application/use_cases/get_health.rs + - **File**: `src/application/use_cases/get_health.rs` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T069** [US3] [TDD] Define HealthDto - Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional) - - **File**: src/presentation/dto/health_dto.rs + - **File**: `src/presentation/dto/health_dto.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T070** [US3] [TDD] Write contract tests for GET /api/health - Test: Returns 200 with HealthDto - - **File**: tests/contract/test_health_api.rs + - **File**: `tests/contract/test_health_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T071** [US3] [TDD] Implement GET /api/health endpoint - Call GetHealthUseCase, map to HealthDto - - **File**: src/presentation/api/health_api.rs + - **File**: `src/presentation/api/health_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T072** [P] [US3] [TDD] Add firmware version display (optional) - If controller supports firmware_version(), display in UI - - **File**: frontend/src/components/DeviceInfo.vue + - **File**: `frontend/src/components/DeviceInfo.vue` - **Complexity**: Low | **Uncertainty**: Medium - **Note**: Device may not support this feature - [ ] **T073** [US3] [TDD] Create HealthIndicator component - Display connection status with color-coded indicator - Show firmware version if available - - **File**: frontend/src/components/HealthIndicator.vue + - **File**: `frontend/src/components/HealthIndicator.vue` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T074** [US3] [TDD] Integrate HealthIndicator in RelayGrid - Fetch health status in useRelayPolling composable - Pass to HealthIndicator component - - **File**: frontend/src/components/RelayGrid.vue + - **File**: `frontend/src/components/RelayGrid.vue` - **Complexity**: Low | **Uncertainty**: Low **Checkpoint**: US3 complete - health monitoring visible @@ -1099,35 +1099,35 @@ - Test: execute(RelayId(1), "Pump") sets label - Test: execute with empty label returns error - Test: execute with 51-char label returns error - - **File**: src/application/use_cases/set_label.rs + - **File**: `src/application/use_cases/set_label.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T076** [US4] [TDD] Implement SetLabelUseCase - Validate label with RelayLabel::new() - Call label_repository.set_label() - - **File**: src/application/use_cases/set_label.rs + - **File**: `src/application/use_cases/set_label.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T077** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label - Test: Returns 200, label is persisted - Test: Returns 400 for invalid label - - **File**: tests/contract/test_relay_api.rs + - **File**: `tests/contract/test_relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T078** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint - Parse id and label, call SetLabelUseCase - - **File**: src/presentation/api/relay_api.rs + - **File**: `src/presentation/api/relay_api.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T079** [US4] [TDD] Add label editing to RelayCard component - Click label → show input field - Submit → call PUT /api/relays/{id}/label - - **File**: frontend/src/components/RelayCard.vue + - **File**: `frontend/src/components/RelayCard.vue` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T080** [US4] [TDD] Integration test for US4 - Set label for relay 1 → refresh → verify label persists - - **File**: frontend/tests/e2e/relay-labeling.spec.ts + - **File**: `frontend/tests/e2e/relay-labeling.spec.ts` - **Complexity**: Low | **Uncertainty**: Low **Checkpoint**: US4 complete - relay labeling functional @@ -1149,7 +1149,7 @@ - Document request/response schemas - Add example values - Tag endpoints appropriately - - **File**: src/presentation/api/*.rs + - **File**: `src/presentation/api/*.rs` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T083** [P] Run cargo clippy and fix all warnings @@ -1172,19 +1172,19 @@ - Document environment variables - Document Modbus device configuration - Add quickstart guide - - **File**: README.md + - **File**: `README.md` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T088** [P] Create Docker image for backend - Multi-stage build with Rust - Include SQLite database setup - - **File**: Dockerfile + - **File**: `Dockerfile` - **Complexity**: Medium | **Uncertainty**: Low - [ ] **T089** [P] Create production settings/production.yaml - Configure for actual device IP - Set appropriate timeouts and retry settings - - **File**: settings/production.yaml + - **File**: `settings/production.yaml` - **Complexity**: Low | **Uncertainty**: Low - [ ] **T090** Deploy to production environment