From 02b7e1e9559dd4e1c26531ad5988bdb0696828d6 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sun, 11 Jan 2026 00:59:58 +0100 Subject: [PATCH] test(infrastructure): write RelayLabelRepository trait tests Add reusable test suite with 18 test functions covering get_label(), save_label(), delete_label(), and get_all_labels() methods. Tests verify contract compliance for any repository implementation. Added delete_label() method to trait interface and implemented it in MockRelayLabelRepository to support complete CRUD operations. TDD phase: RED - Tests written before SQLite implementation (T036) Ref: T035 (specs/001-modbus-relay-control/tasks.md) --- backend/src/domain/relay/repository/label.rs | 9 + .../persistence/label_repository.rs | 5 + .../persistence/label_repository_tests.rs | 449 ++++++++++++++++++ backend/src/infrastructure/persistence/mod.rs | 4 + specs/001-modbus-relay-control/tasks.md | 2 +- 5 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 backend/src/infrastructure/persistence/label_repository_tests.rs diff --git a/backend/src/domain/relay/repository/label.rs b/backend/src/domain/relay/repository/label.rs index d3c7a61..6072ac7 100644 --- a/backend/src/domain/relay/repository/label.rs +++ b/backend/src/domain/relay/repository/label.rs @@ -29,6 +29,15 @@ pub trait RelayLabelRepository: Send + Sync { /// Returns `RepositoryError::DatabaseError` if the database operation fails. async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>; + /// Deletes the label for a specific relay. + /// + /// If no label exists for the relay, this operation succeeds without error. + /// + /// # Errors + /// + /// Returns `RepositoryError::DatabaseError` if the database operation fails. + async fn delete_label(&self, id: RelayId) -> Result<(), RepositoryError>; + /// Retrieves all relay labels from the repository. /// /// Returns a vector of tuples containing relay IDs and their corresponding labels. diff --git a/backend/src/infrastructure/persistence/label_repository.rs b/backend/src/infrastructure/persistence/label_repository.rs index be5d907..be4a678 100644 --- a/backend/src/infrastructure/persistence/label_repository.rs +++ b/backend/src/infrastructure/persistence/label_repository.rs @@ -57,6 +57,11 @@ impl RelayLabelRepository for MockRelayLabelRepository { Ok(()) } + async fn delete_label(&self, id: RelayId) -> Result<(), RepositoryError> { + self.labels().await.remove(&id.as_u8()); + Ok(()) + } + async fn get_all_labels(&self) -> Result, RepositoryError> { let mut result: Vec<(RelayId, RelayLabel)> = self .labels() diff --git a/backend/src/infrastructure/persistence/label_repository_tests.rs b/backend/src/infrastructure/persistence/label_repository_tests.rs new file mode 100644 index 0000000..6bc1afe --- /dev/null +++ b/backend/src/infrastructure/persistence/label_repository_tests.rs @@ -0,0 +1,449 @@ +//! Comprehensive tests for `RelayLabelRepository` trait contract. +//! +//! This module provides a reusable test suite that verifies any implementation +//! of the `RelayLabelRepository` trait meets the expected contract. These tests +//! can be run against different implementations (mock, SQLite, PostgreSQL, etc.) +//! to ensure they all behave correctly. +//! +//! **T035**: Write tests for RelayLabelRepository trait +//! - Test: `get_label(RelayId(1)) → Option` +//! - Test: `save_label(RelayId(1), label) → Result<(), RepositoryError>` +//! - Test: `delete_label(RelayId(1)) → Result<(), RepositoryError>` + +#[cfg(test)] +mod relay_label_repository_contract_tests { + use crate::domain::relay::{ + repository::RelayLabelRepository, + types::{RelayId, RelayLabel}, + }; + + // ========================================================================= + // get_label() Tests + // ========================================================================= + + /// Test: `get_label` returns None for non-existent relay + /// + /// Verifies that querying a relay ID that has no label returns None + /// rather than an error. + pub async fn test_get_label_returns_none_for_non_existent_relay( + repo: &R, + ) { + 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: `get_label` retrieves previously saved label + /// + /// Verifies that after saving a label, `get_label` returns the same label. + pub async fn test_get_label_retrieves_saved_label(repo: &R) { + 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: `get_label` returns None after label is deleted + /// + /// Verifies that after deleting a label, `get_label` returns None. + pub async fn test_get_label_returns_none_after_delete(repo: &R) { + let relay_id = RelayId::new(3).expect("Valid relay ID"); + let label = RelayLabel::new("ToBeDeleted".to_string()).expect("Valid label"); + + // Save and then delete the label + repo.save_label(relay_id, label) + .await + .expect("save_label should succeed"); + repo.delete_label(relay_id) + .await + .expect("delete_label should succeed"); + + // Verify it's gone + 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 after delete" + ); + } + + // ========================================================================= + // save_label() Tests + // ========================================================================= + + /// Test: `save_label` successfully saves a label + /// + /// Verifies that `save_label` returns Ok and stores the label. + pub async fn test_save_label_succeeds(repo: &R) { + 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: `save_label` overwrites existing label + /// + /// Verifies that calling `save_label` multiple times for the same relay ID + /// replaces the old label with the new one. + pub async fn test_save_label_overwrites_existing_label(repo: &R) { + 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: `save_label` works for all valid relay IDs (1-8) + /// + /// Verifies that all relay IDs in the valid range can have labels saved. + pub async fn test_save_label_for_all_valid_relay_ids(repo: &R) { + 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: `save_label` accepts maximum length labels + /// + /// Verifies that labels at the maximum allowed length (50 characters) + /// can be saved successfully. + pub async fn test_save_label_accepts_max_length_labels(repo: &R) { + 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: `save_label` accepts minimum length labels + /// + /// Verifies that labels at the minimum allowed length (1 character) + /// can be saved successfully. + pub async fn test_save_label_accepts_min_length_labels(repo: &R) { + let relay_id = RelayId::new(6).expect("Valid relay ID"); + let min_label = RelayLabel::new("X".to_string()).expect("Valid min-length label"); + + let result = repo.save_label(relay_id, min_label).await; + assert!( + result.is_ok(), + "save_label should succeed with min-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(), "X", "Label should match"); + } + + // ========================================================================= + // delete_label() Tests + // ========================================================================= + + /// Test: `delete_label` succeeds for existing label + /// + /// Verifies that `delete_label` returns Ok when deleting an existing label. + pub async fn test_delete_label_succeeds_for_existing_label(repo: &R) { + 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: `delete_label` succeeds for non-existent label + /// + /// Verifies that `delete_label` returns Ok even when no label exists + /// (idempotent operation). + pub async fn test_delete_label_succeeds_for_non_existent_label( + repo: &R, + ) { + 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: `delete_label` removes label from repository + /// + /// Verifies that after deleting a label, it no longer appears in `get_label` + /// or `get_all_labels` results. + pub async fn test_delete_label_removes_label_from_repository( + repo: &R, + ) { + 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: `delete_label` is idempotent + /// + /// Verifies that calling `delete_label` multiple times succeeds without error. + pub async fn test_delete_label_is_idempotent(repo: &R) { + let relay_id = RelayId::new(3).expect("Valid relay ID"); + let label = RelayLabel::new("Idempotent".to_string()).expect("Valid label"); + + // Save, then delete twice + repo.save_label(relay_id, label) + .await + .expect("save should succeed"); + repo.delete_label(relay_id) + .await + .expect("First delete should succeed"); + let second_delete = repo.delete_label(relay_id).await; + + assert!( + second_delete.is_ok(), + "Second delete should succeed (idempotent)" + ); + } + + // ========================================================================= + // get_all_labels() Tests + // ========================================================================= + + /// Test: `get_all_labels` returns empty vector when no labels exist + /// + /// Verifies that `get_all_labels` returns an empty vector rather than + /// an error when the repository is empty. + pub async fn test_get_all_labels_returns_empty_when_no_labels( + repo: &R, + ) { + 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: `get_all_labels` returns all saved labels + /// + /// Verifies that `get_all_labels` returns all labels that have been saved, + /// and only those relays with labels. + pub async fn test_get_all_labels_returns_all_saved_labels(repo: &R) { + 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: `get_all_labels` excludes relays without labels + /// + /// Verifies that only relays with labels are returned, not all possible + /// relay IDs (1-8). + pub async fn test_get_all_labels_excludes_relays_without_labels( + repo: &R, + ) { + 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: `get_all_labels` excludes deleted labels + /// + /// Verifies that deleted labels don't appear in `get_all_labels` results. + pub async fn test_get_all_labels_excludes_deleted_labels(repo: &R) { + 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"); + } +} diff --git a/backend/src/infrastructure/persistence/mod.rs b/backend/src/infrastructure/persistence/mod.rs index 8f269a0..830e35c 100644 --- a/backend/src/infrastructure/persistence/mod.rs +++ b/backend/src/infrastructure/persistence/mod.rs @@ -6,5 +6,9 @@ /// Mock repository implementation for testing. pub mod label_repository; +/// Comprehensive tests for `RelayLabelRepository` trait contract (T035). +#[cfg(test)] +pub mod label_repository_tests; + /// `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 a835699..caa4a8a 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -531,7 +531,7 @@ - **Complexity**: Medium | **Uncertainty**: High - **Note**: Use #[ignore] attribute, run with `cargo test -- --ignored` -- [ ] **T035** [P] [US4] [TDD] Write tests for RelayLabelRepository trait +- [x] **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>`