Files
sta/backend/tests/contract/test_relay_api.rs
Lucien Cartier-Tilet 2eebc52f17 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
2026-05-14 20:23:40 +02:00

272 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}