//! 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, repo: Arc, ) -> TestClient { 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 { 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 = 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 = 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 = 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 = 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 = 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 = 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"); }