Files
sta/backend/src/infrastructure/modbus/client_test.rs
Lucien Cartier-Tilet 8d6ff23cbc feat(infrastructure): implement ModbusRelayController with timeout handling
Add real Modbus TCP communication through ModbusRelayController:
- T025a: Connection setup with Arc<Mutex<Context>> and configurable timeout
- T025b: read_coils_with_timeout() helper wrapping tokio::time::timeout
- T025c: write_single_coil_with_timeout() with nested Result handling
- T025d: RelayController::read_relay_state() using timeout helper
- T025e: RelayController::write_relay_state() with state conversion
- Additional: Complete RelayController trait with all required methods
- Domain support: RelayId::to_modbus_address(), RelayState conversion helpers

Implements hexagonal architecture with infrastructure layer properly
depending on domain types. Includes structured logging at key operations.

TDD phase: green (implementation following test stubs from T023-T024)

Ref: T025a-T025e (specs/001-modbus-relay-control/tasks.md)
2026-01-11 00:40:11 +01:00

675 lines
26 KiB
Rust

//! Tests for `ModbusRelayController`
//!
//! These tests cover T025a through T025e of the implementation plan.
//! Note: These tests require mocking or a test Modbus server since they test
//! real TCP connections and Modbus protocol interactions.
use super::*;
#[cfg(test)]
mod t025a_connection_setup_tests {
use super::*;
/// T025a Test 1: `new()` with valid config connects successfully
///
/// This test verifies that `ModbusRelayController::new()` can establish
/// a connection to a valid Modbus TCP server.
///
/// NOTE: This test requires a running Modbus TCP server at 127.0.0.1:5020
/// or should be modified to use a mock server. Mark with #[ignore] if no server available.
#[tokio::test]
#[ignore = "Requires running Modbus TCP server"]
async fn test_new_with_valid_config_connects_successfully() {
// Arrange: Use localhost test server
let host = "127.0.0.1";
let port = 5020; // Test Modbus TCP port
let slave_id = 1;
let timeout_secs = 5;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
// Assert: Connection should succeed
assert!(
result.is_ok(),
"Expected successful connection to test Modbus server, got error: {:?}",
result.err()
);
}
/// T025a Test 2: `new()` with invalid host returns `ConnectionError`
///
/// This test verifies that `ModbusRelayController::new()` returns a proper
/// `ConnectionError` when given an invalid hostname.
#[tokio::test]
async fn test_new_with_invalid_host_returns_connection_error() {
// Arrange: Use invalid host format
let host = "not a valid host!!!";
let port = 502;
let slave_id = 1;
let timeout_secs = 5;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
// Assert: Should return ConnectionError
assert!(result.is_err(), "Expected ConnectionError for invalid host");
let error = result.unwrap_err();
assert!(
matches!(
error,
crate::domain::relay::controller::ControllerError::ConnectionError(_)
),
"Expected ControllerError::ConnectionError, got {error:?}"
);
}
/// T025a Test 3: `new()` with unreachable port returns `ConnectionError`
///
/// This test verifies that attempting to connect to a closed/unreachable port
/// results in a `ConnectionError`. Uses localhost port 1 (closed) for instant
/// "connection refused" error without waiting for TCP timeout.
#[tokio::test]
async fn test_new_with_unreachable_host_returns_connection_error() {
// Arrange: Use localhost with a closed port (port 1 is typically closed)
// This gives instant "connection refused" instead of waiting for TCP timeout
let host = "127.0.0.1";
let port = 1; // Closed port for instant connection failure
let slave_id = 1;
let timeout_secs = 1;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
// Assert: Should return ConnectionError
assert!(
result.is_err(),
"Expected ConnectionError for unreachable host"
);
}
/// T025a Test 4: `new()` stores correct `timeout_duration`
///
/// This test verifies that the `timeout_secs` parameter is correctly
/// stored in the controller instance.
///
/// NOTE: This test requires access to internal state or a working connection
/// to verify timeout behavior. Mark with #[ignore] if no server available.
#[tokio::test]
#[ignore = "Requires running Modbus TCP server or refactoring to expose timeout"]
async fn test_new_stores_correct_timeout_duration() {
// Arrange
let host = "127.0.0.1";
let port = 5020;
let slave_id = 1;
let timeout_secs = 10;
// Act
let controller = ModbusRelayController::new(host, port, slave_id, timeout_secs)
.await
.expect("Failed to create controller");
// Assert: Verify timeout is stored correctly
// Note: This requires either:
// 1. A getter method for timeout_duration, or
// 2. Testing timeout behavior by triggering an actual timeout, or
// 3. Refactoring to make timeout_duration accessible for testing
//
// For now, this is a placeholder that documents the requirement
drop(controller);
}
}
#[cfg(test)]
mod t025b_read_coils_timeout_tests {
// Note: These tests require access to private method read_coils_with_timeout()
// Options:
// 1. Make the method pub(crate) for testing
// 2. Test indirectly through public RelayController methods
// 3. Use a test-only feature flag to expose for testing
//
// For now, we'll test through the public interface (read_relay_state)
use super::*;
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::RelayId,
};
/// T025b Test 1: `read_coils_with_timeout()` returns coil values on success
///
/// This test verifies that reading coils succeeds when the Modbus server
/// responds correctly.
///
/// NOTE: Tests through `read_relay_state()` public interface
#[tokio::test]
#[ignore = "Requires running Modbus TCP server with known state"]
async fn test_read_coils_returns_coil_values_on_success() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Read relay state (internally calls read_coils_with_timeout)
let result = controller.read_relay_state(relay_id).await;
// Assert: Should succeed with a valid state
assert!(
result.is_ok(),
"Expected successful coil read, got error: {:?}",
result.err()
);
}
/// T025b Test 2: `read_coils_with_timeout()` returns Timeout error when operation exceeds timeout
///
/// This test verifies that the timeout mechanism works correctly.
///
/// NOTE: Requires either a slow/non-responsive Modbus server or mocking
#[tokio::test]
#[ignore = "Requires slow/non-responsive Modbus server or mocking"]
async fn test_read_coils_returns_timeout_on_slow_response() {
// Arrange: Connect with very short timeout
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 1)
.await
.expect("Failed to connect");
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Attempt to read (server should be configured to delay response)
let result = controller.read_relay_state(relay_id).await;
// Assert: Should return Timeout error
assert!(result.is_err(), "Expected timeout error");
if let Err(ControllerError::Timeout(secs)) = result {
assert_eq!(secs, 1, "Timeout duration should match configured value");
} else {
panic!("Expected ControllerError::Timeout, got {result:?}");
}
}
/// T025b Test 3: `read_coils_with_timeout()` returns `ConnectionError` on `io::Error`
///
/// This test verifies that IO errors are properly wrapped as `ConnectionError`.
#[tokio::test]
#[ignore = "Requires Modbus server that drops connections"]
async fn test_read_coils_returns_connection_error_on_io_error() {
// Arrange: Connect to server that will drop connection
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect");
// Server should be configured to drop connection after initial connect
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Attempt to read after connection is dropped
let result = controller.read_relay_state(relay_id).await;
// Assert: Should return ConnectionError
assert!(result.is_err(), "Expected connection error");
assert!(
matches!(result, Err(ControllerError::ConnectionError(_))),
"Expected ConnectionError, got {result:?}"
);
}
/// T025b Test 4: `read_coils_with_timeout()` returns `ModbusException` on protocol error
///
/// This test verifies that Modbus protocol exceptions are properly handled.
#[tokio::test]
#[ignore = "Requires Modbus server that returns exception codes"]
async fn test_read_coils_returns_modbus_exception_on_protocol_error() {
// Arrange: Connect to server configured to return Modbus exception
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect");
// Server should be configured to return exception for this relay ID
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Attempt to read (should trigger Modbus exception)
let result = controller.read_relay_state(relay_id).await;
// Assert: Should return ModbusException error
assert!(result.is_err(), "Expected Modbus exception");
assert!(
matches!(result, Err(ControllerError::ModbusException(_))),
"Expected ModbusException, got {result:?}"
);
}
}
#[cfg(test)]
mod t025c_write_single_coil_timeout_tests {
use super::*;
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::{RelayId, RelayState},
};
/// T025c Test 1: `write_single_coil_with_timeout()` succeeds for valid write
///
/// This test verifies that writing to a coil succeeds when the Modbus server
/// responds correctly.
///
/// NOTE: Tests through `write_relay_state()` public interface
#[tokio::test]
#[ignore = "Requires running Modbus TCP server"]
async fn test_write_single_coil_succeeds_for_valid_write() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
let state = RelayState::On;
// Act: Write relay state (internally calls write_single_coil_with_timeout)
let result = controller.write_relay_state(relay_id, state).await;
// Assert: Should succeed
assert!(
result.is_ok(),
"Expected successful coil write, got error: {:?}",
result.err()
);
}
/// T025c Test 2: `write_single_coil_with_timeout()` returns Timeout on slow device
///
/// This test verifies that write operations properly timeout.
#[tokio::test]
#[ignore = "Requires slow/non-responsive Modbus server"]
async fn test_write_single_coil_returns_timeout_on_slow_device() {
// Arrange: Connect with very short timeout
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 1)
.await
.expect("Failed to connect");
let relay_id = RelayId::new(1).expect("Valid relay ID");
let state = RelayState::On;
// Act: Attempt to write (server should be configured to delay response)
let result = controller.write_relay_state(relay_id, state).await;
// Assert: Should return Timeout error
assert!(result.is_err(), "Expected timeout error");
if let Err(ControllerError::Timeout(secs)) = result {
assert_eq!(secs, 1, "Timeout duration should match configured value");
} else {
panic!("Expected ControllerError::Timeout, got {result:?}");
}
}
/// T025c Test 3: `write_single_coil_with_timeout()` returns appropriate error on failure
///
/// This test verifies that various write failures are properly handled.
#[tokio::test]
#[ignore = "Requires Modbus server configured for error testing"]
async fn test_write_single_coil_returns_error_on_failure() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect");
let relay_id = RelayId::new(1).expect("Valid relay ID");
let state = RelayState::On;
// Act: Attempt to write (server should be configured to return error)
let result = controller.write_relay_state(relay_id, state).await;
// Assert: Should return an error (ConnectionError or ModbusException)
assert!(result.is_err(), "Expected error from write operation");
}
}
#[cfg(test)]
mod t025d_read_relay_state_tests {
use super::*;
use crate::domain::relay::{
controller::RelayController,
types::{RelayId, RelayState},
};
/// T025d Test 1: `read_relay_state(RelayId(1))` returns On when coil is true
///
/// This test verifies that a true coil value is correctly converted to `RelayState::On`.
#[tokio::test]
#[ignore = "Requires Modbus server with relay 1 set to On"]
async fn test_read_state_returns_on_when_coil_is_true() {
// Arrange: Connect to test server with relay 1 in On state
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Read relay state
let result = controller.read_relay_state(relay_id).await;
// Assert: Should return On state
assert!(result.is_ok(), "Failed to read relay state");
assert_eq!(result.unwrap(), RelayState::On, "Expected relay to be On");
}
/// T025d Test 2: `read_relay_state(RelayId(1))` returns Off when coil is false
///
/// This test verifies that a false coil value is correctly converted to `RelayState::Off`.
#[tokio::test]
#[ignore = "Requires Modbus server with relay 1 set to Off"]
async fn test_read_state_returns_off_when_coil_is_false() {
// Arrange: Connect to test server with relay 1 in Off state
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Read relay state
let result = controller.read_relay_state(relay_id).await;
// Assert: Should return Off state
assert!(result.is_ok(), "Failed to read relay state");
assert_eq!(result.unwrap(), RelayState::Off, "Expected relay to be Off");
}
/// T025d Test 3: `read_relay_state()` propagates `ControllerError` from helper
///
/// This test verifies that errors from `read_coils_with_timeout` are properly propagated.
#[tokio::test]
#[ignore = "Requires Modbus server configured to return errors"]
async fn test_read_state_propagates_controller_error() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Server should be configured to return error for this relay
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act: Attempt to read relay state
let result = controller.read_relay_state(relay_id).await;
// Assert: Should propagate error
assert!(
result.is_err(),
"Expected error to be propagated from helper"
);
}
/// T025d Test 4: `read_relay_state()` correctly maps `RelayId` to `ModbusAddress`
///
/// This test verifies that relay IDs are correctly converted to 0-based Modbus addresses.
#[tokio::test]
#[ignore = "Requires Modbus server with specific relay states"]
async fn test_read_state_correctly_maps_relay_id_to_modbus_address() {
// Arrange: Connect to test server with known relay states
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Test multiple relays to verify address mapping (RelayId 1-8 → Modbus 0-7)
for relay_num in 1..=8 {
let relay_id = RelayId::new(relay_num).expect("Valid relay ID");
// Act: Read relay state
let result = controller.read_relay_state(relay_id).await;
// Assert: Should succeed for all valid relay IDs
assert!(result.is_ok(), "Failed to read relay {relay_num} state");
}
}
}
#[cfg(test)]
mod t025e_write_relay_state_tests {
use super::*;
use crate::domain::relay::{
controller::RelayController,
types::{RelayId, RelayState},
};
/// T025e Test 1: `write_relay_state(RelayId(1)`, `RelayState::On`) writes true to coil
///
/// This test verifies that `RelayState::On` is correctly converted to a true coil value.
#[tokio::test]
#[ignore = "Requires Modbus server that can verify written values"]
async fn test_write_state_on_writes_true_to_coil() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
let state = RelayState::On;
// Act: Write On state
let write_result = controller.write_relay_state(relay_id, state).await;
// Assert: Write should succeed
assert!(
write_result.is_ok(),
"Failed to write relay state: {:?}",
write_result.err()
);
// Verify by reading back
let read_result = controller.read_relay_state(relay_id).await;
assert!(read_result.is_ok(), "Failed to read back relay state");
assert_eq!(
read_result.unwrap(),
RelayState::On,
"Relay should be On after writing On state"
);
}
/// T025e Test 2: `write_relay_state(RelayId(1)`, `RelayState::Off`) writes false to coil
///
/// This test verifies that `RelayState::Off` is correctly converted to a false coil value.
#[tokio::test]
#[ignore = "Requires Modbus server that can verify written values"]
async fn test_write_state_off_writes_false_to_coil() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
let state = RelayState::Off;
// Act: Write Off state
let write_result = controller.write_relay_state(relay_id, state).await;
// Assert: Write should succeed
assert!(
write_result.is_ok(),
"Failed to write relay state: {:?}",
write_result.err()
);
// Verify by reading back
let read_result = controller.read_relay_state(relay_id).await;
assert!(read_result.is_ok(), "Failed to read back relay state");
assert_eq!(
read_result.unwrap(),
RelayState::Off,
"Relay should be Off after writing Off state"
);
}
/// T025e Test 3: `write_relay_state()` correctly maps `RelayId` to `ModbusAddress`
///
/// This test verifies that relay IDs are correctly converted when writing.
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_state_correctly_maps_relay_id_to_modbus_address() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Test writing to all relay IDs to verify address mapping
for relay_num in 1..=8 {
let relay_id = RelayId::new(relay_num).expect("Valid relay ID");
// Act: Write to relay
let result = controller.write_relay_state(relay_id, RelayState::On).await;
// Assert: Should succeed for all valid relay IDs
assert!(
result.is_ok(),
"Failed to write to relay {}: {:?}",
relay_num,
result.err()
);
}
}
/// T025e Test 4: `write_relay_state()` can toggle relays between On and Off
///
/// This test verifies that relays can be toggled multiple times.
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_state_can_toggle_relay_multiple_times() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
let relay_id = RelayId::new(1).expect("Valid relay ID");
// Act & Assert: Toggle relay multiple times
for expected_state in [
RelayState::On,
RelayState::Off,
RelayState::On,
RelayState::Off,
] {
let write_result = controller.write_relay_state(relay_id, expected_state).await;
assert!(
write_result.is_ok(),
"Failed to write state {expected_state:?}"
);
let read_result = controller.read_relay_state(relay_id).await;
assert!(read_result.is_ok(), "Failed to read state back");
assert_eq!(
read_result.unwrap(),
expected_state,
"Relay state should match written value"
);
}
}
}
#[cfg(test)]
mod write_all_states_validation_tests {
use super::*;
/// Test: `write_all_states()` returns `InvalidInput` when given 0 states
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_empty_vector_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Act: Attempt to write with empty vector
let result = controller.write_all_states(vec![]).await;
// Assert: Should return InvalidInput error
assert!(result.is_err(), "Expected error for empty states vector");
assert!(
matches!(result.unwrap_err(), ControllerError::InvalidInput(_)),
"Expected InvalidInput error variant"
);
}
/// Test: `write_all_states()` returns `InvalidInput` when given 7 states (too few)
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_7_states_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Act: Attempt to write with 7 states (missing one)
let states = vec![RelayState::On; 7];
let result = controller.write_all_states(states).await;
// Assert: Should return InvalidInput error with descriptive message
assert!(result.is_err(), "Expected error for 7 states");
match result.unwrap_err() {
ControllerError::InvalidInput(msg) => {
assert!(
msg.contains("Expected 8"),
"Error message should mention expected count"
);
assert!(
msg.contains('7'),
"Error message should mention actual count"
);
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
/// Test: `write_all_states()` returns `InvalidInput` when given 9 states (too many)
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_9_states_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Act: Attempt to write with 9 states (one extra)
let states = vec![RelayState::Off; 9];
let result = controller.write_all_states(states).await;
// Assert: Should return InvalidInput error with descriptive message
assert!(result.is_err(), "Expected error for 9 states");
match result.unwrap_err() {
ControllerError::InvalidInput(msg) => {
assert!(
msg.contains("Expected 8"),
"Error message should mention expected count"
);
assert!(
msg.contains('9'),
"Error message should mention actual count"
);
}
other => panic!("Expected InvalidInput, got {other:?}"),
}
}
/// Test: `write_all_states()` succeeds with exactly 8 states
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_8_states_succeeds() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
.await
.expect("Failed to connect to test server");
// Act: Write with exactly 8 states
let states = vec![RelayState::On; 8];
let result = controller.write_all_states(states).await;
// Assert: Should succeed
assert!(
result.is_ok(),
"Expected success for 8 states, got: {:?}",
result.err()
);
}
}