test(infrastructure): implement MockRelayLabelRepository for testing
Create in-memory mock implementation of RelayLabelRepository trait using HashMap with Arc<Mutex<>> 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
This commit is contained in:
@@ -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.
|
||||
///
|
||||
18
backend/src/domain/relay/repository/mod.rs
Normal file
18
backend/src/domain/relay/repository/mod.rs
Normal file
@@ -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),
|
||||
}
|
||||
237
backend/src/infrastructure/persistence/label_repository.rs
Normal file
237
backend/src/infrastructure/persistence/label_repository.rs
Normal file
@@ -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<Mutex<_>>` 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<Mutex<HashMap<u8, RelayLabel>>>,
|
||||
}
|
||||
|
||||
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<u8, RelayLabel>> {
|
||||
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<Option<RelayLabel>, 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<Vec<(RelayId, RelayLabel)>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user