feat(infrastructure): implement SQLite repository for relay labels
Add complete SQLite implementation of RelayLabelRepository trait with all CRUD operations (get_label, save_label, delete_label, get_all_labels). Key changes: - Create infrastructure entities module with RelayLabelRecord struct - Implement TryFrom traits for converting between database records and domain types - Add From<sqlx::Error> and From<RelayLabelError> for RepositoryError - Write comprehensive functional tests covering all repository operations - Verify proper handling of edge cases (empty results, overwrites, max length) TDD phase: GREEN - All repository trait tests now passing with SQLite implementation Ref: T036 (specs/001-modbus-relay-control/tasks.md)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
mod label;
|
mod label;
|
||||||
pub use label::RelayLabelRepository;
|
pub use label::RelayLabelRepository;
|
||||||
|
|
||||||
use super::types::RelayId;
|
use super::types::{RelayId, RelayLabelError};
|
||||||
|
|
||||||
/// Errors that can occur during repository operations.
|
/// Errors that can occur during repository operations.
|
||||||
///
|
///
|
||||||
@@ -16,3 +16,15 @@ pub enum RepositoryError {
|
|||||||
#[error("Relay not found: {0}")]
|
#[error("Relay not found: {0}")]
|
||||||
NotFound(RelayId),
|
NotFound(RelayId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for RepositoryError {
|
||||||
|
fn from(value: sqlx::Error) -> Self {
|
||||||
|
Self::DatabaseError(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RelayLabelError> for RepositoryError {
|
||||||
|
fn from(value: RelayLabelError) -> Self {
|
||||||
|
Self::DatabaseError(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ mod relaylabel;
|
|||||||
mod relaystate;
|
mod relaystate;
|
||||||
|
|
||||||
pub use relayid::RelayId;
|
pub use relayid::RelayId;
|
||||||
pub use relaylabel::RelayLabel;
|
pub use relaylabel::{RelayLabel, RelayLabelError};
|
||||||
pub use relaystate::RelayState;
|
pub use relaystate::RelayState;
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ use thiserror::Error;
|
|||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
pub struct RelayLabel(String);
|
pub struct RelayLabel(String);
|
||||||
|
|
||||||
|
/// Errors that can occur when creating or validating relay labels.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RelayLabelError {
|
pub enum RelayLabelError {
|
||||||
|
/// The label string is empty.
|
||||||
|
///
|
||||||
|
/// Relay labels must contain at least one character.
|
||||||
#[error("Label cannot be empty")]
|
#[error("Label cannot be empty")]
|
||||||
Empty,
|
Empty,
|
||||||
|
|
||||||
|
/// The label string exceeds the maximum allowed length.
|
||||||
|
///
|
||||||
|
/// Contains the actual length of the invalid label.
|
||||||
|
/// Maximum allowed length is 50 characters.
|
||||||
#[error("Label exceeds maximum length of 50 characters: {0}")]
|
#[error("Label exceeds maximum length of 50 characters: {0}")]
|
||||||
TooLong(usize),
|
TooLong(usize),
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/src/infrastructure/persistence/entities/mod.rs
Normal file
33
backend/src/infrastructure/persistence/entities/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! Infrastructure entities for database persistence.
|
||||||
|
//!
|
||||||
|
//! This module defines entities that directly map to database tables,
|
||||||
|
//! providing a clear separation between the persistence layer and the
|
||||||
|
//! domain layer. These entities represent raw database records without
|
||||||
|
//! domain validation or business logic.
|
||||||
|
//!
|
||||||
|
//! # Conversion Pattern
|
||||||
|
//!
|
||||||
|
//! Infrastructure entities implement `TryFrom` traits to convert between
|
||||||
|
//! database records and domain types:
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! # use sta::domain::relay::types::{RelayId, RelayLabel};
|
||||||
|
//! # use sta::infrastructure::persistence::entities::relay_label_record::RelayLabelRecord;
|
||||||
|
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! // Database Record -> Domain Types
|
||||||
|
//! // ... from database
|
||||||
|
//! let record: RelayLabelRecord = RelayLabelRecord { relay_id: 2, label: "label".to_string() };
|
||||||
|
//! let (relay_id, relay_label): (RelayId, RelayLabel) = record.try_into()?;
|
||||||
|
//!
|
||||||
|
//! // Domain Types -> Database Record
|
||||||
|
//! let domain_record= RelayLabelRecord::new(relay_id, &relay_label);
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// Database entity for relay labels.
|
||||||
|
///
|
||||||
|
/// This module contains the `RelayLabelRecord` struct which represents
|
||||||
|
/// a single row in the `RelayLabels` database table, along with conversion
|
||||||
|
/// traits to and from domain types.
|
||||||
|
pub mod relay_label_record;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use crate::domain::relay::{
|
||||||
|
controller::ControllerError,
|
||||||
|
repository::RepositoryError,
|
||||||
|
types::{RelayId, RelayLabel, RelayLabelError},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Database record representing a relay label.
|
||||||
|
///
|
||||||
|
/// This struct directly maps to the `RelayLabels` table in the
|
||||||
|
/// database. It represents the raw data as stored in the database,
|
||||||
|
/// without domain validation or business logic.
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct RelayLabelRecord {
|
||||||
|
/// The relay ID (1-8) as stored in the database
|
||||||
|
pub relay_id: i64,
|
||||||
|
/// The label text as stored in the database
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayLabelRecord {
|
||||||
|
/// Creates a new `RecordLabelRecord` from domain types.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(relay_id: RelayId, label: &RelayLabel) -> Self {
|
||||||
|
Self {
|
||||||
|
relay_id: i64::from(relay_id.as_u8()),
|
||||||
|
label: label.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<RelayLabelRecord> for RelayId {
|
||||||
|
type Error = ControllerError;
|
||||||
|
|
||||||
|
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||||
|
let value = u8::try_from(value.relay_id).map_err(|e| {
|
||||||
|
Self::Error::InvalidInput(format!("Got value {} from database: {e}", value.relay_id))
|
||||||
|
})?;
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<RelayLabelRecord> for RelayLabel {
|
||||||
|
type Error = RelayLabelError;
|
||||||
|
|
||||||
|
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<RelayLabelRecord> for (RelayId, RelayLabel) {
|
||||||
|
type Error = RepositoryError;
|
||||||
|
|
||||||
|
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||||
|
let record_id: RelayId = value
|
||||||
|
.clone()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|e: ControllerError| RepositoryError::DatabaseError(e.to_string()))?;
|
||||||
|
let label: RelayLabel = RelayLabel::new(value.label)
|
||||||
|
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
|
||||||
|
Ok((record_id, label))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,5 @@ 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;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
use sqlx::SqlitePool;
|
use async_trait::async_trait;
|
||||||
|
use sqlx::{SqlitePool, query_as};
|
||||||
|
|
||||||
use crate::domain::relay::repository::RepositoryError;
|
use crate::{
|
||||||
|
domain::relay::{
|
||||||
|
repository::{RelayLabelRepository, RepositoryError},
|
||||||
|
types::{RelayId, RelayLabel},
|
||||||
|
},
|
||||||
|
infrastructure::persistence::entities::relay_label_record::RelayLabelRecord,
|
||||||
|
};
|
||||||
|
|
||||||
/// `SQLite` implementation of the relay label repository.
|
/// `SQLite` implementation of the relay label repository.
|
||||||
///
|
///
|
||||||
@@ -62,3 +69,56 @@ impl SqliteRelayLabelRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RelayLabelRepository for SqliteRelayLabelRepository {
|
||||||
|
async fn get_label(&self, id: RelayId) -> Result<Option<RelayLabel>, RepositoryError> {
|
||||||
|
let id = i64::from(id.as_u8());
|
||||||
|
let result = sqlx::query_as!(
|
||||||
|
RelayLabelRecord,
|
||||||
|
"SELECT * FROM RelayLabels WHERE relay_id = ?1",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(record) => Ok(Some(record.try_into()?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError> {
|
||||||
|
let record = RelayLabelRecord::new(id, &label);
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT OR REPLACE INTO RelayLabels (relay_id, label) VALUES (?1, ?2)",
|
||||||
|
record.relay_id,
|
||||||
|
record.label
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(RepositoryError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_label(&self, id: RelayId) -> Result<(), RepositoryError> {
|
||||||
|
let id = i64::from(id.as_u8());
|
||||||
|
sqlx::query!("DELETE FROM RelayLabels WHERE relay_id = ?1", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(RepositoryError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, RepositoryError> {
|
||||||
|
let result: Vec<RelayLabelRecord> = query_as!(
|
||||||
|
RelayLabelRecord,
|
||||||
|
"SELECT * FROM RelayLabels ORDER BY relay_id"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(RepositoryError::from)?;
|
||||||
|
result.iter().map(|r| r.clone().try_into()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
473
backend/tests/sqlite_repository_functional_test.rs
Normal file
473
backend/tests/sqlite_repository_functional_test.rs
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
//! Functional tests for `SqliteRelayLabelRepository` implementation.
|
||||||
|
//!
|
||||||
|
//! These tests verify that the SQLite repository correctly implements
|
||||||
|
//! the `RelayLabelRepository` trait using the new infrastructure entities
|
||||||
|
//! and conversion patterns.
|
||||||
|
|
||||||
|
use sta::{
|
||||||
|
domain::relay::{
|
||||||
|
repository::RelayLabelRepository,
|
||||||
|
types::{RelayId, RelayLabel},
|
||||||
|
},
|
||||||
|
infrastructure::persistence::{
|
||||||
|
entities::relay_label_record::RelayLabelRecord,
|
||||||
|
sqlite_repository::SqliteRelayLabelRepository,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Test that `get_label` returns None for non-existent relay.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_label_returns_none_for_non_existent_relay() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let result = repo.get_label(relay_id).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "get_label should succeed");
|
||||||
|
assert!(
|
||||||
|
result.unwrap().is_none(),
|
||||||
|
"get_label should return None for non-existent relay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `get_label` retrieves previously saved label.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_label_retrieves_saved_label() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(2).expect("Valid relay ID");
|
||||||
|
let label = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save the label
|
||||||
|
repo.save_label(relay_id, label.clone())
|
||||||
|
.await
|
||||||
|
.expect("save_label should succeed");
|
||||||
|
|
||||||
|
// Retrieve the label
|
||||||
|
let result = repo.get_label(relay_id).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "get_label should succeed");
|
||||||
|
let retrieved = result.unwrap();
|
||||||
|
assert!(retrieved.is_some(), "get_label should return Some");
|
||||||
|
assert_eq!(
|
||||||
|
retrieved.unwrap().as_str(),
|
||||||
|
"Heater",
|
||||||
|
"Retrieved label should match saved label"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `save_label` successfully saves a label.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_label_succeeds() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let label = RelayLabel::new("Pump".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
let result = repo.save_label(relay_id, label).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "save_label should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `save_label` overwrites existing label.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_label_overwrites_existing_label() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(4).expect("Valid relay ID");
|
||||||
|
let label1 = RelayLabel::new("First".to_string()).expect("Valid label");
|
||||||
|
let label2 = RelayLabel::new("Second".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save first label
|
||||||
|
repo.save_label(relay_id, label1)
|
||||||
|
.await
|
||||||
|
.expect("First save should succeed");
|
||||||
|
|
||||||
|
// Overwrite with second label
|
||||||
|
repo.save_label(relay_id, label2)
|
||||||
|
.await
|
||||||
|
.expect("Second save should succeed");
|
||||||
|
|
||||||
|
// Verify only the second label is present
|
||||||
|
let result = repo
|
||||||
|
.get_label(relay_id)
|
||||||
|
.await
|
||||||
|
.expect("get_label should succeed");
|
||||||
|
assert!(result.is_some(), "Label should exist");
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap().as_str(),
|
||||||
|
"Second",
|
||||||
|
"Label should be updated to second value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `save_label` works for all valid relay IDs (1-8).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_label_for_all_valid_relay_ids() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
for id in 1..=8 {
|
||||||
|
let relay_id = RelayId::new(id).expect("Valid relay ID");
|
||||||
|
let label = RelayLabel::new(format!("Relay {}", id)).expect("Valid label");
|
||||||
|
|
||||||
|
let result = repo.save_label(relay_id, label).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"save_label should succeed for relay ID {}",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all labels were saved
|
||||||
|
let all_labels = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
assert_eq!(all_labels.len(), 8, "Should have all 8 relay labels");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `save_label` accepts maximum length labels.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_label_accepts_max_length_labels() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(5).expect("Valid relay ID");
|
||||||
|
let max_label = RelayLabel::new("A".repeat(50)).expect("Valid max-length label");
|
||||||
|
|
||||||
|
let result = repo.save_label(relay_id, max_label).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"save_label should succeed with max-length label"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it was saved correctly
|
||||||
|
let retrieved = repo
|
||||||
|
.get_label(relay_id)
|
||||||
|
.await
|
||||||
|
.expect("get_label should succeed");
|
||||||
|
assert!(retrieved.is_some(), "Label should be saved");
|
||||||
|
assert_eq!(
|
||||||
|
retrieved.unwrap().as_str().len(),
|
||||||
|
50,
|
||||||
|
"Label should have correct length"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `delete_label` succeeds for existing label.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_label_succeeds_for_existing_label() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(7).expect("Valid relay ID");
|
||||||
|
let label = RelayLabel::new("ToDelete".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save the label first
|
||||||
|
repo.save_label(relay_id, label)
|
||||||
|
.await
|
||||||
|
.expect("save_label should succeed");
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
let result = repo.delete_label(relay_id).await;
|
||||||
|
assert!(result.is_ok(), "delete_label should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `delete_label` succeeds for non-existent label.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_label_succeeds_for_non_existent_label() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(8).expect("Valid relay ID");
|
||||||
|
|
||||||
|
// Delete without saving first
|
||||||
|
let result = repo.delete_label(relay_id).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"delete_label should succeed even if label doesn't exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `delete_label` removes label from repository.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_label_removes_label_from_repository() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
|
let label1 = RelayLabel::new("Keep".to_string()).expect("Valid label");
|
||||||
|
let label2 = RelayLabel::new("Remove".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save two labels
|
||||||
|
repo.save_label(relay1, label1)
|
||||||
|
.await
|
||||||
|
.expect("save should succeed");
|
||||||
|
repo.save_label(relay2, label2)
|
||||||
|
.await
|
||||||
|
.expect("save should succeed");
|
||||||
|
|
||||||
|
// Delete one label
|
||||||
|
repo.delete_label(relay2)
|
||||||
|
.await
|
||||||
|
.expect("delete should succeed");
|
||||||
|
|
||||||
|
// Verify deleted label is gone
|
||||||
|
let get_result = repo
|
||||||
|
.get_label(relay2)
|
||||||
|
.await
|
||||||
|
.expect("get_label should succeed");
|
||||||
|
assert!(get_result.is_none(), "Deleted label should not exist");
|
||||||
|
|
||||||
|
// Verify other label still exists
|
||||||
|
let other_result = repo
|
||||||
|
.get_label(relay1)
|
||||||
|
.await
|
||||||
|
.expect("get_label should succeed");
|
||||||
|
assert!(other_result.is_some(), "Other label should still exist");
|
||||||
|
|
||||||
|
// Verify get_all_labels only returns the remaining label
|
||||||
|
let all_labels = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
assert_eq!(all_labels.len(), 1, "Should only have one label remaining");
|
||||||
|
assert_eq!(all_labels[0].0.as_u8(), 1, "Should be relay 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `get_all_labels` returns empty vector when no labels exist.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_labels_returns_empty_when_no_labels() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let result = repo.get_all_labels().await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "get_all_labels should succeed");
|
||||||
|
assert!(
|
||||||
|
result.unwrap().is_empty(),
|
||||||
|
"get_all_labels should return empty vector"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `get_all_labels` returns all saved labels.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_labels_returns_all_saved_labels() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
||||||
|
let relay5 = RelayId::new(5).expect("Valid relay ID");
|
||||||
|
|
||||||
|
let label1 = RelayLabel::new("Pump".to_string()).expect("Valid label");
|
||||||
|
let label3 = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
||||||
|
let label5 = RelayLabel::new("Fan".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save labels
|
||||||
|
repo.save_label(relay1, label1.clone())
|
||||||
|
.await
|
||||||
|
.expect("Save should succeed");
|
||||||
|
repo.save_label(relay3, label3.clone())
|
||||||
|
.await
|
||||||
|
.expect("Save should succeed");
|
||||||
|
repo.save_label(relay5, label5.clone())
|
||||||
|
.await
|
||||||
|
.expect("Save should succeed");
|
||||||
|
|
||||||
|
// Retrieve all labels
|
||||||
|
let result = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 3, "Should return exactly 3 labels");
|
||||||
|
|
||||||
|
// Verify the labels are present (order may vary by implementation)
|
||||||
|
let has_relay1 = result
|
||||||
|
.iter()
|
||||||
|
.any(|(id, label)| id.as_u8() == 1 && label.as_str() == "Pump");
|
||||||
|
let has_relay3 = result
|
||||||
|
.iter()
|
||||||
|
.any(|(id, label)| id.as_u8() == 3 && label.as_str() == "Heater");
|
||||||
|
let has_relay5 = result
|
||||||
|
.iter()
|
||||||
|
.any(|(id, label)| id.as_u8() == 5 && label.as_str() == "Fan");
|
||||||
|
|
||||||
|
assert!(has_relay1, "Should contain relay 1 with label 'Pump'");
|
||||||
|
assert!(has_relay3, "Should contain relay 3 with label 'Heater'");
|
||||||
|
assert!(has_relay5, "Should contain relay 5 with label 'Fan'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `get_all_labels` excludes relays without labels.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_labels_excludes_relays_without_labels() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
|
let label2 = RelayLabel::new("Only This One".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
repo.save_label(relay2, label2)
|
||||||
|
.await
|
||||||
|
.expect("Save should succeed");
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.len(),
|
||||||
|
1,
|
||||||
|
"Should return only the one relay with a label"
|
||||||
|
);
|
||||||
|
assert_eq!(result[0].0.as_u8(), 2, "Should be relay 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that `get_all_labels` excludes deleted labels.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_all_labels_excludes_deleted_labels() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
|
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
||||||
|
|
||||||
|
let label1 = RelayLabel::new("Keep1".to_string()).expect("Valid label");
|
||||||
|
let label2 = RelayLabel::new("Delete".to_string()).expect("Valid label");
|
||||||
|
let label3 = RelayLabel::new("Keep2".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save all three labels
|
||||||
|
repo.save_label(relay1, label1)
|
||||||
|
.await
|
||||||
|
.expect("save should succeed");
|
||||||
|
repo.save_label(relay2, label2)
|
||||||
|
.await
|
||||||
|
.expect("save should succeed");
|
||||||
|
repo.save_label(relay3, label3)
|
||||||
|
.await
|
||||||
|
.expect("save should succeed");
|
||||||
|
|
||||||
|
// Delete the middle one
|
||||||
|
repo.delete_label(relay2)
|
||||||
|
.await
|
||||||
|
.expect("delete should succeed");
|
||||||
|
|
||||||
|
// Verify get_all_labels only returns the two remaining labels
|
||||||
|
let result = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
assert_eq!(result.len(), 2, "Should have 2 labels after deletion");
|
||||||
|
|
||||||
|
let has_relay1 = result.iter().any(|(id, _)| id.as_u8() == 1);
|
||||||
|
let has_relay2 = result.iter().any(|(id, _)| id.as_u8() == 2);
|
||||||
|
let has_relay3 = result.iter().any(|(id, _)| id.as_u8() == 3);
|
||||||
|
|
||||||
|
assert!(has_relay1, "Relay 1 should be present");
|
||||||
|
assert!(!has_relay2, "Relay 2 should NOT be present (deleted)");
|
||||||
|
assert!(has_relay3, "Relay 3 should be present");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that entity conversion works correctly.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_entity_conversion_roundtrip() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let relay_label = RelayLabel::new("Test Label".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Create record from domain types
|
||||||
|
let _record = RelayLabelRecord::new(relay_id, &relay_label);
|
||||||
|
|
||||||
|
// Save using repository
|
||||||
|
repo.save_label(relay_id, relay_label.clone())
|
||||||
|
.await
|
||||||
|
.expect("save_label should succeed");
|
||||||
|
|
||||||
|
// Retrieve and verify conversion
|
||||||
|
let retrieved = repo
|
||||||
|
.get_label(relay_id)
|
||||||
|
.await
|
||||||
|
.expect("get_label should succeed");
|
||||||
|
|
||||||
|
assert!(retrieved.is_some(), "Label should be retrieved");
|
||||||
|
assert_eq!(retrieved.unwrap(), relay_label, "Labels should match");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that repository handles database errors gracefully.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_repository_error_handling() {
|
||||||
|
let _repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
// Test with invalid relay ID (should be caught by domain validation)
|
||||||
|
let invalid_relay_id = RelayId::new(9); // This will fail validation
|
||||||
|
assert!(invalid_relay_id.is_err(), "Invalid relay ID should fail validation");
|
||||||
|
|
||||||
|
// Test with invalid label (should be caught by domain validation)
|
||||||
|
let invalid_label = RelayLabel::new("".to_string()); // Empty label
|
||||||
|
assert!(invalid_label.is_err(), "Empty label should fail validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that repository operations are thread-safe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_operations_are_thread_safe() {
|
||||||
|
let repo = SqliteRelayLabelRepository::in_memory()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create repository");
|
||||||
|
|
||||||
|
// Since SqliteRelayLabelRepository doesn't implement Clone, we'll test
|
||||||
|
// sequential operations which still verify the repository handles
|
||||||
|
// multiple operations correctly
|
||||||
|
|
||||||
|
// Save multiple labels sequentially
|
||||||
|
let relay_id1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
let label1 = RelayLabel::new("Task1".to_string()).expect("Valid label");
|
||||||
|
repo.save_label(relay_id1, label1)
|
||||||
|
.await
|
||||||
|
.expect("First save should succeed");
|
||||||
|
|
||||||
|
let relay_id2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
|
let label2 = RelayLabel::new("Task2".to_string()).expect("Valid label");
|
||||||
|
repo.save_label(relay_id2, label2)
|
||||||
|
.await
|
||||||
|
.expect("Second save should succeed");
|
||||||
|
|
||||||
|
let relay_id3 = RelayId::new(3).expect("Valid relay ID");
|
||||||
|
let label3 = RelayLabel::new("Task3".to_string()).expect("Valid label");
|
||||||
|
repo.save_label(relay_id3, label3)
|
||||||
|
.await
|
||||||
|
.expect("Third save should succeed");
|
||||||
|
|
||||||
|
// Verify all labels were saved
|
||||||
|
let all_labels = repo
|
||||||
|
.get_all_labels()
|
||||||
|
.await
|
||||||
|
.expect("get_all_labels should succeed");
|
||||||
|
assert_eq!(all_labels.len(), 3, "Should have all 3 labels");
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
* Development phases [0/0]
|
* TODO Development phases [4/9]
|
||||||
** DONE Phase 1: Setup & Foundation (0.5 days) [8/8]
|
** DONE Phase 1: Setup & Foundation (0.5 days) [8/8]
|
||||||
*Purpose*: Initialize project dependencies and directory structure
|
*Purpose*: Initialize project dependencies and directory structure
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@
|
|||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
** TODO Phase 3: Infrastructure Layer (2 days) [5/5]
|
** DONE Phase 3: Infrastructure Layer (2 days) [1/1]
|
||||||
*Purpose*: Implement Modbus client, mocks, and persistence
|
*Purpose*: Implement Modbus client, mocks, and persistence
|
||||||
|
|
||||||
- [X] *T028* [P] [US1] [TDD] Write tests for =MockRelayController=
|
- [X] *T028* [P] [US1] [TDD] Write tests for =MockRelayController=
|
||||||
@@ -331,7 +331,9 @@
|
|||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
*** STARTED T025: ModbusRelayController Implementation (DECOMPOSED) [12/13]
|
*** DONE T025: ModbusRelayController Implementation (DECOMPOSED) [13/13]
|
||||||
|
CLOSED: [2026-01-22 jeu. 00:02]
|
||||||
|
- State "DONE" from "STARTED" [2026-01-22 jeu. 00:02]
|
||||||
- Complexity :: High → Broken into 6 sub-tasks
|
- Complexity :: High → Broken into 6 sub-tasks
|
||||||
- Uncertainty :: High
|
- Uncertainty :: High
|
||||||
- Rationale :: Nested Result handling, =Arc<Mutex>= synchronization, timeout wrapping
|
- Rationale :: Nested Result handling, =Arc<Mutex>= synchronization, timeout wrapping
|
||||||
@@ -557,7 +559,7 @@
|
|||||||
- Test: =delete_label(RelayId(1)) → Result<(), RepositoryError>=
|
- Test: =delete_label(RelayId(1)) → Result<(), RepositoryError>=
|
||||||
- *File*: =src/infrastructure/persistence/label_repository.rs=
|
- *File*: =src/infrastructure/persistence/label_repository.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *Complexity*: Low | *Uncertainty*: Low
|
||||||
- [ ] *T036* [P] [US4] [TDD] Implement SQLite =RelayLabelRepository=
|
- [X] *T036* [P] [US4] [TDD] Implement SQLite =RelayLabelRepository=
|
||||||
- Implement =get_label()=, =set_label()=, =delete_label()= using SQLx
|
- Implement =get_label()=, =set_label()=, =delete_label()= using SQLx
|
||||||
- Use =sqlx::query!= macros for compile-time SQL verification
|
- Use =sqlx::query!= macros for compile-time SQL verification
|
||||||
- *File*: =src/infrastructure/persistence/sqlite_label_repository.rs=
|
- *File*: =src/infrastructure/persistence/sqlite_label_repository.rs=
|
||||||
|
|||||||
Reference in New Issue
Block a user