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

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