feat(domain,presentation,tests): implement Relay entity, DTOs, and API errors

- Add Relay entity with constructors and business logic methods
- Add RelayDto for API responses with From<Relay> conversion
- Add ApiError with ResponseError trait for unified error handling
- Add dependency injection tests for mock infrastructure in test mode
This commit is contained in:
2026-01-23 20:46:48 +01:00
parent aae25ea7e1
commit 0b7636c80c
9 changed files with 1011 additions and 4 deletions

View File

@@ -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");
}
}

View File

@@ -36,6 +36,15 @@ impl From<bool> 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::*;

View File

@@ -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;

View File

@@ -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<Relay> 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());
}
}

View File

@@ -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<RelayLabelError> for ApiError {
fn from(value: RelayLabelError) -> Self {
Self::BadRequest(value.to_string())
}
}
impl From<GetAllRelaysError> for ApiError {
fn from(value: GetAllRelaysError) -> Self {
match value {
GetAllRelaysError::Controller(e) => Self::ControllerError(e),
GetAllRelaysError::Repository(e) => Self::RepositoryError(e),
}
}
}
impl From<ToggleRelayError> 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<RelayLabelError> ---
#[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<GetAllRelaysError> ---
#[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<ToggleRelayError> ---
#[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");
}
}

View File

@@ -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;

View File

@@ -250,4 +250,171 @@ mod tests {
// 2. The From<CorsSettings> 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
}
}

View File

@@ -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!"

View File

@@ -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=