Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a965848076
|
|||
|
7ce35da1ce
|
|||
|
27cfeb3b77
|
|||
|
f726f4185a
|
|||
|
ce186095fa
|
|||
|
1cb4d5f3fc
|
|||
|
8c1d5433de
|
-26
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT * FROM RelayLabels ORDER BY relay_id",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "relay_id",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "label",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 0
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "117e7029e31f9283bbed6b5b3df23c4cdc025b9f7f14a392d63a99e8caef65cb"
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT * FROM RelayLabels WHERE relay_id = ?1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "relay_id",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "label",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "15738a0f943596d60a342c973435b94e1b7dc3199ad9fb400db6db349141b560"
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT OR REPLACE INTO RelayLabels (relay_id, label) VALUES (?1, ?2)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 2
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "50a914fac9783ac8afb0305f6225680017d32a0dd95932ddb736d7df3ca31550"
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "DELETE FROM RelayLabels WHERE relay_id = ?1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "720b84ab40bf4395727575b3cd6c25eff9198526705208ecdb25773f5cc80316"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
IMPORTANT: Ensure you’ve thoroughly reviewed the [AGENTS.md](/AGENTS.md) file before beginning any work.
|
||||||
@@ -5,8 +5,6 @@ edition = "2024"
|
|||||||
publish = false
|
publish = false
|
||||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
description = "Backend for STA, communicating with the physical relay"
|
|
||||||
homepage = "https://labs.phundrak.com/phundrak/sta"
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
@@ -37,9 +35,5 @@ tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.15.0"
|
tempfile = "3.15.0"
|
||||||
|
|
||||||
[[test]]
|
|
||||||
name = "relay_api_contract"
|
|
||||||
path = "tests/contract/test_relay_api.rs"
|
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
||||||
|
|||||||
@@ -265,15 +265,9 @@ mod tests {
|
|||||||
for (index, relay) in result.iter().enumerate() {
|
for (index, relay) in result.iter().enumerate() {
|
||||||
let relay_num = index + 1;
|
let relay_num = index + 1;
|
||||||
if relay_num % 2 == 1 {
|
if relay_num % 2 == 1 {
|
||||||
assert!(
|
assert!(relay.label().is_some(), "Relay {relay_num} should have label");
|
||||||
relay.label().is_some(),
|
|
||||||
"Relay {relay_num} should have label"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
assert!(
|
assert!(relay.label().is_none(), "Relay {relay_num} should not have label");
|
||||||
relay.label().is_none(),
|
|
||||||
"Relay {relay_num} should not have label"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
//! 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.
|
||||||
@@ -13,405 +11,3 @@ 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,15 +36,6 @@ 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::*;
|
||||||
|
|||||||
@@ -44,23 +44,19 @@ impl ModbusRelayController {
|
|||||||
/// - The host/port address is invalid
|
/// - The host/port address is invalid
|
||||||
/// - Connection to the Modbus device fails
|
/// - Connection to the Modbus device fails
|
||||||
/// - The device is unreachable
|
/// - The device is unreachable
|
||||||
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u8) -> Result<Self> {
|
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) -> Result<Self> {
|
||||||
if slave_id != 1 {
|
if slave_id != 1 {
|
||||||
tracing::warn!("Device typically uses slave_id=1, got {slave_id}");
|
tracing::warn!("Device typically uses slave_id=1, got {slave_id}");
|
||||||
}
|
}
|
||||||
let socket_addr = format!("{host}:{port}")
|
let socket_addr = format!("{host}:{port}")
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {e}")))?;
|
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {e}")))?;
|
||||||
let ctx = timeout(
|
let ctx = tcp::connect_slave(socket_addr, Slave(slave_id))
|
||||||
Duration::from_secs(timeout_secs.into()),
|
.await
|
||||||
tcp::connect_slave(socket_addr, Slave(slave_id)),
|
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ControllerError::Timeout(timeout_secs.into()))?
|
|
||||||
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
ctx: Arc::new(Mutex::new(ctx)),
|
ctx: Arc::new(Mutex::new(ctx)),
|
||||||
timeout_duration: Duration::from_secs(timeout_secs.into()),
|
timeout_duration: Duration::from_secs(timeout_secs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
//! Factory module for creating relay controller instances.
|
|
||||||
//!
|
|
||||||
//! This module provides factory functions for creating relay controllers
|
|
||||||
//! with graceful degradation and retry logic.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::domain::relay::controller::RelayController;
|
|
||||||
use crate::settings::ModbusSettings;
|
|
||||||
|
|
||||||
use super::client::ModbusRelayController;
|
|
||||||
use super::mock_controller::MockRelayController;
|
|
||||||
|
|
||||||
/// Creates a relay controller with retry and fallback logic.
|
|
||||||
///
|
|
||||||
/// # Parameters
|
|
||||||
///
|
|
||||||
/// - `settings`: Modbus connection configuration
|
|
||||||
/// - `use_mock`: If true, returns `MockRelayController` immediately without attempting real connection
|
|
||||||
///
|
|
||||||
/// # Behavior
|
|
||||||
///
|
|
||||||
/// 1. If `use_mock` is true, returns `MockRelayController` immediately
|
|
||||||
/// 2. Otherwise, attempts to connect to real Modbus hardware with:
|
|
||||||
/// - 3 retry attempts
|
|
||||||
/// - 2 second backoff between retries
|
|
||||||
/// 3. If all retries fail, falls back to `MockRelayController` (graceful degradation per FR-023)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// An `Arc<dyn RelayController>` that can be either:
|
|
||||||
/// - `MockRelayController` (for testing or when hardware connection fails)
|
|
||||||
/// - `ModbusRelayController` (for real hardware communication)
|
|
||||||
pub async fn create_relay_controller(
|
|
||||||
settings: &ModbusSettings,
|
|
||||||
use_mock: bool,
|
|
||||||
) -> Arc<dyn RelayController> {
|
|
||||||
if use_mock {
|
|
||||||
tracing::info!("Using MockRelayController (test mode)");
|
|
||||||
return Arc::new(MockRelayController::new());
|
|
||||||
}
|
|
||||||
for attempt in 1..=3 {
|
|
||||||
match ModbusRelayController::new(
|
|
||||||
&settings.host,
|
|
||||||
settings.port,
|
|
||||||
settings.slave_id,
|
|
||||||
settings.timeout_secs,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(controller) => {
|
|
||||||
tracing::info!("Connected to Modbus device on attempt {}", attempt);
|
|
||||||
return Arc::new(controller);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(attempt, error = %e, "Failed to connect to Modbus device");
|
|
||||||
if attempt < 3 {
|
|
||||||
tracing::warn!("Retrying in two seconds...");
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::error!("Could not connect to Modbus device after three attempts");
|
|
||||||
tracing::error!("Using MockRelayController as fallback");
|
|
||||||
tracing::error!("STA will NOT be controlling a real device!");
|
|
||||||
Arc::new(MockRelayController::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::domain::relay::types::RelayId;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
// Helper to create test settings
|
|
||||||
fn create_test_settings() -> ModbusSettings {
|
|
||||||
ModbusSettings {
|
|
||||||
host: "192.168.0.200".to_string(),
|
|
||||||
port: 502,
|
|
||||||
slave_id: 0,
|
|
||||||
timeout_secs: 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// T039a: Test 1 - use_mock=true returns MockRelayController immediately
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_relay_controller_with_mock_flag_returns_mock_immediately() {
|
|
||||||
// GIVEN: Settings and use_mock=true
|
|
||||||
let settings = create_test_settings();
|
|
||||||
|
|
||||||
// WHEN: create_relay_controller is called with use_mock=true
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let controller = create_relay_controller(&settings, true).await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
|
|
||||||
// THEN: Should return MockRelayController immediately (< 100ms)
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_millis(100),
|
|
||||||
"Mock controller should be created immediately without delay, took {elapsed:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify it's a mock by checking if we can downcast to MockRelayController
|
|
||||||
// This is a weak test - in reality we'd check the type more carefully
|
|
||||||
// For now we just verify we got a controller back
|
|
||||||
assert!(Arc::strong_count(&controller) > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// T039a: Test 2 - Successful connection returns ModbusRelayController
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "Requires real Modbus hardware"]
|
|
||||||
async fn test_create_relay_controller_successful_connection() {
|
|
||||||
// GIVEN: Valid settings for a real Modbus device
|
|
||||||
let settings = create_test_settings();
|
|
||||||
|
|
||||||
// WHEN: create_relay_controller is called with use_mock=false
|
|
||||||
let controller = create_relay_controller(&settings, false).await;
|
|
||||||
|
|
||||||
// THEN: Should return ModbusRelayController
|
|
||||||
// We verify by attempting a real operation
|
|
||||||
// Note: This test requires actual hardware and should be #[ignore]
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let result = controller.read_relay_state(relay_id).await;
|
|
||||||
|
|
||||||
// Should succeed if hardware is connected
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"Failed to read state from real hardware: {:?}",
|
|
||||||
result.err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_relay_controller_fallback_to_mock_after_retries() {
|
|
||||||
let settings = ModbusSettings {
|
|
||||||
host: "192.0.2.1".to_string(), // TEST-NET-1 (reserved, unreachable)
|
|
||||||
port: 502,
|
|
||||||
slave_id: 0,
|
|
||||||
timeout_secs: 1, // Short timeout for faster test
|
|
||||||
};
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let controller = create_relay_controller(&settings, false).await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
assert!(
|
|
||||||
elapsed >= Duration::from_secs(5),
|
|
||||||
"Should have retried 3 times with 2s delays, took {elapsed:?}",
|
|
||||||
);
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let result = controller.read_relay_state(relay_id).await;
|
|
||||||
assert!(
|
|
||||||
result.is_ok() || result.is_err(),
|
|
||||||
"Controller should be usable (mock or real)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_relay_controller_retry_delays() {
|
|
||||||
let settings = ModbusSettings {
|
|
||||||
host: "192.0.2.1".to_string(), // Unreachable address
|
|
||||||
port: 502,
|
|
||||||
slave_id: 0,
|
|
||||||
timeout_secs: 1,
|
|
||||||
};
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let _controller = create_relay_controller(&settings, false).await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
// Attempt 1 (1s timeout) + 2s delay + Attempt 2 (1s) + 2s delay + Attempt 3 (1s)
|
|
||||||
// = ~7 seconds minimum (allowing some variance)
|
|
||||||
assert!(
|
|
||||||
elapsed >= Duration::from_secs(7) && elapsed <= Duration::from_secs(15),
|
|
||||||
"Retry timing incorrect: expected ~7-15s, got {elapsed:?}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,5 @@
|
|||||||
|
|
||||||
/// Modbus TCP client for real hardware communication.
|
/// Modbus TCP client for real hardware communication.
|
||||||
pub mod client;
|
pub mod client;
|
||||||
/// Factory functions for creating relay controllers with retry and fallback logic.
|
|
||||||
pub mod factory;
|
|
||||||
/// Mock relay controller for testing without hardware.
|
/// Mock relay controller for testing without hardware.
|
||||||
pub mod mock_controller;
|
pub mod mock_controller;
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
//! Factory module for creating relay label repository instances.
|
|
||||||
//!
|
|
||||||
//! This module provides factory functions for creating relay label repositories
|
|
||||||
//! with appropriate implementations based on configuration.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::{domain::relay::repository::{RelayLabelRepository, RepositoryError}, infrastructure::persistence::label_repository::MockRelayLabelRepository};
|
|
||||||
|
|
||||||
use super::sqlite_repository::SqliteRelayLabelRepository;
|
|
||||||
|
|
||||||
/// Creates a relay label repository based on configuration.
|
|
||||||
///
|
|
||||||
/// # Parameters
|
|
||||||
///
|
|
||||||
/// - `db_path`: Path to ``SQLite`` database file (e.g., "relays.db" or ":memory:")
|
|
||||||
/// - `use_mock`: If true, returns `MockRelayLabelRepository` for testing
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// - `Ok(Arc<dyn RelayLabelRepository>)` on success
|
|
||||||
/// - `Err(RepositoryError)` if database connection fails or path is invalid
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `RepositoryError` if:
|
|
||||||
/// - Database path is invalid or inaccessible
|
|
||||||
/// - ``SQLite`` connection fails
|
|
||||||
/// - Database schema migration fails
|
|
||||||
pub async fn create_label_repository(
|
|
||||||
db_path: &str,
|
|
||||||
use_mock: bool,
|
|
||||||
) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> {
|
|
||||||
if use_mock {
|
|
||||||
tracing::info!("Using MockRelayLabelRepository (test mode)");
|
|
||||||
return Ok(Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
}
|
|
||||||
let repo = SqliteRelayLabelRepository::new(db_path).await?;
|
|
||||||
Ok(Arc::new(repo))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::relay::types::{RelayId, RelayLabel};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_label_repository_with_mock_flag() {
|
|
||||||
let db_path = ":memory:";
|
|
||||||
let result = create_label_repository(db_path, true).await;
|
|
||||||
assert!(result.is_ok(), "Failed to create mock repository");
|
|
||||||
let repository = result.unwrap();
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let label_result = repository.get_label(relay_id).await;
|
|
||||||
assert!(
|
|
||||||
label_result.is_ok(),
|
|
||||||
"Mock repository should be immediately usable"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
label_result.unwrap(),
|
|
||||||
None,
|
|
||||||
"Mock repository should start with no labels"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_label_repository_with_sqlite() {
|
|
||||||
let db_path = ":memory:";
|
|
||||||
let result = create_label_repository(db_path, false).await;
|
|
||||||
assert!(result.is_ok(), "Failed to create SQLite repository");
|
|
||||||
let repository = result.unwrap();
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let label = RelayLabel::new("Pump".to_string()).unwrap();
|
|
||||||
let save_result = repository.save_label(relay_id, label.clone()).await;
|
|
||||||
assert!(
|
|
||||||
save_result.is_ok(),
|
|
||||||
"Failed to save label on SQLite repository"
|
|
||||||
);
|
|
||||||
let get_result = repository.get_label(relay_id).await;
|
|
||||||
assert!(get_result.is_ok(), "Failed to get label");
|
|
||||||
assert_eq!(get_result.unwrap(), Some(label));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_label_repository_with_invalid_path() {
|
|
||||||
let db_path = "/nonexistent/directory/impossible/path/relays.db";
|
|
||||||
let result = create_label_repository(db_path, false).await;
|
|
||||||
assert!(result.is_err(), "Should fail with invalid database path");
|
|
||||||
if let Err(error) = result {
|
|
||||||
#[allow(clippy::match_wildcard_for_single_variants)]
|
|
||||||
match error {
|
|
||||||
RepositoryError::DatabaseError(_) => {
|
|
||||||
// Expected error type - test passes
|
|
||||||
}
|
|
||||||
_ => panic!("Expected DatabaseError for invalid path"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_mock_and_sqlite_repositories_are_independent() {
|
|
||||||
let mock_repo = create_label_repository(":memory:", true).await.unwrap();
|
|
||||||
let sqlite_repo = create_label_repository(":memory:", false).await.unwrap();
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let label = RelayLabel::new("Test".to_string()).unwrap();
|
|
||||||
mock_repo.save_label(relay_id, label.clone()).await.unwrap();
|
|
||||||
let sqlite_result = sqlite_repo.get_label(relay_id).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
sqlite_result, None,
|
|
||||||
"SQLite repository should be independent from mock"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_in_memory_sqlite_does_not_persist() {
|
|
||||||
let relay_id = RelayId::new(1).unwrap();
|
|
||||||
let label = RelayLabel::new("Temporary".to_string()).unwrap();
|
|
||||||
{
|
|
||||||
let repo = create_label_repository(":memory:", false).await.unwrap();
|
|
||||||
repo.save_label(relay_id, label.clone()).await.unwrap();
|
|
||||||
} // repo is dropped here
|
|
||||||
let new_repo = create_label_repository(":memory:", false).await.unwrap();
|
|
||||||
let result = new_repo.get_label(relay_id).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
result, None,
|
|
||||||
"In-memory database should not persist across instances"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,17 +12,22 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod relay_label_repository_contract_tests {
|
mod relay_label_repository_contract_tests {
|
||||||
use crate::{
|
use crate::domain::relay::{
|
||||||
domain::relay::{
|
repository::RelayLabelRepository,
|
||||||
repository::RelayLabelRepository,
|
types::{RelayId, RelayLabel},
|
||||||
types::{RelayId, RelayLabel},
|
|
||||||
},
|
|
||||||
infrastructure::persistence::label_repository::MockRelayLabelRepository,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
// =========================================================================
|
||||||
pub async fn test_get_label_returns_none_for_non_existent_relay() {
|
// get_label() Tests
|
||||||
let repo = MockRelayLabelRepository::new();
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Test: `get_label` returns None for non-existent relay
|
||||||
|
///
|
||||||
|
/// Verifies that querying a relay ID that has no label returns None
|
||||||
|
/// rather than an error.
|
||||||
|
pub async fn test_get_label_returns_none_for_non_existent_relay<R: RelayLabelRepository>(
|
||||||
|
repo: &R,
|
||||||
|
) {
|
||||||
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
||||||
|
|
||||||
let result = repo.get_label(relay_id).await;
|
let result = repo.get_label(relay_id).await;
|
||||||
@@ -34,16 +39,19 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `get_label` retrieves previously saved label
|
||||||
pub async fn test_get_label_retrieves_saved_label() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that after saving a label, `get_label` returns the same label.
|
||||||
|
pub async fn test_get_label_retrieves_saved_label<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(2).expect("Valid relay ID");
|
let relay_id = RelayId::new(2).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
let label = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save the label
|
||||||
repo.save_label(relay_id, label.clone())
|
repo.save_label(relay_id, label.clone())
|
||||||
.await
|
.await
|
||||||
.expect("save_label should succeed");
|
.expect("save_label should succeed");
|
||||||
|
|
||||||
|
// Retrieve the label
|
||||||
let result = repo.get_label(relay_id).await;
|
let result = repo.get_label(relay_id).await;
|
||||||
|
|
||||||
assert!(result.is_ok(), "get_label should succeed");
|
assert!(result.is_ok(), "get_label should succeed");
|
||||||
@@ -56,12 +64,14 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `get_label` returns None after label is deleted
|
||||||
pub async fn test_get_label_returns_none_after_delete() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that after deleting a label, `get_label` returns None.
|
||||||
|
pub async fn test_get_label_returns_none_after_delete<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(3).expect("Valid relay ID");
|
let relay_id = RelayId::new(3).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new("ToBeDeleted".to_string()).expect("Valid label");
|
let label = RelayLabel::new("ToBeDeleted".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save and then delete the label
|
||||||
repo.save_label(relay_id, label)
|
repo.save_label(relay_id, label)
|
||||||
.await
|
.await
|
||||||
.expect("save_label should succeed");
|
.expect("save_label should succeed");
|
||||||
@@ -69,6 +79,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("delete_label should succeed");
|
.expect("delete_label should succeed");
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
let result = repo.get_label(relay_id).await;
|
let result = repo.get_label(relay_id).await;
|
||||||
assert!(result.is_ok(), "get_label should succeed");
|
assert!(result.is_ok(), "get_label should succeed");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -77,9 +88,14 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// =========================================================================
|
||||||
pub async fn test_save_label_succeeds() {
|
// save_label() Tests
|
||||||
let repo = MockRelayLabelRepository::new();
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Test: `save_label` successfully saves a label
|
||||||
|
///
|
||||||
|
/// Verifies that `save_label` returns Ok and stores the label.
|
||||||
|
pub async fn test_save_label_succeeds<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
let relay_id = RelayId::new(1).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new("Pump".to_string()).expect("Valid label");
|
let label = RelayLabel::new("Pump".to_string()).expect("Valid label");
|
||||||
|
|
||||||
@@ -88,21 +104,26 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert!(result.is_ok(), "save_label should succeed");
|
assert!(result.is_ok(), "save_label should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `save_label` overwrites existing label
|
||||||
pub async fn test_save_label_overwrites_existing_label() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that calling `save_label` multiple times for the same relay ID
|
||||||
|
/// replaces the old label with the new one.
|
||||||
|
pub async fn test_save_label_overwrites_existing_label<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(4).expect("Valid relay ID");
|
let relay_id = RelayId::new(4).expect("Valid relay ID");
|
||||||
let label1 = RelayLabel::new("First".to_string()).expect("Valid label");
|
let label1 = RelayLabel::new("First".to_string()).expect("Valid label");
|
||||||
let label2 = RelayLabel::new("Second".to_string()).expect("Valid label");
|
let label2 = RelayLabel::new("Second".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save first label
|
||||||
repo.save_label(relay_id, label1)
|
repo.save_label(relay_id, label1)
|
||||||
.await
|
.await
|
||||||
.expect("First save should succeed");
|
.expect("First save should succeed");
|
||||||
|
|
||||||
|
// Overwrite with second label
|
||||||
repo.save_label(relay_id, label2)
|
repo.save_label(relay_id, label2)
|
||||||
.await
|
.await
|
||||||
.expect("Second save should succeed");
|
.expect("Second save should succeed");
|
||||||
|
|
||||||
|
// Verify only the second label is present
|
||||||
let result = repo
|
let result = repo
|
||||||
.get_label(relay_id)
|
.get_label(relay_id)
|
||||||
.await
|
.await
|
||||||
@@ -115,9 +136,10 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `save_label` works for all valid relay IDs (1-8)
|
||||||
pub async fn test_save_label_for_all_valid_relay_ids() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that all relay IDs in the valid range can have labels saved.
|
||||||
|
pub async fn test_save_label_for_all_valid_relay_ids<R: RelayLabelRepository>(repo: &R) {
|
||||||
for id in 1..=8 {
|
for id in 1..=8 {
|
||||||
let relay_id = RelayId::new(id).expect("Valid relay ID");
|
let relay_id = RelayId::new(id).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new(format!("Relay {id}")).expect("Valid label");
|
let label = RelayLabel::new(format!("Relay {id}")).expect("Valid label");
|
||||||
@@ -129,6 +151,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify all labels were saved
|
||||||
let all_labels = repo
|
let all_labels = repo
|
||||||
.get_all_labels()
|
.get_all_labels()
|
||||||
.await
|
.await
|
||||||
@@ -136,9 +159,11 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert_eq!(all_labels.len(), 8, "Should have all 8 relay labels");
|
assert_eq!(all_labels.len(), 8, "Should have all 8 relay labels");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `save_label` accepts maximum length labels
|
||||||
pub async fn test_save_label_accepts_max_length_labels() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that labels at the maximum allowed length (50 characters)
|
||||||
|
/// can be saved successfully.
|
||||||
|
pub async fn test_save_label_accepts_max_length_labels<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(5).expect("Valid relay ID");
|
let relay_id = RelayId::new(5).expect("Valid relay ID");
|
||||||
let max_label = RelayLabel::new("A".repeat(50)).expect("Valid max-length label");
|
let max_label = RelayLabel::new("A".repeat(50)).expect("Valid max-length label");
|
||||||
|
|
||||||
@@ -148,6 +173,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
"save_label should succeed with max-length label"
|
"save_label should succeed with max-length label"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verify it was saved correctly
|
||||||
let retrieved = repo
|
let retrieved = repo
|
||||||
.get_label(relay_id)
|
.get_label(relay_id)
|
||||||
.await
|
.await
|
||||||
@@ -160,9 +186,11 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `save_label` accepts minimum length labels
|
||||||
pub async fn test_save_label_accepts_min_length_labels() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that labels at the minimum allowed length (1 character)
|
||||||
|
/// can be saved successfully.
|
||||||
|
pub async fn test_save_label_accepts_min_length_labels<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(6).expect("Valid relay ID");
|
let relay_id = RelayId::new(6).expect("Valid relay ID");
|
||||||
let min_label = RelayLabel::new("X".to_string()).expect("Valid min-length label");
|
let min_label = RelayLabel::new("X".to_string()).expect("Valid min-length label");
|
||||||
|
|
||||||
@@ -172,6 +200,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
"save_label should succeed with min-length label"
|
"save_label should succeed with min-length label"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verify it was saved correctly
|
||||||
let retrieved = repo
|
let retrieved = repo
|
||||||
.get_label(relay_id)
|
.get_label(relay_id)
|
||||||
.await
|
.await
|
||||||
@@ -180,25 +209,37 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert_eq!(retrieved.unwrap().as_str(), "X", "Label should match");
|
assert_eq!(retrieved.unwrap().as_str(), "X", "Label should match");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// =========================================================================
|
||||||
pub async fn test_delete_label_succeeds_for_existing_label() {
|
// delete_label() Tests
|
||||||
let repo = MockRelayLabelRepository::new();
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Test: `delete_label` succeeds for existing label
|
||||||
|
///
|
||||||
|
/// Verifies that `delete_label` returns Ok when deleting an existing label.
|
||||||
|
pub async fn test_delete_label_succeeds_for_existing_label<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(7).expect("Valid relay ID");
|
let relay_id = RelayId::new(7).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new("ToDelete".to_string()).expect("Valid label");
|
let label = RelayLabel::new("ToDelete".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save the label first
|
||||||
repo.save_label(relay_id, label)
|
repo.save_label(relay_id, label)
|
||||||
.await
|
.await
|
||||||
.expect("save_label should succeed");
|
.expect("save_label should succeed");
|
||||||
|
|
||||||
|
// Delete it
|
||||||
let result = repo.delete_label(relay_id).await;
|
let result = repo.delete_label(relay_id).await;
|
||||||
assert!(result.is_ok(), "delete_label should succeed");
|
assert!(result.is_ok(), "delete_label should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `delete_label` succeeds for non-existent label
|
||||||
pub async fn test_delete_label_succeeds_for_non_existent_label() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that `delete_label` returns Ok even when no label exists
|
||||||
|
/// (idempotent operation).
|
||||||
|
pub async fn test_delete_label_succeeds_for_non_existent_label<R: RelayLabelRepository>(
|
||||||
|
repo: &R,
|
||||||
|
) {
|
||||||
let relay_id = RelayId::new(8).expect("Valid relay ID");
|
let relay_id = RelayId::new(8).expect("Valid relay ID");
|
||||||
|
|
||||||
|
// Delete without saving first
|
||||||
let result = repo.delete_label(relay_id).await;
|
let result = repo.delete_label(relay_id).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
@@ -206,14 +247,19 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `delete_label` removes label from repository
|
||||||
pub async fn test_delete_label_removes_label_from_repository() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that after deleting a label, it no longer appears in `get_label`
|
||||||
|
/// or `get_all_labels` results.
|
||||||
|
pub async fn test_delete_label_removes_label_from_repository<R: RelayLabelRepository>(
|
||||||
|
repo: &R,
|
||||||
|
) {
|
||||||
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
let label1 = RelayLabel::new("Keep".to_string()).expect("Valid label");
|
let label1 = RelayLabel::new("Keep".to_string()).expect("Valid label");
|
||||||
let label2 = RelayLabel::new("Remove".to_string()).expect("Valid label");
|
let label2 = RelayLabel::new("Remove".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save two labels
|
||||||
repo.save_label(relay1, label1)
|
repo.save_label(relay1, label1)
|
||||||
.await
|
.await
|
||||||
.expect("save should succeed");
|
.expect("save should succeed");
|
||||||
@@ -221,22 +267,26 @@ mod relay_label_repository_contract_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("save should succeed");
|
.expect("save should succeed");
|
||||||
|
|
||||||
|
// Delete one label
|
||||||
repo.delete_label(relay2)
|
repo.delete_label(relay2)
|
||||||
.await
|
.await
|
||||||
.expect("delete should succeed");
|
.expect("delete should succeed");
|
||||||
|
|
||||||
|
// Verify deleted label is gone
|
||||||
let get_result = repo
|
let get_result = repo
|
||||||
.get_label(relay2)
|
.get_label(relay2)
|
||||||
.await
|
.await
|
||||||
.expect("get_label should succeed");
|
.expect("get_label should succeed");
|
||||||
assert!(get_result.is_none(), "Deleted label should not exist");
|
assert!(get_result.is_none(), "Deleted label should not exist");
|
||||||
|
|
||||||
|
// Verify other label still exists
|
||||||
let other_result = repo
|
let other_result = repo
|
||||||
.get_label(relay1)
|
.get_label(relay1)
|
||||||
.await
|
.await
|
||||||
.expect("get_label should succeed");
|
.expect("get_label should succeed");
|
||||||
assert!(other_result.is_some(), "Other label should still exist");
|
assert!(other_result.is_some(), "Other label should still exist");
|
||||||
|
|
||||||
|
// Verify get_all_labels only returns the remaining label
|
||||||
let all_labels = repo
|
let all_labels = repo
|
||||||
.get_all_labels()
|
.get_all_labels()
|
||||||
.await
|
.await
|
||||||
@@ -245,12 +295,14 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert_eq!(all_labels[0].0.as_u8(), 1, "Should be relay 1");
|
assert_eq!(all_labels[0].0.as_u8(), 1, "Should be relay 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `delete_label` is idempotent
|
||||||
pub async fn test_delete_label_is_idempotent() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that calling `delete_label` multiple times succeeds without error.
|
||||||
|
pub async fn test_delete_label_is_idempotent<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay_id = RelayId::new(3).expect("Valid relay ID");
|
let relay_id = RelayId::new(3).expect("Valid relay ID");
|
||||||
let label = RelayLabel::new("Idempotent".to_string()).expect("Valid label");
|
let label = RelayLabel::new("Idempotent".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save, then delete twice
|
||||||
repo.save_label(relay_id, label)
|
repo.save_label(relay_id, label)
|
||||||
.await
|
.await
|
||||||
.expect("save should succeed");
|
.expect("save should succeed");
|
||||||
@@ -265,9 +317,17 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// =========================================================================
|
||||||
pub async fn test_get_all_labels_returns_empty_when_no_labels() {
|
// get_all_labels() Tests
|
||||||
let repo = MockRelayLabelRepository::new();
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Test: `get_all_labels` returns empty vector when no labels exist
|
||||||
|
///
|
||||||
|
/// Verifies that `get_all_labels` returns an empty vector rather than
|
||||||
|
/// an error when the repository is empty.
|
||||||
|
pub async fn test_get_all_labels_returns_empty_when_no_labels<R: RelayLabelRepository>(
|
||||||
|
repo: &R,
|
||||||
|
) {
|
||||||
let result = repo.get_all_labels().await;
|
let result = repo.get_all_labels().await;
|
||||||
|
|
||||||
assert!(result.is_ok(), "get_all_labels should succeed");
|
assert!(result.is_ok(), "get_all_labels should succeed");
|
||||||
@@ -277,9 +337,11 @@ mod relay_label_repository_contract_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `get_all_labels` returns all saved labels
|
||||||
pub async fn test_get_all_labels_returns_all_saved_labels() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that `get_all_labels` returns all labels that have been saved,
|
||||||
|
/// and only those relays with labels.
|
||||||
|
pub async fn test_get_all_labels_returns_all_saved_labels<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
||||||
let relay5 = RelayId::new(5).expect("Valid relay ID");
|
let relay5 = RelayId::new(5).expect("Valid relay ID");
|
||||||
@@ -288,6 +350,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
let label3 = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
let label3 = RelayLabel::new("Heater".to_string()).expect("Valid label");
|
||||||
let label5 = RelayLabel::new("Fan".to_string()).expect("Valid label");
|
let label5 = RelayLabel::new("Fan".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save labels
|
||||||
repo.save_label(relay1, label1.clone())
|
repo.save_label(relay1, label1.clone())
|
||||||
.await
|
.await
|
||||||
.expect("Save should succeed");
|
.expect("Save should succeed");
|
||||||
@@ -298,6 +361,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("Save should succeed");
|
.expect("Save should succeed");
|
||||||
|
|
||||||
|
// Retrieve all labels
|
||||||
let result = repo
|
let result = repo
|
||||||
.get_all_labels()
|
.get_all_labels()
|
||||||
.await
|
.await
|
||||||
@@ -305,6 +369,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
|
|
||||||
assert_eq!(result.len(), 3, "Should return exactly 3 labels");
|
assert_eq!(result.len(), 3, "Should return exactly 3 labels");
|
||||||
|
|
||||||
|
// Verify the labels are present (order may vary by implementation)
|
||||||
let has_relay1 = result
|
let has_relay1 = result
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(id, label)| id.as_u8() == 1 && label.as_str() == "Pump");
|
.any(|(id, label)| id.as_u8() == 1 && label.as_str() == "Pump");
|
||||||
@@ -320,9 +385,13 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert!(has_relay5, "Should contain relay 5 with label 'Fan'");
|
assert!(has_relay5, "Should contain relay 5 with label 'Fan'");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `get_all_labels` excludes relays without labels
|
||||||
pub async fn test_get_all_labels_excludes_relays_without_labels() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that only relays with labels are returned, not all possible
|
||||||
|
/// relay IDs (1-8).
|
||||||
|
pub async fn test_get_all_labels_excludes_relays_without_labels<R: RelayLabelRepository>(
|
||||||
|
repo: &R,
|
||||||
|
) {
|
||||||
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
let label2 = RelayLabel::new("Only This One".to_string()).expect("Valid label");
|
let label2 = RelayLabel::new("Only This One".to_string()).expect("Valid label");
|
||||||
|
|
||||||
@@ -343,9 +412,10 @@ mod relay_label_repository_contract_tests {
|
|||||||
assert_eq!(result[0].0.as_u8(), 2, "Should be relay 2");
|
assert_eq!(result[0].0.as_u8(), 2, "Should be relay 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
/// Test: `get_all_labels` excludes deleted labels
|
||||||
pub async fn test_get_all_labels_excludes_deleted_labels() {
|
///
|
||||||
let repo = MockRelayLabelRepository::new();
|
/// Verifies that deleted labels don't appear in `get_all_labels` results.
|
||||||
|
pub async fn test_get_all_labels_excludes_deleted_labels<R: RelayLabelRepository>(repo: &R) {
|
||||||
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
let relay1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
let relay2 = RelayId::new(2).expect("Valid relay ID");
|
||||||
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
let relay3 = RelayId::new(3).expect("Valid relay ID");
|
||||||
@@ -354,6 +424,7 @@ mod relay_label_repository_contract_tests {
|
|||||||
let label2 = RelayLabel::new("Delete".to_string()).expect("Valid label");
|
let label2 = RelayLabel::new("Delete".to_string()).expect("Valid label");
|
||||||
let label3 = RelayLabel::new("Keep2".to_string()).expect("Valid label");
|
let label3 = RelayLabel::new("Keep2".to_string()).expect("Valid label");
|
||||||
|
|
||||||
|
// Save all three labels
|
||||||
repo.save_label(relay1, label1)
|
repo.save_label(relay1, label1)
|
||||||
.await
|
.await
|
||||||
.expect("save should succeed");
|
.expect("save should succeed");
|
||||||
@@ -364,10 +435,12 @@ mod relay_label_repository_contract_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("save should succeed");
|
.expect("save should succeed");
|
||||||
|
|
||||||
|
// Delete the middle one
|
||||||
repo.delete_label(relay2)
|
repo.delete_label(relay2)
|
||||||
.await
|
.await
|
||||||
.expect("delete should succeed");
|
.expect("delete should succeed");
|
||||||
|
|
||||||
|
// Verify get_all_labels only returns the two remaining labels
|
||||||
let result = repo
|
let result = repo
|
||||||
.get_all_labels()
|
.get_all_labels()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -3,11 +3,6 @@
|
|||||||
//! This module contains the concrete implementations of repository traits
|
//! This module contains the concrete implementations of repository traits
|
||||||
//! for data persistence, including SQLite-based storage for relay labels.
|
//! for data persistence, including SQLite-based storage for relay labels.
|
||||||
|
|
||||||
pub mod entities;
|
|
||||||
|
|
||||||
/// Factory functions for creating relay label repositories.
|
|
||||||
pub mod factory;
|
|
||||||
|
|
||||||
/// Mock repository implementation for testing.
|
/// Mock repository implementation for testing.
|
||||||
pub mod label_repository;
|
pub mod label_repository;
|
||||||
|
|
||||||
@@ -17,3 +12,5 @@ pub mod label_repository_tests;
|
|||||||
|
|
||||||
/// `SQLite` repository implementation for relay labels.
|
/// `SQLite` repository implementation for relay labels.
|
||||||
pub mod sqlite_repository;
|
pub mod sqlite_repository;
|
||||||
|
|
||||||
|
pub mod entities;
|
||||||
|
|||||||
+5
-6
@@ -85,7 +85,7 @@ pub mod presentation;
|
|||||||
|
|
||||||
type MaybeListener = Option<poem::listener::TcpListener<String>>;
|
type MaybeListener = Option<poem::listener::TcpListener<String>>;
|
||||||
|
|
||||||
async fn prepare(listener: MaybeListener) -> startup::Application {
|
fn prepare(listener: MaybeListener) -> startup::Application {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let settings = settings::Settings::new().expect("Failed to read settings");
|
let settings = settings::Settings::new().expect("Failed to read settings");
|
||||||
if !cfg!(test) {
|
if !cfg!(test) {
|
||||||
@@ -98,8 +98,7 @@ async fn prepare(listener: MaybeListener) -> startup::Application {
|
|||||||
"Using these settings: {:?}",
|
"Using these settings: {:?}",
|
||||||
settings
|
settings
|
||||||
);
|
);
|
||||||
let application = startup::Application::build(settings, listener).await
|
let application = startup::Application::build(settings, listener);
|
||||||
.expect("Failed to build application");
|
|
||||||
tracing::event!(
|
tracing::event!(
|
||||||
target: "backend",
|
target: "backend",
|
||||||
tracing::Level::INFO,
|
tracing::Level::INFO,
|
||||||
@@ -125,7 +124,7 @@ async fn prepare(listener: MaybeListener) -> startup::Application {
|
|||||||
/// an I/O error during runtime (e.g., port already in use, network issues).
|
/// an I/O error during runtime (e.g., port already in use, network issues).
|
||||||
#[cfg(not(tarpaulin_include))]
|
#[cfg(not(tarpaulin_include))]
|
||||||
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
|
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
|
||||||
let application = prepare(listener).await;
|
let application = prepare(listener);
|
||||||
application.make_app().run().await
|
application.make_app().run().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +137,7 @@ fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
async fn get_test_app() -> startup::App {
|
fn get_test_app() -> startup::App {
|
||||||
let tcp_listener = make_random_tcp_listener();
|
let tcp_listener = make_random_tcp_listener();
|
||||||
prepare(Some(tcp_listener)).await.make_app().into()
|
prepare(Some(tcp_listener)).make_app().into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub mod relay_api;
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use poem::Result;
|
|
||||||
use poem_openapi::{ApiResponse, OpenApi, param::Path, payload::Json};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
application::use_cases::{GetAllRelaysUseCase, ToggleRelayUseCase},
|
|
||||||
domain::relay::{
|
|
||||||
Relay, controller::RelayController, repository::RelayLabelRepository, types::RelayId,
|
|
||||||
},
|
|
||||||
presentation::{dto::relay_dto::RelayDto, error::ApiError},
|
|
||||||
route::ApiCategory
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(ApiResponse)]
|
|
||||||
enum GetAllRelaysResponse {
|
|
||||||
#[oai(status = 200)]
|
|
||||||
Ok(Json<Vec<RelayDto>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(ApiResponse)]
|
|
||||||
enum ToggleRelayResponse {
|
|
||||||
#[oai(status = 200)]
|
|
||||||
Ok(Json<RelayDto>),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RelayApi {
|
|
||||||
relay_controller: Arc<dyn RelayController>,
|
|
||||||
label_repository: Arc<dyn RelayLabelRepository>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RelayApi {
|
|
||||||
pub fn new(
|
|
||||||
relay_controller: Arc<dyn RelayController>,
|
|
||||||
label_repository: Arc<dyn RelayLabelRepository>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
relay_controller,
|
|
||||||
label_repository,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Endpoints ---
|
|
||||||
#[OpenApi(tag = "ApiCategory::Relays")]
|
|
||||||
impl RelayApi {
|
|
||||||
#[oai(path = "/relays", method = "get")]
|
|
||||||
async fn get_all_relays(&self) -> Result<GetAllRelaysResponse> {
|
|
||||||
let use_case =
|
|
||||||
GetAllRelaysUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
|
|
||||||
let relays = use_case
|
|
||||||
.execute()
|
|
||||||
.await
|
|
||||||
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
|
|
||||||
let dtos: Vec<_> = relays
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
let domain_relay =
|
|
||||||
Relay::with_label(r.id(), r.state(), r.label().unwrap_or_default());
|
|
||||||
RelayDto::from(domain_relay)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(GetAllRelaysResponse::Ok(Json(dtos)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[oai(path = "/relays/:id/toggle", method = "post")]
|
|
||||||
async fn toggle_relay(&self, id: Path<u8>) -> Result<ToggleRelayResponse> {
|
|
||||||
let relay_id =
|
|
||||||
RelayId::new(*id).map_err(|_| poem::Error::from(ApiError::RelayNotFound(*id)))?;
|
|
||||||
let use_case =
|
|
||||||
ToggleRelayUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
|
|
||||||
let relay = use_case
|
|
||||||
.execute(relay_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
|
|
||||||
let domain_relay =
|
|
||||||
Relay::with_label(relay.id(), relay.state(), relay.label().unwrap_or_default());
|
|
||||||
Ok(ToggleRelayResponse::Ok(Json(RelayDto::from(domain_relay))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use poem::http::StatusCode;
|
|
||||||
use poem_openapi::OpenApiService;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
domain::relay::{
|
|
||||||
controller::RelayController,
|
|
||||||
repository::RelayLabelRepository,
|
|
||||||
types::{RelayId, RelayState},
|
|
||||||
},
|
|
||||||
infrastructure::{
|
|
||||||
modbus::mock_controller::MockRelayController,
|
|
||||||
persistence::label_repository::MockRelayLabelRepository,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::RelayApi;
|
|
||||||
|
|
||||||
fn make_relay_api(controller: Arc<MockRelayController>) -> poem::test::TestClient<impl poem::Endpoint> {
|
|
||||||
let repo = Arc::new(MockRelayLabelRepository::new());
|
|
||||||
let relay_api = RelayApi::new(controller, repo);
|
|
||||||
let api_service = OpenApiService::new(relay_api, "test", "1.0");
|
|
||||||
let app = poem::Route::new().nest("/api", api_service);
|
|
||||||
poem::test::TestClient::new(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- GET /api/relays --
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_returns_200() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_returns_empty_array_when_no_states() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
assert!(body.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_returns_all_initialized_relays() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
for i in 1u8..=8 {
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(i).unwrap(), RelayState::Off)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body.len(), 8);
|
|
||||||
assert_eq!(body[0]["id"], 1);
|
|
||||||
assert_eq!(body[0]["state"], "on");
|
|
||||||
assert_eq!(body[1]["id"], 2);
|
|
||||||
assert_eq!(body[1]["state"], "off");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- POST /api/relays/{id}/toggle --
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_with_out_of_range_id_9_returns_404() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.post("/api/relays/9/toggle").send().await;
|
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_with_id_0_returns_404() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.post("/api/relays/0/toggle").send().await;
|
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_toggles_off_to_on_and_returns_200() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(1).unwrap(), RelayState::Off)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.post("/api/relays/1/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["id"], 1);
|
|
||||||
assert_eq!(body["state"], "on");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_toggles_on_to_off_and_returns_200() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = make_relay_api(controller);
|
|
||||||
let resp = cli.post("/api/relays/3/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["id"], 3);
|
|
||||||
assert_eq!(body["state"], "off");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_includes_label_in_response() {
|
|
||||||
use crate::domain::relay::types::RelayLabel;
|
|
||||||
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(2).unwrap(), RelayState::Off)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let repo = Arc::new(MockRelayLabelRepository::new());
|
|
||||||
repo.save_label(RelayId::new(2).unwrap(), RelayLabel::new("Pump".to_string()).unwrap())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let relay_api = RelayApi::new(controller, repo);
|
|
||||||
let api_service = OpenApiService::new(relay_api, "test", "1.0");
|
|
||||||
let app = poem::Route::new().nest("/api", api_service);
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/2/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["label"], "Pump");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Integration tests via get_test_app() --
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_endpoint_reachable_via_full_app() {
|
|
||||||
let app = crate::get_test_app().await;
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_invalid_id_returns_404_via_full_app() {
|
|
||||||
let app = crate::get_test_app().await;
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.post("/api/relays/9/toggle").send().await;
|
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Posting to a valid relay ID on an empty mock should hit the handler (route found)
|
|
||||||
// and return 500 because the mock controller has no relay state initialised.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_valid_id_empty_mock_returns_500_via_full_app() {
|
|
||||||
let app = crate::get_test_app().await;
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.post("/api/relays/1/toggle").send().await;
|
|
||||||
resp.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/// 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;
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
//! 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,12 +94,3 @@
|
|||||||
//! - 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 api;
|
|
||||||
pub mod dto;
|
|
||||||
pub mod error;
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl HealthApi {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn health_check_works() {
|
async fn health_check_works() {
|
||||||
let app = crate::get_test_app().await;
|
let app = crate::get_test_app();
|
||||||
let cli = poem::test::TestClient::new(app);
|
let cli = poem::test::TestClient::new(app);
|
||||||
let resp = cli.get("/api/health").send().await;
|
let resp = cli.get("/api/health").send().await;
|
||||||
resp.assert_status_is_ok();
|
resp.assert_status_is_ok();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ impl MetaApi {
|
|||||||
mod tests {
|
mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn meta_endpoint_returns_correct_data() {
|
async fn meta_endpoint_returns_correct_data() {
|
||||||
let app = crate::get_test_app().await;
|
let app = crate::get_test_app();
|
||||||
let cli = poem::test::TestClient::new(app);
|
let cli = poem::test::TestClient::new(app);
|
||||||
let resp = cli.get("/api/meta").send().await;
|
let resp = cli.get("/api/meta").send().await;
|
||||||
resp.assert_status_is_ok();
|
resp.assert_status_is_ok();
|
||||||
@@ -78,7 +78,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn meta_endpoint_returns_200_status() {
|
async fn meta_endpoint_returns_200_status() {
|
||||||
let app = crate::get_test_app().await;
|
let app = crate::get_test_app();
|
||||||
let cli = poem::test::TestClient::new(app);
|
let cli = poem::test::TestClient::new(app);
|
||||||
let resp = cli.get("/api/meta").send().await;
|
let resp = cli.get("/api/meta").send().await;
|
||||||
resp.assert_status_is_ok();
|
resp.assert_status_is_ok();
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ mod meta;
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[derive(Tags)]
|
#[derive(Tags)]
|
||||||
pub enum ApiCategory {
|
enum ApiCategory {
|
||||||
Health,
|
Health,
|
||||||
Meta,
|
Meta,
|
||||||
Relays,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Api {
|
pub(crate) struct Api {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
/// Application-specific configuration settings.
|
|
||||||
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
|
||||||
pub struct ApplicationSettings {
|
|
||||||
/// Application name
|
|
||||||
pub name: String,
|
|
||||||
/// Application version
|
|
||||||
pub version: String,
|
|
||||||
/// Port to bind to
|
|
||||||
pub port: u16,
|
|
||||||
/// Host address to bind to
|
|
||||||
pub host: String,
|
|
||||||
/// Base URL of the application
|
|
||||||
pub base_url: String,
|
|
||||||
/// Protocol (http or https)
|
|
||||||
pub protocol: String,
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#[derive(Debug, serde::Deserialize, Clone)]
|
|
||||||
pub struct DatabaseSettings {
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DatabaseSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
path: "sqlite::memory:".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/// Application environment.
|
|
||||||
#[derive(Debug, PartialEq, Eq, Default)]
|
|
||||||
pub enum Environment {
|
|
||||||
/// Development environment
|
|
||||||
#[default]
|
|
||||||
Development,
|
|
||||||
/// Production environment
|
|
||||||
Production,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for Environment {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
let self_str = match self {
|
|
||||||
Self::Development => "development",
|
|
||||||
Self::Production => "production",
|
|
||||||
};
|
|
||||||
write!(f, "{self_str}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for Environment {
|
|
||||||
type Error = String;
|
|
||||||
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
||||||
Self::try_from(value.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&str> for Environment {
|
|
||||||
type Error = String;
|
|
||||||
|
|
||||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
||||||
match value.to_lowercase().as_str() {
|
|
||||||
"development" | "dev" => Ok(Self::Development),
|
|
||||||
"production" | "prod" => Ok(Self::Production),
|
|
||||||
other => Err(format!(
|
|
||||||
"{other} is not a supported environment. Use either `development` or `production`"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_display_development() {
|
|
||||||
let env = Environment::Development;
|
|
||||||
assert_eq!(env.to_string(), "development");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_display_production() {
|
|
||||||
let env = Environment::Production;
|
|
||||||
assert_eq!(env.to_string(), "production");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_str_development() {
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("development").unwrap(),
|
|
||||||
Environment::Development
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("dev").unwrap(),
|
|
||||||
Environment::Development
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("Development").unwrap(),
|
|
||||||
Environment::Development
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("DEV").unwrap(),
|
|
||||||
Environment::Development
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_str_production() {
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("production").unwrap(),
|
|
||||||
Environment::Production
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("prod").unwrap(),
|
|
||||||
Environment::Production
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("Production").unwrap(),
|
|
||||||
Environment::Production
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("PROD").unwrap(),
|
|
||||||
Environment::Production
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_str_invalid() {
|
|
||||||
let result = Environment::try_from("invalid");
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().contains("not a supported environment"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_string_development() {
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("development".to_string()).unwrap(),
|
|
||||||
Environment::Development
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_string_production() {
|
|
||||||
assert_eq!(
|
|
||||||
Environment::try_from("production".to_string()).unwrap(),
|
|
||||||
Environment::Production
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_from_string_invalid() {
|
|
||||||
let result = Environment::try_from("invalid".to_string());
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn environment_default_is_development() {
|
|
||||||
let env = Environment::default();
|
|
||||||
assert_eq!(env, Environment::Development);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+271
-19
@@ -7,21 +7,8 @@
|
|||||||
//! Settings include application details, Modbus connection parameters, relay configuration,
|
//! Settings include application details, Modbus connection parameters, relay configuration,
|
||||||
//! rate limiting, and environment settings.
|
//! rate limiting, and environment settings.
|
||||||
|
|
||||||
mod application;
|
|
||||||
mod cors;
|
mod cors;
|
||||||
mod database;
|
|
||||||
mod environment;
|
|
||||||
mod modbus;
|
|
||||||
mod rate_limiting;
|
|
||||||
mod relay;
|
|
||||||
|
|
||||||
pub use application::ApplicationSettings;
|
|
||||||
pub use cors::CorsSettings;
|
pub use cors::CorsSettings;
|
||||||
pub use database::DatabaseSettings;
|
|
||||||
pub use environment::Environment;
|
|
||||||
pub use modbus::ModbusSettings;
|
|
||||||
pub use rate_limiting::RateLimitSettings;
|
|
||||||
pub use relay::RelaySettings;
|
|
||||||
|
|
||||||
/// Application configuration settings.
|
/// Application configuration settings.
|
||||||
///
|
///
|
||||||
@@ -31,21 +18,15 @@ pub struct Settings {
|
|||||||
/// Application-specific settings (name, version, host, port, etc.)
|
/// Application-specific settings (name, version, host, port, etc.)
|
||||||
pub application: ApplicationSettings,
|
pub application: ApplicationSettings,
|
||||||
/// Debug mode flag
|
/// Debug mode flag
|
||||||
#[serde(default)]
|
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
/// Frontend URL for CORS configuration
|
/// Frontend URL for CORS configuration
|
||||||
pub frontend_url: String,
|
pub frontend_url: String,
|
||||||
/// Database settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub database: DatabaseSettings,
|
|
||||||
/// Rate limiting configuration
|
/// Rate limiting configuration
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rate_limit: RateLimitSettings,
|
pub rate_limit: RateLimitSettings,
|
||||||
/// Modbus configuration
|
/// Modbus configuration
|
||||||
#[serde(default)]
|
|
||||||
pub modbus: ModbusSettings,
|
pub modbus: ModbusSettings,
|
||||||
/// Relay configuration
|
/// Relay configuration
|
||||||
#[serde(default)]
|
|
||||||
pub relay: RelaySettings,
|
pub relay: RelaySettings,
|
||||||
/// CORS configuration
|
/// CORS configuration
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -97,10 +78,272 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Application-specific configuration settings.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||||
|
pub struct ApplicationSettings {
|
||||||
|
/// Application name
|
||||||
|
pub name: String,
|
||||||
|
/// Application version
|
||||||
|
pub version: String,
|
||||||
|
/// Port to bind to
|
||||||
|
pub port: u16,
|
||||||
|
/// Host address to bind to
|
||||||
|
pub host: String,
|
||||||
|
/// Base URL of the application
|
||||||
|
pub base_url: String,
|
||||||
|
/// Protocol (http or https)
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application environment.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum Environment {
|
||||||
|
/// Development environment
|
||||||
|
#[default]
|
||||||
|
Development,
|
||||||
|
/// Production environment
|
||||||
|
Production,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Environment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let self_str = match self {
|
||||||
|
Self::Development => "development",
|
||||||
|
Self::Production => "production",
|
||||||
|
};
|
||||||
|
write!(f, "{self_str}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
match value.to_lowercase().as_str() {
|
||||||
|
"development" | "dev" => Ok(Self::Development),
|
||||||
|
"production" | "prod" => Ok(Self::Production),
|
||||||
|
other => Err(format!(
|
||||||
|
"{other} is not a supported environment. Use either `development` or `production`"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiting configuration.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone)]
|
||||||
|
pub struct RateLimitSettings {
|
||||||
|
/// Whether rate limiting is enabled
|
||||||
|
#[serde(default = "default_rate_limit_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Maximum number of requests allowed in the time window (burst size)
|
||||||
|
#[serde(default = "default_burst_size")]
|
||||||
|
pub burst_size: u32,
|
||||||
|
/// Time window in seconds for rate limiting
|
||||||
|
#[serde(default = "default_per_seconds")]
|
||||||
|
pub per_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_rate_limit_enabled(),
|
||||||
|
burst_size: default_burst_size(),
|
||||||
|
per_seconds: default_per_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_rate_limit_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_burst_size() -> u32 {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_per_seconds() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modbus TCP connection configuration.
|
||||||
|
///
|
||||||
|
/// Configures the connection parameters for communicating with the Modbus relay device
|
||||||
|
/// using Modbus RTU over TCP protocol.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone)]
|
||||||
|
pub struct ModbusSettings {
|
||||||
|
/// IP address or hostname of the Modbus device
|
||||||
|
pub host: String,
|
||||||
|
/// TCP port for Modbus communication (standard Modbus TCP port is 502)
|
||||||
|
pub port: u16,
|
||||||
|
/// Modbus slave/device ID (unit identifier)
|
||||||
|
pub slave_id: u8,
|
||||||
|
/// Operation timeout in seconds
|
||||||
|
pub timeout_secs: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModbusSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "192.168.0.200".to_string(),
|
||||||
|
port: 502,
|
||||||
|
slave_id: 0,
|
||||||
|
timeout_secs: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay control configuration.
|
||||||
|
///
|
||||||
|
/// Configures parameters for relay management and labeling.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone)]
|
||||||
|
pub struct RelaySettings {
|
||||||
|
/// Maximum length for custom relay labels (in characters)
|
||||||
|
pub label_max_length: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RelaySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
label_max_length: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_display_development() {
|
||||||
|
let env = Environment::Development;
|
||||||
|
assert_eq!(env.to_string(), "development");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_display_production() {
|
||||||
|
let env = Environment::Production;
|
||||||
|
assert_eq!(env.to_string(), "production");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_development() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("development").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("dev").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("Development").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("DEV").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_production() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("production").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("prod").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("Production").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("PROD").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_invalid() {
|
||||||
|
let result = Environment::try_from("invalid");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not a supported environment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_development() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("development".to_string()).unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_production() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("production".to_string()).unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_invalid() {
|
||||||
|
let result = Environment::try_from("invalid".to_string());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_default_is_development() {
|
||||||
|
let env = Environment::default();
|
||||||
|
assert_eq!(env, Environment::Development);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_default() {
|
||||||
|
let settings = RateLimitSettings::default();
|
||||||
|
assert!(settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 100);
|
||||||
|
assert_eq!(settings.per_seconds, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_full() {
|
||||||
|
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 50);
|
||||||
|
assert_eq!(settings.per_seconds, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_partial() {
|
||||||
|
let json = r#"{"enabled": false}"#;
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 100); // default
|
||||||
|
assert_eq!(settings.per_seconds, 60); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_empty() {
|
||||||
|
let json = "{}";
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(settings.enabled); // default
|
||||||
|
assert_eq!(settings.burst_size, 100); // default
|
||||||
|
assert_eq!(settings.per_seconds, 60); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// T009: Integration test for CorsSettings within Settings struct
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_loads_cors_section_from_yaml() {
|
fn settings_loads_cors_section_from_yaml() {
|
||||||
// Create a temporary settings file with CORS configuration
|
// Create a temporary settings file with CORS configuration
|
||||||
@@ -126,6 +369,15 @@ cors:
|
|||||||
- "http://localhost:5173"
|
- "http://localhost:5173"
|
||||||
allow_credentials: false
|
allow_credentials: false
|
||||||
max_age_secs: 3600
|
max_age_secs: 3600
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
host: "192.168.0.200"
|
||||||
|
port: 502
|
||||||
|
slave_id: 0
|
||||||
|
timeout_secs: 5
|
||||||
|
|
||||||
|
relay:
|
||||||
|
label_max_length: 50
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// Use serde_yaml to deserialize directly
|
// Use serde_yaml to deserialize directly
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
/// Modbus TCP connection configuration.
|
|
||||||
///
|
|
||||||
/// Configures the connection parameters for communicating with the Modbus relay device
|
|
||||||
/// using Modbus RTU over TCP protocol.
|
|
||||||
#[derive(Debug, serde::Deserialize, Clone)]
|
|
||||||
pub struct ModbusSettings {
|
|
||||||
/// IP address or hostname of the Modbus device
|
|
||||||
pub host: String,
|
|
||||||
/// TCP port for Modbus communication (standard Modbus TCP port is 502)
|
|
||||||
pub port: u16,
|
|
||||||
/// Modbus slave/device ID (unit identifier)
|
|
||||||
pub slave_id: u8,
|
|
||||||
/// Operation timeout in seconds
|
|
||||||
pub timeout_secs: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ModbusSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
host: "192.168.0.200".to_string(),
|
|
||||||
port: 502,
|
|
||||||
slave_id: 0,
|
|
||||||
timeout_secs: 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/// Rate limiting configuration.
|
|
||||||
#[derive(Debug, serde::Deserialize, Clone)]
|
|
||||||
pub struct RateLimitSettings {
|
|
||||||
/// Whether rate limiting is enabled
|
|
||||||
#[serde(default = "default_rate_limit_enabled")]
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Maximum number of requests allowed in the time window (burst size)
|
|
||||||
#[serde(default = "default_burst_size")]
|
|
||||||
pub burst_size: u32,
|
|
||||||
/// Time window in seconds for rate limiting
|
|
||||||
#[serde(default = "default_per_seconds")]
|
|
||||||
pub per_seconds: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RateLimitSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: default_rate_limit_enabled(),
|
|
||||||
burst_size: default_burst_size(),
|
|
||||||
per_seconds: default_per_seconds(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn default_rate_limit_enabled() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn default_burst_size() -> u32 {
|
|
||||||
100
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn default_per_seconds() -> u64 {
|
|
||||||
60
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_settings_default() {
|
|
||||||
let settings = RateLimitSettings::default();
|
|
||||||
assert!(settings.enabled);
|
|
||||||
assert_eq!(settings.burst_size, 100);
|
|
||||||
assert_eq!(settings.per_seconds, 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_settings_deserialize_full() {
|
|
||||||
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
|
|
||||||
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(settings.enabled);
|
|
||||||
assert_eq!(settings.burst_size, 50);
|
|
||||||
assert_eq!(settings.per_seconds, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_settings_deserialize_partial() {
|
|
||||||
let json = r#"{"enabled": false}"#;
|
|
||||||
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(!settings.enabled);
|
|
||||||
assert_eq!(settings.burst_size, 100); // default
|
|
||||||
assert_eq!(settings.per_seconds, 60); // default
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_settings_deserialize_empty() {
|
|
||||||
let json = "{}";
|
|
||||||
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(settings.enabled); // default
|
|
||||||
assert_eq!(settings.burst_size, 100); // default
|
|
||||||
assert_eq!(settings.per_seconds, 60); // default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/// Relay control configuration.
|
|
||||||
///
|
|
||||||
/// Configures parameters for relay management and labeling.
|
|
||||||
#[derive(Debug, serde::Deserialize, Clone)]
|
|
||||||
pub struct RelaySettings {
|
|
||||||
/// Maximum length for custom relay labels (in characters)
|
|
||||||
pub label_max_length: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RelaySettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
label_max_length: 8,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
-109
@@ -10,9 +10,6 @@ use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
|
|||||||
use poem::{EndpointExt, Route};
|
use poem::{EndpointExt, Route};
|
||||||
use poem_openapi::OpenApiService;
|
use poem_openapi::OpenApiService;
|
||||||
|
|
||||||
use crate::infrastructure::modbus::factory::create_relay_controller;
|
|
||||||
use crate::infrastructure::persistence::factory::create_label_repository;
|
|
||||||
use crate::presentation::api::relay_api::RelayApi;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::rate_limit::{RateLimit, RateLimitConfig},
|
middleware::rate_limit::{RateLimit, RateLimitConfig},
|
||||||
route::Api,
|
route::Api,
|
||||||
@@ -97,17 +94,17 @@ impl From<Application> for RunnableApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Application {
|
impl Application {
|
||||||
fn setup_app(settings: &Settings, relay_api: RelayApi) -> poem::Route {
|
fn setup_app(settings: &Settings) -> poem::Route {
|
||||||
let api_service = OpenApiService::new(
|
let api_service = OpenApiService::new(
|
||||||
(Api::from(settings).apis(), relay_api),
|
Api::from(settings).apis(),
|
||||||
settings.application.clone().name,
|
settings.application.clone().name,
|
||||||
settings.application.clone().version,
|
settings.application.clone().version,
|
||||||
)
|
)
|
||||||
.url_prefix("/api");
|
.url_prefix("/api");
|
||||||
let ui = api_service.swagger_ui();
|
let ui = api_service.swagger_ui();
|
||||||
poem::Route::new()
|
poem::Route::new()
|
||||||
|
.nest("/api", api_service.clone())
|
||||||
.nest("/specs", api_service.spec_endpoint_yaml())
|
.nest("/specs", api_service.spec_endpoint_yaml())
|
||||||
.nest("/api", api_service)
|
|
||||||
.nest("/", ui)
|
.nest("/", ui)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,31 +125,22 @@ impl Application {
|
|||||||
/// Builds a new application with the given settings and optional TCP listener.
|
/// Builds a new application with the given settings and optional TCP listener.
|
||||||
///
|
///
|
||||||
/// If no listener is provided, one will be created based on the settings.
|
/// If no listener is provided, one will be created based on the settings.
|
||||||
///
|
#[must_use]
|
||||||
/// # Errors
|
pub fn build(
|
||||||
///
|
|
||||||
/// Returns an error if dependency injection fails (currently always succeeds).
|
|
||||||
pub async fn build(
|
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Self {
|
||||||
let use_mock = cfg!(test) || std::env::var("CI").is_ok();
|
|
||||||
let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
|
|
||||||
let label_repository = create_label_repository(&settings.database.path, use_mock).await?;
|
|
||||||
let relay_api = RelayApi::new(relay_controller, label_repository);
|
|
||||||
|
|
||||||
let port = settings.application.port;
|
let port = settings.application.port;
|
||||||
let host = settings.application.clone().host;
|
let host = settings.application.clone().host;
|
||||||
let app = Self::setup_app(&settings, relay_api);
|
let app = Self::setup_app(&settings);
|
||||||
let server = Self::setup_server(&settings, tcp_listener);
|
let server = Self::setup_server(&settings, tcp_listener);
|
||||||
|
Self {
|
||||||
Ok(Self {
|
|
||||||
server,
|
server,
|
||||||
app,
|
app,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
settings,
|
settings,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts the application into a runnable application.
|
/// Converts the application into a runnable application.
|
||||||
@@ -199,131 +187,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn application_build_and_host() {
|
fn application_build_and_host() {
|
||||||
let settings = create_test_settings();
|
let settings = create_test_settings();
|
||||||
let app = Application::build(settings.clone(), None).await.unwrap();
|
let app = Application::build(settings.clone(), None);
|
||||||
assert_eq!(app.host(), settings.application.host);
|
assert_eq!(app.host(), settings.application.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn application_build_and_port() {
|
fn application_build_and_port() {
|
||||||
let settings = create_test_settings();
|
let settings = create_test_settings();
|
||||||
let app = Application::build(settings, None).await.unwrap();
|
let app = Application::build(settings, None);
|
||||||
assert_eq!(app.port(), 8080);
|
assert_eq!(app.port(), 8080);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn application_host_returns_correct_value() {
|
fn application_host_returns_correct_value() {
|
||||||
let settings = create_test_settings();
|
let settings = create_test_settings();
|
||||||
let app = Application::build(settings, None).await.unwrap();
|
let app = Application::build(settings, None);
|
||||||
assert_eq!(app.host(), "127.0.0.1");
|
assert_eq!(app.host(), "127.0.0.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn application_port_returns_correct_value() {
|
fn application_port_returns_correct_value() {
|
||||||
let settings = create_test_settings();
|
let settings = create_test_settings();
|
||||||
let app = Application::build(settings, None).await.unwrap();
|
let app = Application::build(settings, None);
|
||||||
assert_eq!(app.port(), 8080);
|
assert_eq!(app.port(), 8080);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn application_with_custom_listener() {
|
fn application_with_custom_listener() {
|
||||||
let settings = create_test_settings();
|
let settings = create_test_settings();
|
||||||
let tcp_listener =
|
let tcp_listener =
|
||||||
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
|
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
|
||||||
let port = tcp_listener.local_addr().unwrap().port();
|
let port = tcp_listener.local_addr().unwrap().port();
|
||||||
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
|
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
|
||||||
|
|
||||||
let app = Application::build(settings, Some(listener)).await.unwrap();
|
let app = Application::build(settings, Some(listener));
|
||||||
assert_eq!(app.host(), "127.0.0.1");
|
assert_eq!(app.host(), "127.0.0.1");
|
||||||
assert_eq!(app.port(), 8080);
|
assert_eq!(app.port(), 8080);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// T015: Test that CORS middleware is configured from settings
|
||||||
async fn runnable_application_uses_cors_from_settings() {
|
#[test]
|
||||||
|
fn runnable_application_uses_cors_from_settings() {
|
||||||
|
// GIVEN: An application with custom CORS settings
|
||||||
let mut settings = create_test_settings();
|
let mut settings = create_test_settings();
|
||||||
settings.cors = crate::settings::CorsSettings {
|
settings.cors = crate::settings::CorsSettings {
|
||||||
allowed_origins: vec!["http://localhost:5173".to_string()],
|
allowed_origins: vec!["http://localhost:5173".to_string()],
|
||||||
allow_credentials: false,
|
allow_credentials: false,
|
||||||
max_age_secs: 3600,
|
max_age_secs: 3600,
|
||||||
};
|
};
|
||||||
let app = Application::build(settings, None).await.unwrap();
|
|
||||||
|
// WHEN: The application is converted to a runnable application
|
||||||
|
let app = Application::build(settings, None);
|
||||||
let _runnable_app = app.make_app();
|
let _runnable_app = app.make_app();
|
||||||
|
|
||||||
|
// THEN: The middleware chain should use CORS settings from configuration
|
||||||
// Note: This is a structural test - actual CORS behavior is tested in integration tests (T016)
|
// Note: This is a structural test - actual CORS behavior is tested in integration tests (T016)
|
||||||
// The fact that this compiles and runs without panic verifies that:
|
// The fact that this compiles and runs without panic verifies that:
|
||||||
// 1. CORS settings are properly loaded
|
// 1. CORS settings are properly loaded
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_application_build_succeeds_in_test_mode() {
|
|
||||||
let settings = create_test_settings();
|
|
||||||
let app = Application::build(settings, None).await;
|
|
||||||
assert!(
|
|
||||||
app.is_ok(),
|
|
||||||
"Application::build() should succeed in test mode"
|
|
||||||
);
|
|
||||||
let app = app.unwrap();
|
|
||||||
assert_eq!(app.port(), 8080);
|
|
||||||
assert_eq!(app.host(), "127.0.0.1");
|
|
||||||
let runnable_app = app.make_app();
|
|
||||||
let _app: App = runnable_app.into();
|
|
||||||
// Success - the application was built with dependencies and can run
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 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 /relays endpoints
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_openapi_spec_includes_relay_endpoints() {
|
|
||||||
let settings = create_test_settings();
|
|
||||||
let app: App = Application::build(settings, None)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.make_app()
|
|
||||||
.into();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let resp = cli.get("/specs").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let spec = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
spec.contains("/relays:"),
|
|
||||||
"OpenAPI spec should include the /relays path, got:\n{spec}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
spec.contains("/relays/{id}/toggle:"),
|
|
||||||
"OpenAPI spec should include the /relays/{{id}}/toggle path, got:\n{spec}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// T039d: Test 2 - OpenAPI spec includes the Relays tag
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_swagger_ui_includes_relays_tag() {
|
|
||||||
let settings = create_test_settings();
|
|
||||||
let app: App = Application::build(settings, None)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.make_app()
|
|
||||||
.into();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let resp = cli.get("/specs").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let spec = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
spec.contains("Relays"),
|
|
||||||
"OpenAPI spec should include a 'Relays' tag, got:\n{spec}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
//! Contract tests for the Relay API HTTP endpoints.
|
|
||||||
//!
|
|
||||||
//! - **T048**: `GET /api/relays` contract tests
|
|
||||||
//! - **T050**: `POST /api/relays/:id/toggle` contract tests
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use poem::{http::StatusCode, test::TestClient};
|
|
||||||
use poem_openapi::OpenApiService;
|
|
||||||
use sta::{
|
|
||||||
domain::relay::{
|
|
||||||
controller::RelayController,
|
|
||||||
repository::RelayLabelRepository,
|
|
||||||
types::{RelayId, RelayLabel, RelayState},
|
|
||||||
},
|
|
||||||
infrastructure::{
|
|
||||||
modbus::mock_controller::MockRelayController,
|
|
||||||
persistence::label_repository::MockRelayLabelRepository,
|
|
||||||
},
|
|
||||||
presentation::api::relay_api::RelayApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Helpers --
|
|
||||||
|
|
||||||
fn build_test_client(
|
|
||||||
controller: Arc<MockRelayController>,
|
|
||||||
repo: Arc<MockRelayLabelRepository>,
|
|
||||||
) -> TestClient<impl poem::Endpoint> {
|
|
||||||
let relay_api = RelayApi::new(controller, repo);
|
|
||||||
let api_service = OpenApiService::new(relay_api, "STA", "0.1");
|
|
||||||
let app = poem::Route::new().nest("/api", api_service);
|
|
||||||
TestClient::new(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a controller with all 8 relays initialised to `Off`.
|
|
||||||
async fn all_relays_off() -> Arc<MockRelayController> {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
for id in 1u8..=8 {
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(id).unwrap(), RelayState::Off)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
controller
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// T048: GET /api/relays
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// T048 – Returns 200 OK.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_returns_200() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – Returns an array of exactly 8 `RelayDto` objects.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_returns_array_of_8_relay_dtos() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body.len(), 8, "Expected 8 relays, got {}", body.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – Relay IDs are 1 through 8, in ascending order.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_relay_ids_are_1_to_8_in_order() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
|
|
||||||
for (index, relay) in body.iter().enumerate() {
|
|
||||||
let expected_id = index + 1;
|
|
||||||
assert_eq!(
|
|
||||||
relay["id"], expected_id,
|
|
||||||
"Relay at index {index} should have id {expected_id}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – Every relay has a `state` field that is either `"on"` or `"off"`.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_each_relay_has_valid_state_field() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
|
|
||||||
for relay in &body {
|
|
||||||
let state = relay["state"].as_str().expect("state should be a string");
|
|
||||||
assert!(
|
|
||||||
state == "on" || state == "off",
|
|
||||||
"state must be 'on' or 'off', got '{state}'"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – Every relay has a `label` field (string).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_each_relay_has_label_field() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
|
|
||||||
for relay in &body {
|
|
||||||
assert!(relay["label"].is_string(), "label should be a string field");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – Relay states in the response match the controller's actual states.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_states_reflect_controller_state() {
|
|
||||||
let controller = all_relays_off().await;
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = build_test_client(controller, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
|
|
||||||
assert_eq!(body[0]["state"], "on", "Relay 1 should be on");
|
|
||||||
assert_eq!(body[1]["state"], "off", "Relay 2 should be off");
|
|
||||||
assert_eq!(body[2]["state"], "on", "Relay 3 should be on");
|
|
||||||
assert_eq!(body[3]["state"], "off", "Relay 4 should be off");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T048 – A relay with a persisted label returns that label.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_all_relays_relay_with_label_returns_label() {
|
|
||||||
let repo = Arc::new(MockRelayLabelRepository::new());
|
|
||||||
repo.save_label(
|
|
||||||
RelayId::new(2).unwrap(),
|
|
||||||
RelayLabel::new("Water Pump".to_string()).unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = build_test_client(all_relays_off().await, repo);
|
|
||||||
let resp = cli.get("/api/relays").send().await;
|
|
||||||
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
|
|
||||||
|
|
||||||
assert_eq!(body[1]["label"], "Water Pump");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// T050: POST /api/relays/:id/toggle
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// T050 – Returns 200 OK with a `RelayDto` body.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_returns_200_with_relay_dto() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/1/toggle").send().await;
|
|
||||||
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert!(body["id"].is_number());
|
|
||||||
assert!(body["state"].is_string());
|
|
||||||
assert!(body["label"].is_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – Returns 404 for relay id 0 (below valid range).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_returns_404_for_id_below_range() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/0/toggle").send().await;
|
|
||||||
|
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – Returns 404 for relay id 9 (above valid range).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_returns_404_for_id_above_range() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/9/toggle").send().await;
|
|
||||||
|
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – State changes from `Off` to `On` and response reflects new state.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_off_to_on_response_shows_on() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/1/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["state"], "on");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – State changes from `On` to `Off` and response reflects new state.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_on_to_off_response_shows_off() {
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
controller
|
|
||||||
.write_relay_state(RelayId::new(5).unwrap(), RelayState::On)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = build_test_client(controller, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/5/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["state"], "off");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – State actually changes in the underlying controller, not just in the response.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_state_actually_changes_in_controller() {
|
|
||||||
let controller = all_relays_off().await;
|
|
||||||
let relay_id = RelayId::new(3).unwrap();
|
|
||||||
|
|
||||||
let cli = build_test_client(controller.clone(), Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
cli.post("/api/relays/3/toggle").send().await;
|
|
||||||
|
|
||||||
let state = controller.read_relay_state(relay_id).await.unwrap();
|
|
||||||
assert_eq!(state, RelayState::On, "Relay 3 should be On in the controller after toggle");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – Response includes the correct relay id.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_response_includes_correct_relay_id() {
|
|
||||||
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
|
|
||||||
|
|
||||||
let resp = cli.post("/api/relays/4/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["id"], 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// T050 – Response includes a persisted label.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn toggle_relay_response_includes_label_when_set() {
|
|
||||||
let repo = Arc::new(MockRelayLabelRepository::new());
|
|
||||||
repo.save_label(
|
|
||||||
RelayId::new(6).unwrap(),
|
|
||||||
RelayLabel::new("Heater".to_string()).unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cli = build_test_client(all_relays_off().await, repo);
|
|
||||||
let resp = cli.post("/api/relays/6/toggle").send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
|
||||||
assert_eq!(body["label"], "Heater");
|
|
||||||
}
|
|
||||||
+10
-12
@@ -13,7 +13,7 @@ use poem::test::TestClient;
|
|||||||
use sta::{settings::Settings, startup::Application};
|
use sta::{settings::Settings, startup::Application};
|
||||||
|
|
||||||
/// Helper function to create a test app with custom CORS settings.
|
/// Helper function to create a test app with custom CORS settings.
|
||||||
async fn get_test_app_with_cors(
|
fn get_test_app_with_cors(
|
||||||
allowed_origins: Vec<String>,
|
allowed_origins: Vec<String>,
|
||||||
allow_credentials: bool,
|
allow_credentials: bool,
|
||||||
max_age_secs: i32,
|
max_age_secs: i32,
|
||||||
@@ -32,8 +32,6 @@ async fn get_test_app_with_cors(
|
|||||||
settings.cors.max_age_secs = max_age_secs;
|
settings.cors.max_age_secs = max_age_secs;
|
||||||
|
|
||||||
Application::build(settings, Some(listener))
|
Application::build(settings, Some(listener))
|
||||||
.await
|
|
||||||
.expect("Failed to build application")
|
|
||||||
.make_app()
|
.make_app()
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -44,7 +42,7 @@ async fn get_test_app_with_cors(
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn preflight_request_returns_cors_headers() {
|
async fn preflight_request_returns_cors_headers() {
|
||||||
// GIVEN: An app with CORS configured for specific origin
|
// GIVEN: An app with CORS configured for specific origin
|
||||||
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
|
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent with Origin header
|
// WHEN: A preflight OPTIONS request is sent with Origin header
|
||||||
@@ -84,7 +82,7 @@ async fn preflight_request_returns_cors_headers() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_request_with_origin_returns_allow_origin_header() {
|
async fn get_request_with_origin_returns_allow_origin_header() {
|
||||||
// GIVEN: An app with CORS configured for specific origin
|
// GIVEN: An app with CORS configured for specific origin
|
||||||
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
|
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A GET request is sent with Origin header
|
// WHEN: A GET request is sent with Origin header
|
||||||
@@ -121,7 +119,7 @@ async fn preflight_response_includes_max_age_from_config() {
|
|||||||
vec!["http://localhost:5173".to_string()],
|
vec!["http://localhost:5173".to_string()],
|
||||||
false,
|
false,
|
||||||
custom_max_age,
|
custom_max_age,
|
||||||
).await;
|
);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent
|
// WHEN: A preflight OPTIONS request is sent
|
||||||
@@ -155,7 +153,7 @@ async fn response_includes_allow_credentials_when_configured() {
|
|||||||
vec!["http://localhost:5173".to_string()],
|
vec!["http://localhost:5173".to_string()],
|
||||||
true, // allow_credentials
|
true, // allow_credentials
|
||||||
3600,
|
3600,
|
||||||
).await;
|
);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent
|
// WHEN: A preflight OPTIONS request is sent
|
||||||
@@ -189,7 +187,7 @@ async fn response_does_not_include_credentials_when_disabled() {
|
|||||||
vec!["http://localhost:5173".to_string()],
|
vec!["http://localhost:5173".to_string()],
|
||||||
false, // allow_credentials
|
false, // allow_credentials
|
||||||
3600,
|
3600,
|
||||||
).await;
|
);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent
|
// WHEN: A preflight OPTIONS request is sent
|
||||||
@@ -219,7 +217,7 @@ async fn response_does_not_include_credentials_when_disabled() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn preflight_response_includes_correct_allowed_methods() {
|
async fn preflight_response_includes_correct_allowed_methods() {
|
||||||
// GIVEN: An app with CORS configured
|
// GIVEN: An app with CORS configured
|
||||||
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
|
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent
|
// WHEN: A preflight OPTIONS request is sent
|
||||||
@@ -262,7 +260,7 @@ async fn wildcard_origin_works_with_credentials_disabled() {
|
|||||||
vec!["*".to_string()],
|
vec!["*".to_string()],
|
||||||
false, // credentials MUST be false with wildcard
|
false, // credentials MUST be false with wildcard
|
||||||
3600,
|
3600,
|
||||||
).await;
|
);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A preflight OPTIONS request is sent with any origin
|
// WHEN: A preflight OPTIONS request is sent with any origin
|
||||||
@@ -301,7 +299,7 @@ async fn multiple_origins_are_supported() {
|
|||||||
],
|
],
|
||||||
false,
|
false,
|
||||||
3600,
|
3600,
|
||||||
).await;
|
);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A request is sent with the first origin
|
// WHEN: A request is sent with the first origin
|
||||||
@@ -343,7 +341,7 @@ async fn multiple_origins_are_supported() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unauthorized_origin_is_rejected() {
|
async fn unauthorized_origin_is_rejected() {
|
||||||
// GIVEN: An app with CORS configured for specific origins only
|
// GIVEN: An app with CORS configured for specific origins only
|
||||||
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
|
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
|
||||||
let client = TestClient::new(app);
|
let client = TestClient::new(app);
|
||||||
|
|
||||||
// WHEN: A request is sent with an unauthorized origin
|
// WHEN: A request is sent with an unauthorized origin
|
||||||
|
|||||||
@@ -427,10 +427,7 @@ async fn test_repository_error_handling() {
|
|||||||
|
|
||||||
// Test with invalid relay ID (should be caught by domain validation)
|
// Test with invalid relay ID (should be caught by domain validation)
|
||||||
let invalid_relay_id = RelayId::new(9); // This will fail validation
|
let invalid_relay_id = RelayId::new(9); // This will fail validation
|
||||||
assert!(
|
assert!(invalid_relay_id.is_err(), "Invalid relay ID should fail validation");
|
||||||
invalid_relay_id.is_err(),
|
|
||||||
"Invalid relay ID should fail validation"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test with invalid label (should be caught by domain validation)
|
// Test with invalid label (should be caught by domain validation)
|
||||||
let invalid_label = RelayLabel::new("".to_string()); // Empty label
|
let invalid_label = RelayLabel::new("".to_string()); // Empty label
|
||||||
@@ -447,7 +444,7 @@ async fn test_concurrent_operations_are_thread_safe() {
|
|||||||
// Since SqliteRelayLabelRepository doesn't implement Clone, we'll test
|
// Since SqliteRelayLabelRepository doesn't implement Clone, we'll test
|
||||||
// sequential operations which still verify the repository handles
|
// sequential operations which still verify the repository handles
|
||||||
// multiple operations correctly
|
// multiple operations correctly
|
||||||
|
|
||||||
// Save multiple labels sequentially
|
// Save multiple labels sequentially
|
||||||
let relay_id1 = RelayId::new(1).expect("Valid relay ID");
|
let relay_id1 = RelayId::new(1).expect("Valid relay ID");
|
||||||
let label1 = RelayLabel::new("Task1".to_string()).expect("Valid label");
|
let label1 = RelayLabel::new("Task1".to_string()).expect("Valid label");
|
||||||
@@ -473,4 +470,4 @@ async fn test_concurrent_operations_are_thread_safe() {
|
|||||||
.await
|
.await
|
||||||
.expect("get_all_labels should succeed");
|
.expect("get_all_labels should succeed");
|
||||||
assert_eq!(all_labels.len(), 3, "Should have all 3 labels");
|
assert_eq!(all_labels.len(), 3, "Should have all 3 labels");
|
||||||
}
|
}
|
||||||
Generated
+209
-12
@@ -23,6 +23,76 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cachix": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"git-hooks": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760971495,
|
||||||
|
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "cachix",
|
||||||
|
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "latest",
|
||||||
|
"repo": "cachix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv": {
|
||||||
|
"inputs": {
|
||||||
|
"cachix": "cachix",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nix": "nix",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1766843567,
|
||||||
|
"narHash": "sha256-062oL6KZCH7ePf4BBG61OdFJUh5ovw6zTpd/lVwy/xk=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "d0f2c8545f09e5aba9d321079a284b550371879d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv-root": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fenix": {
|
"fenix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
@@ -45,6 +115,43 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761588595,
|
||||||
|
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760948891,
|
||||||
|
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@@ -79,25 +186,115 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1760663237,
|
||||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
|
||||||
"owner": "nixos",
|
"owner": "cachix",
|
||||||
"repo": "nixpkgs",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "cachix",
|
||||||
"ref": "nixos-unstable",
|
"repo": "git-hooks.nix",
|
||||||
"repo": "nixpkgs",
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"flake-parts": [
|
||||||
|
"devenv",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
|
"git-hooks-nix": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-23-11": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs-regression": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761648602,
|
||||||
|
"narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "devenv-2.30.6",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1764580874,
|
||||||
|
"narHash": "sha256-GMlWyeVh6fVuPeJI+ZmbJVV8DDS5wfdfDY88FHt5g/8=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"alejandra": "alejandra",
|
"alejandra": "alejandra",
|
||||||
|
"devenv": "devenv",
|
||||||
|
"devenv-root": "devenv-root",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
@@ -127,11 +324,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777950921,
|
"lastModified": 1766803264,
|
||||||
"narHash": "sha256-NpOgt8ISaHTDNJZjNUfwFfbieKfRXzab4WKM31gZCGA=",
|
"narHash": "sha256-eGK6He8BR6L7N73kyyjz/vGxZX1Usnr8Gwfs3D18KgE=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "366ea19e0e55b768f74b7a0b2a20f847e7ae828d",
|
"rev": "6b5c52313aaf3f3e1a0a6757bb89846edfb5195c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -1,69 +1,57 @@
|
|||||||
{
|
{
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
alejandra = {
|
alejandra = {
|
||||||
url = "github:kamadorueda/alejandra/4.0.0";
|
url = "github:kamadorueda/alejandra/4.0.0";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
devenv = {
|
||||||
|
url = "github:cachix/devenv";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
devenv-root = {
|
||||||
|
url = "file+file:///dev/null";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
nixConfig = {
|
nixConfig = {
|
||||||
extra-trusted-public-keys = [
|
extra-trusted-public-keys = [
|
||||||
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
|
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
|
||||||
"phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="
|
|
||||||
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
|
||||||
];
|
];
|
||||||
extra-substituters = [
|
extra-substituters = [
|
||||||
"https://phundrak.cachix.org?priority=10"
|
"https://devenv.cachix.org"
|
||||||
"https://nix-community.cachix.org?priority=20"
|
|
||||||
"https://cache.nixos.org?priority=30"
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
alejandra,
|
alejandra,
|
||||||
...
|
...
|
||||||
}:
|
} @ inputs:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system: let
|
system: let
|
||||||
overlays = [(import rust-overlay)];
|
overlays = [(import rust-overlay)];
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
rustVersion = pkgs.rust-bin.stable.latest.default;
|
rustVersion = pkgs.rust-bin.stable.latest.default;
|
||||||
targets = {
|
rustPlatform = pkgs.makeRustPlatform {
|
||||||
linux-x86_64 = {
|
cargo = rustVersion;
|
||||||
crossPkgs = pkgs;
|
rustc = rustVersion;
|
||||||
triple = "x86_64-unknown-linux-gnu";
|
|
||||||
};
|
|
||||||
linux-aarch64 = {
|
|
||||||
crossPkgs = pkgs.pkgsCross.aarch64-multiplatform;
|
|
||||||
triple = "aarch64-unknown-linux-gnu";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
mkRustBuild = import ./nix/backend.nix;
|
|
||||||
packages = {
|
|
||||||
linux-x86_64 = mkRustBuild targets.linux-x86_64;
|
|
||||||
linux-aarch64 = mkRustBuild targets.linux-aarch64;
|
|
||||||
};
|
|
||||||
defaultBySystem = {
|
|
||||||
"x86_64-linux" = packages.linux-x86_64;
|
|
||||||
"aarch64-linux" = packages.linux-aarch64;
|
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
formatter = alejandra.defaultPackage.${system};
|
formatter = alejandra.defaultPackage.${system};
|
||||||
packages.backend =
|
packages = import ./nix/package.nix {inherit pkgs rustPlatform;};
|
||||||
packages
|
devShell = import ./nix/shell.nix {
|
||||||
// {
|
inherit inputs pkgs self rustVersion system;
|
||||||
default = defaultBySystem.${system} or packages.linux-x86_64;
|
};
|
||||||
};
|
|
||||||
devShell = import ./nix/shell.nix {inherit pkgs rustVersion;};
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ release-build:
|
|||||||
release-run:
|
release-run:
|
||||||
cargo run --release
|
cargo run --release
|
||||||
|
|
||||||
[env("SQLX_OFFLINE", "1")]
|
|
||||||
test:
|
test:
|
||||||
cargo test --all --all-targets
|
cargo test --all --all-targets
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
target: let
|
|
||||||
cargoToml = fromTOML (builtins.readFile ../backend/Cargo.toml);
|
|
||||||
inherit (cargoToml.package) name version;
|
|
||||||
pkgs = target.crossPkgs;
|
|
||||||
buildArgs = {
|
|
||||||
pname = name;
|
|
||||||
inherit version;
|
|
||||||
src = pkgs.lib.cleanSource ../.;
|
|
||||||
cargoLock.lockFile = ../Cargo.lock;
|
|
||||||
useNextest = true;
|
|
||||||
meta = {
|
|
||||||
inherit (cargoToml.package) description homepage;
|
|
||||||
};
|
|
||||||
postBuild = "${pkgs.upx}/bin/upx target/*/release/*${name}";
|
|
||||||
};
|
|
||||||
rustVersion = pkgs.rust-bin.stable.latest.default.override {
|
|
||||||
targets = [target.triple];
|
|
||||||
};
|
|
||||||
rustPlatform = target.crossPkgs.makeRustPlatform {
|
|
||||||
cargo = rustVersion;
|
|
||||||
rustc = rustVersion;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
rustPlatform.buildRustPackage buildArgs
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
rustPlatform,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
|
||||||
|
name = cargoToml.package.name;
|
||||||
|
version = cargoToml.package.version;
|
||||||
|
rustBuild = rustPlatform.buildRustPackage {
|
||||||
|
pname = name;
|
||||||
|
inherit version;
|
||||||
|
src = ../.;
|
||||||
|
cargoLock.lockFile = ../Cargo.lock;
|
||||||
|
};
|
||||||
|
settingsDir = pkgs.runCommand "settings" {} ''
|
||||||
|
mkdir -p $out/settings
|
||||||
|
cp ${../settings}/*.yaml $out/settings/
|
||||||
|
'';
|
||||||
|
in {
|
||||||
|
jj-mcp = rustBuild;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
rust-overlay,
|
||||||
|
inputs,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
overlays = [(import rust-overlay)];
|
||||||
|
in rec {
|
||||||
|
pkgs = import inputs.nixpkgs {inherit system overlays;};
|
||||||
|
version = pkgs.rust-bin.stable.latest.default;
|
||||||
|
}
|
||||||
+51
-25
@@ -1,32 +1,58 @@
|
|||||||
{
|
{
|
||||||
|
inputs,
|
||||||
pkgs,
|
pkgs,
|
||||||
|
self,
|
||||||
rustVersion,
|
rustVersion,
|
||||||
|
system,
|
||||||
|
...
|
||||||
}:
|
}:
|
||||||
pkgs.mkShell {
|
inputs.devenv.lib.mkShell {
|
||||||
packages = with pkgs; [
|
inherit inputs pkgs;
|
||||||
(rustVersion.override {
|
modules = [
|
||||||
extensions = [
|
{
|
||||||
"clippy"
|
packages = with pkgs; [
|
||||||
"rust-src"
|
# Backend
|
||||||
"rust-analyzer"
|
(rustVersion.override {
|
||||||
"rustfmt"
|
extensions = [
|
||||||
];
|
"clippy"
|
||||||
})
|
"rust-src"
|
||||||
bacon
|
"rust-analyzer"
|
||||||
cargo-deny
|
"rustfmt"
|
||||||
cargo-edit
|
];
|
||||||
cargo-shuttle
|
})
|
||||||
cargo-tarpaulin
|
bacon
|
||||||
just
|
cargo-deny
|
||||||
marksman # Markdown LSP server
|
cargo-edit
|
||||||
sqlx-cli
|
cargo-shuttle
|
||||||
tombi # TOML LSP server
|
cargo-tarpaulin
|
||||||
|
just
|
||||||
|
marksman # Markdown LSP server
|
||||||
|
sqlx-cli
|
||||||
|
tombi # TOML LSP server
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
nodejs_24
|
nodejs_24
|
||||||
rustywind # tailwind
|
rustywind # tailwind
|
||||||
prettier
|
nodePackages.prettier
|
||||||
eslint
|
nodePackages.eslint
|
||||||
pnpm
|
nodePackages.pnpm
|
||||||
|
];
|
||||||
|
|
||||||
|
processes.run.exec = "bacon run";
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
echo "🦀 Rust MCP development environment loaded!"
|
||||||
|
echo "📦 Rust version: $(rustc --version)"
|
||||||
|
echo "📦 Cargo version: $(cargo --version)"
|
||||||
|
echo ""
|
||||||
|
echo "Available tools:"
|
||||||
|
echo " - rust-analyzer (LSP)"
|
||||||
|
echo " - clippy (linter)"
|
||||||
|
echo " - rustfmt (formatter)"
|
||||||
|
echo " - bacon (continuous testing/linting)"
|
||||||
|
echo " - cargo-deny (dependency checker)"
|
||||||
|
echo " - cargo-tarpaulin (code coverage)"
|
||||||
|
'';
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#+title: Implementation Tasks: Modbus Relay Control System
|
#+title: Implementation Tasks: Modbus Relay Control System
|
||||||
#+author: Lucien Cartier-Tilet
|
#+author: Lucien Cartier-Tilet
|
||||||
#+email: lucien@phundrak.com
|
#+email: lucien@phundrak.com
|
||||||
#+startup: content align hideblocks
|
|
||||||
#+options: ^:nil
|
#+options: ^:nil
|
||||||
#+LATEX_CLASS_OPTIONS: [a4paper,10pt]
|
#+LATEX_CLASS_OPTIONS: [a4paper,10pt]
|
||||||
#+LATEX_HEADER: \makeatletter \@ifpackageloaded{geometry}{\geometry{margin=2cm}}{\usepackage[margin=2cm]{geometry}} \makeatother
|
#+LATEX_HEADER: \makeatletter \@ifpackageloaded{geometry}{\geometry{margin=2cm}}{\usepackage[margin=2cm]{geometry}} \makeatother
|
||||||
@@ -587,7 +586,7 @@ CLOSED: [2026-01-22 jeu. 00:02]
|
|||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [3/5]
|
** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [1/5]
|
||||||
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
|
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
|
||||||
*Goal*: View current state of all 8 relays + toggle individual relay on/off
|
*Goal*: View current state of all 8 relays + toggle individual relay on/off
|
||||||
|
|
||||||
@@ -617,108 +616,27 @@ 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
|
||||||
|
|
||||||
*** DONE Presentation Layer (Backend API) [3/3]
|
*** STARTED Presentation Layer (Backend API) [0/2]
|
||||||
CLOSED: [2026-05-14 jeu. 18:43]
|
|
||||||
- State "DONE" from "TODO" [2026-05-14 jeu. 18:43]
|
|
||||||
- State "STARTED" from "TODO" [2026-01-23 ven. 20:42]
|
- State "STARTED" from "TODO" [2026-01-23 ven. 20:42]
|
||||||
- [X] *T045* [US1] [TDD] Define =RelayDto= in presentation layer
|
- [ ] *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
|
||||||
- [X] *T046* [US1] [TDD] Define API error responses
|
- [ ] *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=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *Complexity*: Low | *Uncertainty*: Low
|
||||||
- [X] *T047* [US1] [TDD] Create =RelayApi= struct with dependency injection
|
|
||||||
- Create =RelayApi= struct that holds dependencies:
|
|
||||||
- =relay_controller: Arc<dyn RelayController>=
|
|
||||||
- =label_repository: Arc<dyn RelayLabelRepository>=
|
|
||||||
- Implement constructor: =RelayApi::new(controller, repository) -> Self=
|
|
||||||
- Add =#[derive(Clone)]= to allow sharing across poem-openapi
|
|
||||||
- *File*: =src/presentation/api/relay_api.rs= or =src/route/relay.rs=
|
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
|
||||||
|
|
||||||
*TDD Checklist*:
|
|
||||||
|
|
||||||
- [ ] Test: =RelayApi::new()= creates instance with provided dependencies
|
|
||||||
- [ ] Test: =RelayApi= can be cloned (required for poem-openapi)
|
|
||||||
- [ ] Test: Constructor stores both controller and repository
|
|
||||||
|
|
||||||
*Pseudocode*:
|
|
||||||
|
|
||||||
#+begin_src rust
|
|
||||||
use std::sync::Arc;
|
|
||||||
use crate::domain::relay::{
|
|
||||||
controller::RelayController,
|
|
||||||
repository::RelayLabelRepository,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// API handler for relay control endpoints.
|
|
||||||
///
|
|
||||||
/// This struct holds the dependencies needed for relay operations
|
|
||||||
/// and implements the poem-openapi handlers.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RelayApi {
|
|
||||||
relay_controller: Arc<dyn RelayController>,
|
|
||||||
label_repository: Arc<dyn RelayLabelRepository>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RelayApi {
|
|
||||||
/// Creates a new RelayApi with the provided dependencies.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `relay_controller` - Controller for reading/writing relay states
|
|
||||||
/// * `label_repository` - Repository for managing relay labels
|
|
||||||
pub fn new(
|
|
||||||
relay_controller: Arc<dyn RelayController>,
|
|
||||||
label_repository: Arc<dyn RelayLabelRepository>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
relay_controller,
|
|
||||||
label_repository,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}6 lerolero 7
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::infrastructure::modbus::MockRelayController;
|
|
||||||
use crate::infrastructure::persistence::MockLabelRepository;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_relay_api_new_creates_instance() {
|
|
||||||
// GIVEN: Mock dependencies
|
|
||||||
let controller = Arc::new(MockRelayController::new());
|
|
||||||
let repository = Arc::new(MockLabelRepository::new());
|
|
||||||
|
|
||||||
// WHEN: Creating RelayApi
|
|
||||||
let api = RelayApi::new(controller.clone(), repository.clone());
|
|
||||||
|
|
||||||
// THEN: Instance is created successfully
|
|
||||||
// Verify by checking that we can clone it (required for poem-openapi)
|
|
||||||
let _cloned_api = api.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*Note*: After this task, T048-T051 will add endpoint methods to this struct.
|
|
||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
*** DONE T039: Dependency Injection Setup (DECOMPOSED) [8/8]
|
*** TODO T039: Dependency Injection Setup (DECOMPOSED) [0/8]
|
||||||
CLOSED: [2026-05-14 jeu. 20:09]
|
|
||||||
- State "DONE" from "STARTED" [2026-05-14 jeu. 20:09]
|
|
||||||
- State "STARTED" from "TODO" [2026-03-06 ven. 22:11]
|
|
||||||
- Complexity :: High → Broken into 4 sub-tasks
|
- Complexity :: High → Broken into 4 sub-tasks
|
||||||
- Uncertainty :: Medium
|
- Uncertainty :: Medium
|
||||||
- Rationale :: Graceful degradation (FR-023), conditional mock/real controller
|
- Rationale :: Graceful degradation (FR-023), conditional mock/real controller
|
||||||
- Prerequisites :: T047 (RelayApi struct) must be complete before T039c
|
|
||||||
|
|
||||||
- [X] *T039a* [US1] [TDD] Create =ModbusRelayController= factory with retry and fallback
|
- [ ] *T039a* [US1] [TDD] Create =ModbusRelayController= factory with retry and fallback
|
||||||
|
|
||||||
- Factory function: ~create_relay_controller(settings, use_mock) => Arc~
|
- Factory function: ~create_relay_controller(settings, use_mock) => Arc~
|
||||||
- Retry 3 times with 2s backoff on connection failure
|
- Retry 3 times with 2s backoff on connection failure
|
||||||
@@ -774,12 +692,13 @@ CLOSED: [2026-05-14 jeu. 20:09]
|
|||||||
|
|
||||||
*TDD Checklist*:
|
*TDD Checklist*:
|
||||||
|
|
||||||
- [X] Test: ~use_mock=true~ returns =MockRelayController= immediately
|
- [ ] Test: use_mock=true returns =MockRelayController= immediately
|
||||||
- [X] Test: Successful connection returns =ModbusRelayController=
|
- [ ] Test: Successful connection returns =ModbusRelayController=
|
||||||
- [X] Test: Connection failure after 3 retries returns =MockRelayController=
|
- [ ] Test: Connection failure after 3 retries returns =MockRelayController=
|
||||||
- [X] Test: Retry delays are 2 seconds between attempts
|
- [ ] Test: Retry delays are 2 seconds between attempts
|
||||||
- [X] Test: Logs appropriate messages for each connection attempt
|
- [ ] Test: Logs appropriate messages for each connection attempt
|
||||||
- [X] *T039b* [US4] [TDD] Create =RelayLabelRepository= factory
|
|
||||||
|
- [ ] *T039b* [US4] [TDD] Create =RelayLabelRepositor=y factory
|
||||||
|
|
||||||
- Factory function: ~create_label_repository(db_path, use_mock) => Arc~
|
- Factory function: ~create_label_repository(db_path, use_mock) => Arc~
|
||||||
- If use_mock: return =MockLabelRepository=
|
- If use_mock: return =MockLabelRepository=
|
||||||
@@ -806,19 +725,17 @@ CLOSED: [2026-05-14 jeu. 20:09]
|
|||||||
|
|
||||||
*TDD Checklist*:
|
*TDD Checklist*:
|
||||||
|
|
||||||
- [X] Test: use_mock=true returns =MockLabelRepository=
|
- [ ] Test: use_mock=true returns =MockLabelRepository=
|
||||||
- [X] Test: use_mock=false returns =SQLiteLabelRepository=
|
- [ ] Test: use_mock=false returns =SQLiteLabelRepository=
|
||||||
- [X] Test: Invalid =db_path= returns =RepositoryError=
|
- [ ] Test: Invalid =db_path= returns =RepositoryError=
|
||||||
- [X] *T039c* [US1] [TDD] Wire dependencies in =Application::build()=
|
|
||||||
|
- [ ] *T039c* [US1] [TDD] Wire dependencies in =Application::build()=
|
||||||
|
|
||||||
- *Prerequisites*: T047 must be complete (RelayApi struct created)
|
|
||||||
- Determine test mode: ~cfg!(test) || env::var("CI").is_ok()~
|
- Determine test mode: ~cfg!(test) || env::var("CI").is_ok()~
|
||||||
- Call =create_relay_controller()= and =create_label_repository()=
|
- Call =create_relay_controller()= and =create_label_repository()=
|
||||||
- Create =RelayApi= instance with dependencies (requires T047)
|
- Pass dependencies to =RelayApi::new()=
|
||||||
- Pass =RelayApi= to OpenAPI service
|
|
||||||
- *File*: =src/startup.rs=
|
- *File*: =src/startup.rs=
|
||||||
- *Complexity*: Medium | *Uncertainty*: Low
|
- *Complexity*: Medium | *Uncertainty*: Low
|
||||||
- *Note*: Tests for T039c have been written (they currently pass trivially)
|
|
||||||
|
|
||||||
*Pseudocode*:
|
*Pseudocode*:
|
||||||
|
|
||||||
@@ -853,10 +770,12 @@ CLOSED: [2026-05-14 jeu. 20:09]
|
|||||||
|
|
||||||
*TDD Checklist*:
|
*TDD Checklist*:
|
||||||
|
|
||||||
- [X] Test: =Application::build()= succeeds in test mode
|
- [ ] Test: =Application::build()= succeeds in test mode
|
||||||
- [X] Test: =Application::build()= creates correct mock dependencies when CI=true
|
- [ ] Test: =Application::build()= creates correct mock dependencies when CI=true
|
||||||
- [X] Test: =Application::build()= creates real dependencies when not in test mode
|
- [ ] Test: =Application::build()= creates real dependencies when not in test mode
|
||||||
- [X] *T039d* [US1] [TDD] Register =RelayApi= in route aggregator
|
|
||||||
|
- [ ] *T039d* [US1] [TDD] Register =RelayApi= in route aggregator
|
||||||
|
|
||||||
- Add =RelayApi= to OpenAPI service
|
- Add =RelayApi= to OpenAPI service
|
||||||
- Tag: "Relays"
|
- Tag: "Relays"
|
||||||
- *File*: =src/startup.rs=
|
- *File*: =src/startup.rs=
|
||||||
@@ -864,28 +783,28 @@ CLOSED: [2026-05-14 jeu. 20:09]
|
|||||||
|
|
||||||
*TDD Checklist*:
|
*TDD Checklist*:
|
||||||
|
|
||||||
- [X] Test: OpenAPI spec includes =/api/relays= endpoints
|
- [ ] Test: OpenAPI spec includes =/api/relays= endpoints
|
||||||
- [X] Test: Swagger UI renders =Relays= tag
|
- [ ] Test: Swagger UI renders =Relays= tag
|
||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
- [X] *T048* [US1] [TDD] Write contract tests for =GET /api/relays=
|
- [ ] *T048* [US1] [TDD] Write contract tests for =GET /api/relays=
|
||||||
- Test: Returns 200 with array of 8 =RelayDto=
|
- Test: Returns 200 with array of 8 =RelayDto=
|
||||||
- Test: Each relay has id 1-8, state, and optional label
|
- Test: Each relay has id 1-8, state, and optional label
|
||||||
- *File*: =tests/contract/test_relay_api.rs=
|
- *File*: =tests/contract/test_relay_api.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *Complexity*: Low | *Uncertainty*: Low
|
||||||
- [X] *T049* [US1] [TDD] Implement =GET /api/relays= endpoint
|
- [ ] *T049* [US1] [TDD] Implement =GET /api/relays= endpoint
|
||||||
- ~#[oai(path = "/relays", method = "get")]~
|
- ~#[oai(path = "/relays", method = "get")]~
|
||||||
- Call =GetAllRelaysUseCase=, map to =RelayDto=
|
- Call =GetAllRelaysUseCase=, map to =RelayDto=
|
||||||
- *File*: =src/presentation/api/relay_api.rs=
|
- *File*: =src/presentation/api/relay_api.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *Complexity*: Low | *Uncertainty*: Low
|
||||||
- [X] *T050* [US1] [TDD] Write contract tests for =POST /api/relays/{id}/toggle=
|
- [ ] *T050* [US1] [TDD] Write contract tests for =POST /api/relays/{id}/toggle=
|
||||||
- Test: Returns 200 with updated =RelayDto=
|
- Test: Returns 200 with updated =RelayDto=
|
||||||
- Test: Returns 404 for id < 1 or id > 8
|
- Test: Returns 404 for id < 1 or id > 8
|
||||||
- Test: State actually changes in controller
|
- Test: State actually changes in controller
|
||||||
- *File*: =tests/contract/test_relay_api.rs=
|
- *File*: =tests/contract/test_relay_api.rs=
|
||||||
- *Complexity*: Low | *Uncertainty*: Low
|
- *Complexity*: Low | *Uncertainty*: Low
|
||||||
- [X] *T051* [US1] [TDD] Implement =POST /api/relays/{id}/toggle= endpoint
|
- [ ] *T051* [US1] [TDD] Implement =POST /api/relays/{id}/toggle= endpoint
|
||||||
- ~#[oai(path = "/relays/:id/toggle", method = "post")]~
|
- ~#[oai(path = "/relays/:id/toggle", method = "post")]~
|
||||||
- Parse id, call =ToggleRelayUseCase=, return updated state
|
- Parse id, call =ToggleRelayUseCase=, return updated state
|
||||||
- *File*: =src/presentation/api/relay_api.rs=
|
- *File*: =src/presentation/api/relay_api.rs=
|
||||||
|
|||||||
Reference in New Issue
Block a user