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:
@@ -3,6 +3,8 @@
|
|||||||
//! This module contains the core domain logic for relay control and management,
|
//! This module contains the core domain logic for relay control and management,
|
||||||
//! including relay types, repository abstractions, and business rules.
|
//! including relay types, repository abstractions, and business rules.
|
||||||
|
|
||||||
|
use types::{RelayId, RelayLabel, RelayState};
|
||||||
|
|
||||||
/// Controller error types for relay operations.
|
/// Controller error types for relay operations.
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
/// Relay entity representing the relay aggregate.
|
/// Relay entity representing the relay aggregate.
|
||||||
@@ -11,3 +13,409 @@ pub mod entity;
|
|||||||
pub mod repository;
|
pub mod repository;
|
||||||
/// Domain types for relay identification and control.
|
/// Domain types for relay identification and control.
|
||||||
pub mod types;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
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
|
//! - Architecture: `specs/constitution.md` - API-First Design principle
|
||||||
//! - API design: `specs/001-modbus-relay-control/plan.md` - Presentation layer tasks
|
//! - API design: `specs/001-modbus-relay-control/plan.md` - Presentation layer tasks
|
||||||
//! - Domain types: [`crate::domain`] - Types to be wrapped in DTOs
|
//! - 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;
|
||||||
|
|||||||
@@ -250,4 +250,171 @@ mod tests {
|
|||||||
// 2. The From<CorsSettings> trait is correctly implemented
|
// 2. The From<CorsSettings> trait is correctly implemented
|
||||||
// 3. The middleware chain accepts the CORS configuration
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ inputs.devenv.lib.mkShell {
|
|||||||
nodePackages.pnpm
|
nodePackages.pnpm
|
||||||
];
|
];
|
||||||
|
|
||||||
processes.run.exec = "bacon run";
|
processes.backend-run.exec = "bacon run";
|
||||||
|
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
echo "🦀 Rust MCP development environment loaded!"
|
echo "🦀 Rust MCP development environment loaded!"
|
||||||
|
|||||||
@@ -616,14 +616,16 @@ CLOSED: [2026-01-23 ven. 20:42]
|
|||||||
- *File*: =src/application/use_cases/get_all_relays.rs=
|
- *File*: =src/application/use_cases/get_all_relays.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *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]
|
- 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=)
|
- Fields: =id= (=u8=), =state= ("on"/"off"), =label= (=Option=)
|
||||||
- Implement =From= for =RelayDto=
|
- Implement =From= for =RelayDto=
|
||||||
- *File*: =src/presentation/dto/relay_dto.rs=
|
- *File*: =src/presentation/dto/relay_dto.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *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
|
- =ApiError= enum with status codes and messages
|
||||||
- Implement =poem::error::ResponseError=
|
- Implement =poem::error::ResponseError=
|
||||||
- *File*: =src/presentation/error.rs=
|
- *File*: =src/presentation/error.rs=
|
||||||
|
|||||||
Reference in New Issue
Block a user