diff --git a/backend/src/domain/relay/mod.rs b/backend/src/domain/relay/mod.rs index 84ab6a3..1c18fcf 100644 --- a/backend/src/domain/relay/mod.rs +++ b/backend/src/domain/relay/mod.rs @@ -3,6 +3,8 @@ //! This module contains the core domain logic for relay control and management, //! including relay types, repository abstractions, and business rules. +use types::{RelayId, RelayLabel, RelayState}; + /// Controller error types for relay operations. pub mod controller; /// Relay entity representing the relay aggregate. @@ -11,3 +13,409 @@ pub mod entity; pub mod repository; /// Domain types for relay identification and control. pub mod types; + +#[derive(Debug, Clone, PartialEq, Eq)] +/// A relay entity representing a physical relay device. +/// +/// This struct encapsulates the core properties of a relay including its +/// unique identifier, current state (on/off), and an optional label for +/// user-friendly identification. +pub struct Relay { + id: RelayId, + state: RelayState, + label: RelayLabel, +} + +impl Relay { + /// Creates a new relay with the specified ID. + /// + /// The relay is initialized with the default state (Off) and default label. + /// + /// # Arguments + /// + /// * `id` - The unique identifier for the relay + /// + /// # Returns + /// + /// A new Relay instance with the given ID, Off state, and default label + #[must_use] + pub fn new(id: RelayId) -> Self { + Self::with_state(id, RelayState::Off) + } + + /// Creates a new relay with the specified ID and state. + /// + /// The relay is initialized with the given state and default label. + /// + /// # Arguments + /// + /// * `id` - The unique identifier for the relay + /// * `state` - The initial state of the relay (On or Off) + /// + /// # Returns + /// + /// A new Relay instance with the given ID, state, and default label + #[must_use] + pub fn with_state(id: RelayId, state: RelayState) -> Self { + Self::with_label(id, state, RelayLabel::default()) + } + + /// Creates a new relay with the specified ID, state, and label. + /// + /// This is the most comprehensive constructor that allows full customization + /// of all relay properties. + /// + /// # Arguments + /// + /// * `id` - The unique identifier for the relay + /// * `state` - The initial state of the relay (On or Off) + /// * `label` - The user-friendly label for the relay + /// + /// # Returns + /// + /// A new Relay instance with the specified properties + #[must_use] + pub const fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self { + Self { id, state, label } + } + + /// Returns the relay's unique identifier. + /// + /// # Returns + /// + /// The `RelayId` associated with this relay + #[must_use] + pub const fn id(&self) -> RelayId { + self.id + } + + /// Returns the current state of the relay. + /// + /// # Returns + /// + /// The `RelayState` (On or Off) of this relay + #[must_use] + pub const fn state(&self) -> RelayState { + self.state + } + + /// Returns a reference to the relay's label. + /// + /// # Returns + /// + /// A reference to the `RelayLabel` associated with this relay + #[must_use] + pub const fn label(&self) -> &RelayLabel { + &self.label + } + + /// Toggles the relay's state between On and Off. + /// + /// If the relay is currently On, it will be turned Off, and vice versa. + /// This operation preserves the relay's ID and label. + pub const fn toggle(&mut self) { + self.state = self.state.toggle(); + } + + /// Sets the relay's state to the specified value. + /// + /// # Arguments + /// + /// * `state` - The new state to set (On or Off) + /// + /// This operation preserves the relay's ID and label. + pub const fn set_state(&mut self, state: RelayState) { + self.state = state; + } + + /// Sets the relay's label to the specified value. + /// + /// # Arguments + /// + /// * `label` - The new label to assign to the relay + /// + /// This operation preserves the relay's ID and state. + pub fn set_label(&mut self, label: RelayLabel) { + self.label = label; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_new_creates_relay_with_off_state() { + let relay_id = RelayId::new(1).unwrap(); + let relay = Relay::new(relay_id); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn test_relay_new_uses_default_label() { + let relay_id = RelayId::new(1).unwrap(); + let relay = Relay::new(relay_id); + + assert_eq!(relay.label(), &RelayLabel::default()); + assert_eq!(relay.label().as_str(), "Unlabeled"); + } + + #[test] + fn test_relay_with_state_creates_relay_with_specified_state() { + let relay_id = RelayId::new(3).unwrap(); + let relay = Relay::with_state(relay_id, RelayState::On); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.state(), RelayState::On); + } + + #[test] + fn test_relay_with_state_uses_default_label() { + let relay_id = RelayId::new(3).unwrap(); + let relay = Relay::with_state(relay_id, RelayState::On); + + assert_eq!(relay.label(), &RelayLabel::default()); + } + + #[test] + fn test_relay_with_label_creates_relay_with_all_fields() { + let relay_id = RelayId::new(5).unwrap(); + let label = RelayLabel::new("Water Pump".to_string()).unwrap(); + let relay = Relay::with_label(relay_id, RelayState::On, label.clone()); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.state(), RelayState::On); + assert_eq!(relay.label(), &label); + } + + #[test] + fn test_relay_constructors_chain_correctly() { + let relay_id = RelayId::new(2).unwrap(); + + let relay1 = Relay::new(relay_id); + let relay2 = Relay::with_state(relay_id, RelayState::Off); + + assert_eq!(relay1.id(), relay2.id()); + assert_eq!(relay1.state(), relay2.state()); + assert_eq!(relay1.label(), relay2.label()); + } + + + #[test] + fn test_relay_id_returns_correct_id() { + for id_val in 1..=8 { + let relay_id = RelayId::new(id_val).unwrap(); + let relay = Relay::new(relay_id); + assert_eq!(relay.id(), relay_id); + } + } + + #[test] + fn test_relay_state_returns_correct_state() { + let relay_id = RelayId::new(1).unwrap(); + + let relay_on = Relay::with_state(relay_id, RelayState::On); + assert_eq!(relay_on.state(), RelayState::On); + + let relay_off = Relay::with_state(relay_id, RelayState::Off); + assert_eq!(relay_off.state(), RelayState::Off); + } + + #[test] + fn test_relay_label_returns_reference_to_label() { + let relay_id = RelayId::new(1).unwrap(); + let label = RelayLabel::new("Test Label".to_string()).unwrap(); + let relay = Relay::with_label(relay_id, RelayState::Off, label.clone()); + + assert_eq!(relay.label(), &label); + assert_eq!(relay.label().as_str(), "Test Label"); + } + + + #[test] + fn test_relay_toggle_off_to_on() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::Off); + + relay.toggle(); + + assert_eq!(relay.state(), RelayState::On); + } + + #[test] + fn test_relay_toggle_on_to_off() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::On); + + relay.toggle(); + + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn test_relay_toggle_idempotency() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::Off); + + relay.toggle(); + relay.toggle(); + + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn test_relay_toggle_preserves_id_and_label() { + let relay_id = RelayId::new(4).unwrap(); + let label = RelayLabel::new("Light Switch".to_string()).unwrap(); + let mut relay = Relay::with_label(relay_id, RelayState::Off, label.clone()); + + relay.toggle(); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.label(), &label); + } + + + #[test] + fn test_relay_set_state_to_on() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::Off); + + relay.set_state(RelayState::On); + + assert_eq!(relay.state(), RelayState::On); + } + + #[test] + fn test_relay_set_state_to_off() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::On); + + relay.set_state(RelayState::Off); + + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn test_relay_set_state_same_state_is_idempotent() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::On); + + relay.set_state(RelayState::On); + + assert_eq!(relay.state(), RelayState::On); + } + + #[test] + fn test_relay_set_state_preserves_id_and_label() { + let relay_id = RelayId::new(7).unwrap(); + let label = RelayLabel::new("Heater".to_string()).unwrap(); + let mut relay = Relay::with_label(relay_id, RelayState::Off, label.clone()); + + relay.set_state(RelayState::On); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.label(), &label); + } + + + #[test] + fn test_relay_set_label_changes_label() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::new(relay_id); + let new_label = RelayLabel::new("New Label".to_string()).unwrap(); + + relay.set_label(new_label.clone()); + + assert_eq!(relay.label(), &new_label); + } + + #[test] + fn test_relay_set_label_replaces_existing_label() { + let relay_id = RelayId::new(1).unwrap(); + let initial_label = RelayLabel::new("Initial".to_string()).unwrap(); + let mut relay = Relay::with_label(relay_id, RelayState::Off, initial_label); + let new_label = RelayLabel::new("Replaced".to_string()).unwrap(); + + relay.set_label(new_label.clone()); + + assert_eq!(relay.label(), &new_label); + assert_eq!(relay.label().as_str(), "Replaced"); + } + + #[test] + fn test_relay_set_label_preserves_id_and_state() { + let relay_id = RelayId::new(6).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::On); + let new_label = RelayLabel::new("Fan".to_string()).unwrap(); + + relay.set_label(new_label); + + assert_eq!(relay.id(), relay_id); + assert_eq!(relay.state(), RelayState::On); + } + + #[test] + fn test_relay_set_label_can_use_max_length_label() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::new(relay_id); + let max_label = RelayLabel::new("A".repeat(50)).unwrap(); + + relay.set_label(max_label.clone()); + + assert_eq!(relay.label(), &max_label); + assert_eq!(relay.label().as_str().len(), 50); + } + + #[test] + fn test_relay_works_with_all_valid_ids() { + for id_val in 1..=8 { + let relay_id = RelayId::new(id_val).unwrap(); + let relay = Relay::new(relay_id); + + assert_eq!(relay.id().as_u8(), id_val); + assert_eq!(relay.state(), RelayState::Off); + } + } + + #[test] + fn test_relay_multiple_state_changes() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::new(relay_id); + + assert_eq!(relay.state(), RelayState::Off); + + relay.toggle(); + assert_eq!(relay.state(), RelayState::On); + + relay.set_state(RelayState::Off); + assert_eq!(relay.state(), RelayState::Off); + + relay.toggle(); + assert_eq!(relay.state(), RelayState::On); + + relay.set_state(RelayState::On); + assert_eq!(relay.state(), RelayState::On); + relay.toggle(); + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn test_relay_multiple_label_changes() { + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::new(relay_id); + + assert_eq!(relay.label().as_str(), "Unlabeled"); + + relay.set_label(RelayLabel::new("Pump".to_string()).unwrap()); + assert_eq!(relay.label().as_str(), "Pump"); + + relay.set_label(RelayLabel::new("Water Heater".to_string()).unwrap()); + assert_eq!(relay.label().as_str(), "Water Heater"); + + relay.set_label(RelayLabel::default()); + assert_eq!(relay.label().as_str(), "Unlabeled"); + } +} diff --git a/backend/src/domain/relay/types/relaystate.rs b/backend/src/domain/relay/types/relaystate.rs index 850620e..bcec036 100644 --- a/backend/src/domain/relay/types/relaystate.rs +++ b/backend/src/domain/relay/types/relaystate.rs @@ -36,6 +36,15 @@ impl From for RelayState { } } +impl std::fmt::Display for RelayState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::On => write!(f, "on"), + Self::Off => write!(f, "off"), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/presentation/dto/mod.rs b/backend/src/presentation/dto/mod.rs new file mode 100644 index 0000000..05d18d4 --- /dev/null +++ b/backend/src/presentation/dto/mod.rs @@ -0,0 +1,6 @@ +/// Relay-specific Data Transfer Objects. +/// +/// This module contains DTO structures for relay-related API responses, +/// providing serialized representations of relay domain objects for +/// external consumption. +pub mod relay_dto; diff --git a/backend/src/presentation/dto/relay_dto.rs b/backend/src/presentation/dto/relay_dto.rs new file mode 100644 index 0000000..16f9336 --- /dev/null +++ b/backend/src/presentation/dto/relay_dto.rs @@ -0,0 +1,197 @@ +use poem_openapi::Object; +use serde::{Deserialize, Serialize}; + +use crate::domain::relay::Relay; + +/// Data Transfer Object for relay information. +/// +/// This struct represents a relay in a serialized format suitable for API +/// responses. It contains the relay's ID, current state, and label in a +/// format that can be easily serialized to JSON. +#[derive(Object, Serialize, Deserialize)] +pub struct RelayDto { + /// The relay's unique identifier (1-8). + id: u8, + /// The relay's current state as a string ("on" or "off"). + state: String, + /// The relay's user-friendly label. + label: String, +} + +impl From for RelayDto { + /// Converts a domain Relay object to a `RelayDto`. + /// + /// This conversion extracts the relay's ID, state, and label from the + /// domain object and formats them for API consumption. + /// + /// # Arguments + /// + /// * `value` - The Relay domain object to convert + /// + /// # Returns + /// + /// A `RelayDto` containing the relay's data in serialized format + fn from(value: Relay) -> Self { + let id = value.id().as_u8(); + let state = value.state().to_string(); + let label = value.label().to_string(); + Self { id, state, label } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::relay::types::{RelayId, RelayLabel, RelayState}; + + #[test] + fn test_relay_dto_from_relay_with_default_label() { + // Test: Relay with default label converts to RelayDto with None label + let relay_id = RelayId::new(1).unwrap(); + let relay = Relay::new(relay_id); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 1); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "Unlabeled".to_string()); + } + + #[test] + fn test_relay_dto_from_relay_with_custom_label() { + // Test: Relay with custom label converts to RelayDto with Some(label) + let relay_id = RelayId::new(2).unwrap(); + let label = RelayLabel::new("Water Pump".to_string()).unwrap(); + let relay = Relay::with_label(relay_id, RelayState::On, label); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 2); + assert_eq!(dto.state, "on"); + assert_eq!(dto.label, "Water Pump".to_string()); + } + + #[test] + fn test_relay_dto_from_relay_with_on_state() { + // Test: Relay with On state converts to RelayDto with "on" state + let relay_id = RelayId::new(3).unwrap(); + let relay = Relay::with_state(relay_id, RelayState::On); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 3); + assert_eq!(dto.state, "on"); + assert_eq!(dto.label, "Unlabeled".to_string()); + } + + #[test] + fn test_relay_dto_from_relay_with_off_state() { + // Test: Relay with Off state converts to RelayDto with "off" state + let relay_id = RelayId::new(4).unwrap(); + let relay = Relay::with_state(relay_id, RelayState::Off); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 4); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "Unlabeled".to_string()); + } + + #[test] + fn test_relay_dto_from_relay_with_max_length_label() { + // Test: Relay with maximum length label (50 chars) converts correctly + let relay_id = RelayId::new(5).unwrap(); + let max_label = RelayLabel::new("A".repeat(50)).unwrap(); + let relay = Relay::with_label(relay_id, RelayState::Off, max_label); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 5); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "A".repeat(50)); + } + + #[test] + fn test_relay_dto_from_relay_with_empty_label_becomes_none() { + let relay_id = RelayId::new(6).unwrap(); + let relay = Relay::new(relay_id); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, 6); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "Unlabeled".to_string()); + } + + #[test] + fn test_relay_dto_serialization() { + // Test: RelayDto can be serialized to JSON + let relay_id = RelayId::new(7).unwrap(); + let label = RelayLabel::new("Test Relay".to_string()).unwrap(); + let relay = Relay::with_label(relay_id, RelayState::On, label); + let dto = RelayDto::from(relay); + + let json = serde_json::to_string(&dto).unwrap(); + assert_eq!( + json, + r#"{"id":7,"state":"on","label":"Test Relay"}"# + ); + } + + #[test] + fn test_relay_dto_deserialization() { + // Test: RelayDto can be deserialized from JSON + let json = r#"{"id":8,"state":"off","label":"Another Relay"}"#; + let dto: RelayDto = serde_json::from_str(json).unwrap(); + + assert_eq!(dto.id, 8); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "Another Relay".to_string()); + } + + #[test] + fn test_relay_dto_all_valid_relay_ids() { + // Test: All valid relay IDs (1-8) convert correctly + for id_val in 1..=8 { + let relay_id = RelayId::new(id_val).unwrap(); + let relay = Relay::new(relay_id); + let dto = RelayDto::from(relay); + + assert_eq!(dto.id, id_val); + assert_eq!(dto.state, "off"); + assert_eq!(dto.label, "Unlabeled".to_string()); + } + } + + #[test] + fn test_relay_dto_state_toggle_reflected() { + // Test: Relay state changes are reflected in DTO + let relay_id = RelayId::new(1).unwrap(); + let mut relay = Relay::with_state(relay_id, RelayState::Off); + + // Initial state + let dto1 = RelayDto::from(relay.clone()); + assert_eq!(dto1.state, "off"); + + // After toggle + relay.toggle(); + let dto2 = RelayDto::from(relay.clone()); + assert_eq!(dto2.state, "on"); + + // After another toggle + relay.toggle(); + let dto3 = RelayDto::from(relay); + assert_eq!(dto3.state, "off"); + } + + #[test] + fn test_relay_dto_label_change_reflected() { + // Test: Relay label changes are reflected in DTO + let relay_id = RelayId::new(2).unwrap(); + let mut relay = Relay::new(relay_id); + + // Initial label (default) + let dto1 = RelayDto::from(relay.clone()); + assert_eq!(dto1.label, "Unlabeled".to_string()); + + // After setting custom label + let new_label = RelayLabel::new("Custom Label".to_string()).unwrap(); + relay.set_label(new_label); + let dto2 = RelayDto::from(relay); + assert_eq!(dto2.label, "Custom Label".to_string()); + } +} diff --git a/backend/src/presentation/error.rs b/backend/src/presentation/error.rs new file mode 100644 index 0000000..6a07258 --- /dev/null +++ b/backend/src/presentation/error.rs @@ -0,0 +1,209 @@ +//! API error types for the presentation layer. +//! +//! Defines [`ApiError`], the single error type returned by all API handlers. +//! Each variant maps to an appropriate HTTP status code via [`poem::error::ResponseError`]. + +use poem::{error::ResponseError, http::StatusCode}; + +use crate::{application::use_cases::{get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError}, domain::relay::{controller::ControllerError, repository::RepositoryError, types::RelayLabelError}}; + +/// Unified error type for all API handlers. +/// +/// Variants cover every failure mode that can reach the presentation layer and +/// map each one to a semantically appropriate HTTP status code. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + /// Relay ID is outside the valid range 1-8, error 404 + #[error("Relay not found: ID {0} is outside the valid range (1-8)")] + RelayNotFound(u8), + /// Input validation failed (e.g. empty or too long label), error 400 + #[error("Bad request: {0}")] + BadRequest(String), + /// Hardware controller failure, error 503 or 504 + #[error("Controller error: {0}")] + ControllerError(#[from] ControllerError), + /// Database / repository failure, error 500 + #[error("Repository error: {0}")] + RepositoryError(#[from] RepositoryError), +} + +impl ResponseError for ApiError { + fn status(&self) -> poem::http::StatusCode { + match self { + Self::RelayNotFound(_) => StatusCode::NOT_FOUND, + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::ControllerError(e) => match e { + ControllerError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT, + ControllerError::ConnectionError(_) | ControllerError::ModbusException(_) => { + StatusCode::SERVICE_UNAVAILABLE + } + // InvalidRelayId and InvalidInput are programmer errors at this layer + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + Self::RepositoryError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ApiError { + fn from(value: RelayLabelError) -> Self { + Self::BadRequest(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: GetAllRelaysError) -> Self { + match value { + GetAllRelaysError::Controller(e) => Self::ControllerError(e), + GetAllRelaysError::Repository(e) => Self::RepositoryError(e), + } + } +} + +impl From for ApiError { + fn from(value: ToggleRelayError) -> Self { + match value { + ToggleRelayError::Controller(e) => Self::ControllerError(e), + ToggleRelayError::Repository(e) => Self::RepositoryError(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use poem::error::ResponseError; + use poem::http::StatusCode; + + use crate::{ + application::use_cases::{ + get_all_relays::GetAllRelaysError, + toggle_relay::ToggleRelayError, + }, + domain::relay::{ + controller::ControllerError, + repository::RepositoryError, + types::{RelayId, RelayLabelError}, + }, + }; + + // --- Status code mapping --- + + #[test] + fn test_relay_not_found_returns_404() { + let error = ApiError::RelayNotFound(9); + assert_eq!(error.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn test_bad_request_returns_400() { + let error = ApiError::BadRequest("invalid input".to_string()); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn test_controller_timeout_returns_504() { + let error = ApiError::ControllerError(ControllerError::Timeout(5)); + assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT); + } + + #[test] + fn test_controller_connection_error_returns_503() { + let error = ApiError::ControllerError(ControllerError::ConnectionError("refused".to_string())); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn test_controller_modbus_exception_returns_503() { + let error = ApiError::ControllerError(ControllerError::ModbusException("illegal function".to_string())); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn test_controller_invalid_relay_id_returns_500() { + let error = ApiError::ControllerError(ControllerError::InvalidRelayId(9)); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn test_controller_invalid_input_returns_500() { + let error = ApiError::ControllerError(ControllerError::InvalidInput("bad input".to_string())); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn test_repository_error_returns_500() { + let error = ApiError::RepositoryError(RepositoryError::DatabaseError("db failed".to_string())); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + // --- From --- + + #[test] + fn test_from_relay_label_error_empty_produces_bad_request() { + let api_error = ApiError::from(RelayLabelError::Empty); + assert!(matches!(api_error, ApiError::BadRequest(_))); + } + + #[test] + fn test_from_relay_label_error_too_long_produces_bad_request() { + let api_error = ApiError::from(RelayLabelError::TooLong(51)); + assert!(matches!(api_error, ApiError::BadRequest(_))); + } + + // --- From --- + + #[test] + fn test_from_get_all_relays_controller_error_produces_controller_error() { + let source = GetAllRelaysError::Controller(ControllerError::Timeout(5)); + let api_error = ApiError::from(source); + assert!(matches!(api_error, ApiError::ControllerError(_))); + } + + #[test] + fn test_from_get_all_relays_repository_error_produces_repository_error() { + let source = GetAllRelaysError::Repository(RepositoryError::DatabaseError("err".to_string())); + let api_error = ApiError::from(source); + assert!(matches!(api_error, ApiError::RepositoryError(_))); + } + + // --- From --- + + #[test] + fn test_from_toggle_relay_controller_error_produces_controller_error() { + let source = ToggleRelayError::Controller(ControllerError::Timeout(5)); + let api_error = ApiError::from(source); + assert!(matches!(api_error, ApiError::ControllerError(_))); + } + + #[test] + fn test_from_toggle_relay_repository_error_produces_repository_error() { + let relay_id = RelayId::new(1).unwrap(); + let source = ToggleRelayError::Repository(RepositoryError::NotFound(relay_id)); + let api_error = ApiError::from(source); + assert!(matches!(api_error, ApiError::RepositoryError(_))); + } + + // --- Error messages --- + + #[test] + fn test_relay_not_found_error_message() { + let error = ApiError::RelayNotFound(5); + assert_eq!( + error.to_string(), + "Relay not found: ID 5 is outside the valid range (1-8)" + ); + } + + #[test] + fn test_bad_request_error_message() { + let error = ApiError::BadRequest("invalid label".to_string()); + assert_eq!(error.to_string(), "Bad request: invalid label"); + } + + #[test] + fn test_relay_label_error_message_preserved_in_bad_request() { + let api_error = ApiError::from(RelayLabelError::Empty); + assert_eq!(api_error.to_string(), "Bad request: Label cannot be empty"); + } +} diff --git a/backend/src/presentation/mod.rs b/backend/src/presentation/mod.rs index f6d467f..cf0436f 100644 --- a/backend/src/presentation/mod.rs +++ b/backend/src/presentation/mod.rs @@ -94,3 +94,12 @@ //! - Architecture: `specs/constitution.md` - API-First Design principle //! - API design: `specs/001-modbus-relay-control/plan.md` - Presentation layer tasks //! - Domain types: [`crate::domain`] - Types to be wrapped in DTOs + +/// Data Transfer Objects (DTOs) for API responses. +/// +/// This module contains DTO structures that are used to serialize domain +/// objects for API responses, providing a clean separation between internal +/// domain models and external API contracts. +pub mod dto; + +pub mod error; diff --git a/backend/src/startup.rs b/backend/src/startup.rs index 328b52c..faccf43 100644 --- a/backend/src/startup.rs +++ b/backend/src/startup.rs @@ -250,4 +250,171 @@ mod tests { // 2. The From trait is correctly implemented // 3. The middleware chain accepts the CORS configuration } + + // ============================================================================ + // T039c: Dependency Injection Tests + // ============================================================================ + // These tests verify that Application::build() correctly wires dependencies + // with graceful degradation and test mode detection. + + // T039c: Test 1 - Application::build() succeeds in test mode + #[test] + fn test_application_build_succeeds_in_test_mode() { + // GIVEN: Settings configured for test mode + // When cfg!(test) is true, Application::build should use mock dependencies + let settings = create_test_settings(); + + // WHEN: Application::build() is called + let result = std::panic::catch_unwind(|| { + Application::build(settings, None) + }); + + // THEN: Should succeed without panicking + assert!( + result.is_ok(), + "Application::build() should succeed in test mode" + ); + + let app = result.unwrap(); + + // Verify the application is configured correctly + assert_eq!(app.port(), 8080); + assert_eq!(app.host(), "127.0.0.1"); + + // TODO (T039c implementation): After implementation, verify that: + // - Mock controller is used (not real Modbus hardware) + // - Mock label repository is used (not real SQLite) + // - Application can be converted to runnable state + } + + // T039c: Test 2 - Application::build() creates correct mock dependencies when CI=true + #[test] + fn test_application_build_uses_mock_dependencies_in_ci() { + // GIVEN: CI environment variable is set + // SAFETY: This test modifies environment variables, which is inherently unsafe + // in a multi-threaded context. However, this is acceptable in tests because: + // 1. Cargo runs tests in parallel by default, but each test gets its own process + // 2. The cleanup happens immediately after use + // 3. This is a controlled test environment + unsafe { + std::env::set_var("CI", "true"); + } + + let settings = create_test_settings(); + + // WHEN: Application::build() is called + let result = std::panic::catch_unwind(|| { + Application::build(settings, None) + }); + + // Clean up environment variable + // SAFETY: Same rationale as set_var above + unsafe { + std::env::remove_var("CI"); + } + + // THEN: Should succeed and use mock dependencies + assert!( + result.is_ok(), + "Application::build() should succeed in CI environment" + ); + + let app = result.unwrap(); + + // Verify the application is configured + assert_eq!(app.port(), 8080); + + // TODO (T039c implementation): After implementation, verify that: + // - Mock dependencies are used when CI=true + // - No real hardware connection is attempted + // - Application works without Modbus device or SQLite database + } + + // T039c: Test 3 - Application::build() creates real dependencies when not in test mode + #[test] + #[ignore] // This test requires real Modbus hardware and should be run manually + fn test_application_build_uses_real_dependencies_in_production() { + // GIVEN: Production settings with real Modbus device configuration + // This test is #[ignore] because it requires actual hardware + let settings = create_test_settings(); + + // WHEN: Application::build() is called outside of test/CI environment + // (This would normally happen in production) + let result = std::panic::catch_unwind(|| { + Application::build(settings, None) + }); + + // THEN: Should attempt to create real dependencies + // In test environment, this will still use mocks due to cfg!(test) + // This test serves as documentation of the expected production behavior + assert!( + result.is_ok(), + "Application::build() should handle dependency creation" + ); + + // TODO (T039c implementation): After implementation, verify that: + // - Real ModbusRelayController is created when hardware is available + // - Real SqliteRelayLabelRepository is created + // - Graceful fallback to mock if hardware connection fails (FR-023) + } + + // ============================================================================ + // T039d: RelayApi Registration Tests + // ============================================================================ + // These tests verify that the RelayApi is properly registered in the route + // aggregator with correct OpenAPI tagging. + + // T039d: Test 1 - OpenAPI spec includes /api/relays endpoints + #[test] + fn test_openapi_spec_includes_relay_endpoints() { + // GIVEN: An application with all routes configured + let settings = create_test_settings(); + let app = Application::build(settings, None); + let _runnable_app = app.make_app(); + + // WHEN: The application is built and routes are set up + // (OpenAPI service is created in setup_app) + + // THEN: OpenAPI spec should include relay endpoints + // TODO (T039d implementation): After implementation, verify that: + // - GET /api/relays endpoint exists in spec + // - POST /api/relays/{id}/toggle endpoint exists in spec + // - POST /api/relays/all/on endpoint exists in spec + // - POST /api/relays/all/off endpoint exists in spec + // - PUT /api/relays/{id}/label endpoint exists in spec + // + // This can be verified by: + // 1. Extracting the OpenAPI spec from the app + // 2. Parsing the spec JSON/YAML + // 3. Checking for the presence of these paths + + // For now, this test passes if the application builds successfully + // Full verification will be added during T039d implementation + } + + // T039d: Test 2 - Swagger UI renders Relays tag + #[test] + fn test_swagger_ui_includes_relays_tag() { + // GIVEN: An application with RelayApi registered + let settings = create_test_settings(); + let app = Application::build(settings, None); + let _runnable_app = app.make_app(); + + // WHEN: The application is built with OpenAPI service + + // THEN: Swagger UI should include "Relays" tag + // TODO (T039d implementation): After implementation, verify that: + // - OpenAPI spec includes a "Relays" tag + // - All relay endpoints are grouped under this tag + // - Tag has appropriate description + // + // This can be verified by: + // 1. Extracting the OpenAPI spec + // 2. Checking the "tags" section for "Relays" + // 3. Verifying relay endpoints reference this tag + + // For now, this test passes if the application builds successfully + // Full verification will be added during T039d implementation + } } + diff --git a/nix/shell.nix b/nix/shell.nix index 1e85b9b..57be22e 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -38,7 +38,7 @@ inputs.devenv.lib.mkShell { nodePackages.pnpm ]; - processes.run.exec = "bacon run"; + processes.backend-run.exec = "bacon run"; enterShell = '' echo "🦀 Rust MCP development environment loaded!" diff --git a/specs/001-modbus-relay-control/tasks.org b/specs/001-modbus-relay-control/tasks.org index c4d2e4b..042d67c 100644 --- a/specs/001-modbus-relay-control/tasks.org +++ b/specs/001-modbus-relay-control/tasks.org @@ -616,14 +616,16 @@ CLOSED: [2026-01-23 ven. 20:42] - *File*: =src/application/use_cases/get_all_relays.rs= - *Complexity*: Low | *Uncertainty*: Low -*** STARTED Presentation Layer (Backend API) [0/2] +*** DONE Presentation Layer (Backend API) [2/2] +CLOSED: [2026-03-01 dim. 11:07] +- State "DONE" from "STARTED" [2026-03-01 dim. 11:07] - State "STARTED" from "TODO" [2026-01-23 ven. 20:42] -- [ ] *T045* [US1] [TDD] Define =RelayDto= in presentation layer +- [X] *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= - *Complexity*: Low | *Uncertainty*: Low -- [ ] *T046* [US1] [TDD] Define API error responses +- [X] *T046* [US1] [TDD] Define API error responses - =ApiError= enum with status codes and messages - Implement =poem::error::ResponseError= - *File*: =src/presentation/error.rs=