feat: wire relay API with dependency injection

- split settings module into per-struct files
- add DatabaseSettings with default in-memory SQLite path
- implement RelayApi struct with GET /relays and POST
  /relays/{id}/toggle
- wire create_relay_controller and create_label_repository into
  Application::build() with mock/real selection via cfg!(test) || CI
- register RelayApi in OpenApiService alongside existing APIs
This commit is contained in:
2026-03-04 12:47:21 +01:00
parent fd00d1925b
commit 2eebc52f17
30 changed files with 1170 additions and 670 deletions

View File

@@ -0,0 +1,271 @@
//! 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");
}

View File

@@ -13,7 +13,7 @@ use poem::test::TestClient;
use sta::{settings::Settings, startup::Application};
/// Helper function to create a test app with custom CORS settings.
fn get_test_app_with_cors(
async fn get_test_app_with_cors(
allowed_origins: Vec<String>,
allow_credentials: bool,
max_age_secs: i32,
@@ -32,6 +32,8 @@ fn get_test_app_with_cors(
settings.cors.max_age_secs = max_age_secs;
Application::build(settings, Some(listener))
.await
.expect("Failed to build application")
.make_app()
.into()
}
@@ -42,7 +44,7 @@ fn get_test_app_with_cors(
#[tokio::test]
async fn preflight_request_returns_cors_headers() {
// GIVEN: An app with CORS configured for specific origin
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent with Origin header
@@ -82,7 +84,7 @@ async fn preflight_request_returns_cors_headers() {
#[tokio::test]
async fn get_request_with_origin_returns_allow_origin_header() {
// GIVEN: An app with CORS configured for specific origin
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let client = TestClient::new(app);
// WHEN: A GET request is sent with Origin header
@@ -119,7 +121,7 @@ async fn preflight_response_includes_max_age_from_config() {
vec!["http://localhost:5173".to_string()],
false,
custom_max_age,
);
).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -153,7 +155,7 @@ async fn response_includes_allow_credentials_when_configured() {
vec!["http://localhost:5173".to_string()],
true, // allow_credentials
3600,
);
).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -187,7 +189,7 @@ async fn response_does_not_include_credentials_when_disabled() {
vec!["http://localhost:5173".to_string()],
false, // allow_credentials
3600,
);
).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -217,7 +219,7 @@ async fn response_does_not_include_credentials_when_disabled() {
#[tokio::test]
async fn preflight_response_includes_correct_allowed_methods() {
// GIVEN: An app with CORS configured
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -260,7 +262,7 @@ async fn wildcard_origin_works_with_credentials_disabled() {
vec!["*".to_string()],
false, // credentials MUST be false with wildcard
3600,
);
).await;
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent with any origin
@@ -299,7 +301,7 @@ async fn multiple_origins_are_supported() {
],
false,
3600,
);
).await;
let client = TestClient::new(app);
// WHEN: A request is sent with the first origin
@@ -341,7 +343,7 @@ async fn multiple_origins_are_supported() {
#[tokio::test]
async fn unauthorized_origin_is_rejected() {
// 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);
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let client = TestClient::new(app);
// WHEN: A request is sent with an unauthorized origin

View File

@@ -427,7 +427,10 @@ async fn test_repository_error_handling() {
// Test with invalid relay ID (should be caught by domain validation)
let invalid_relay_id = RelayId::new(9); // This will fail validation
assert!(invalid_relay_id.is_err(), "Invalid relay ID should fail validation");
assert!(
invalid_relay_id.is_err(),
"Invalid relay ID should fail validation"
);
// Test with invalid label (should be caught by domain validation)
let invalid_label = RelayLabel::new("".to_string()); // Empty label
@@ -444,7 +447,7 @@ async fn test_concurrent_operations_are_thread_safe() {
// Since SqliteRelayLabelRepository doesn't implement Clone, we'll test
// sequential operations which still verify the repository handles
// multiple operations correctly
// Save multiple labels sequentially
let relay_id1 = RelayId::new(1).expect("Valid relay ID");
let label1 = RelayLabel::new("Task1".to_string()).expect("Valid label");
@@ -470,4 +473,4 @@ async fn test_concurrent_operations_are_thread_safe() {
.await
.expect("get_all_labels should succeed");
assert_eq!(all_labels.len(), 3, "Should have all 3 labels");
}
}