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:
271
backend/tests/contract/test_relay_api.rs
Normal file
271
backend/tests/contract/test_relay_api.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user