272 lines
9.4 KiB
Rust
272 lines
9.4 KiB
Rust
|
|
//! 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");
|
|||
|
|
}
|