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:
6
backend/src/presentation/dto/mod.rs
Normal file
6
backend/src/presentation/dto/mod.rs
Normal 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;
|
||||
197
backend/src/presentation/dto/relay_dto.rs
Normal file
197
backend/src/presentation/dto/relay_dto.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
209
backend/src/presentation/error.rs
Normal file
209
backend/src/presentation/error.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user