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

@@ -10,6 +10,9 @@ use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService;
use crate::infrastructure::modbus::factory::create_relay_controller;
use crate::infrastructure::persistence::factory::create_label_repository;
use crate::presentation::api::relay_api::RelayApi;
use crate::{
middleware::rate_limit::{RateLimit, RateLimitConfig},
route::Api,
@@ -94,17 +97,17 @@ impl From<Application> for RunnableApplication {
}
impl Application {
fn setup_app(settings: &Settings) -> poem::Route {
fn setup_app(settings: &Settings, relay_api: RelayApi) -> poem::Route {
let api_service = OpenApiService::new(
Api::from(settings).apis(),
(Api::from(settings).apis(), relay_api),
settings.application.clone().name,
settings.application.clone().version,
)
.url_prefix("/api");
let ui = api_service.swagger_ui();
poem::Route::new()
.nest("/api", api_service.clone())
.nest("/specs", api_service.spec_endpoint_yaml())
.nest("/api", api_service)
.nest("/", ui)
}
@@ -125,22 +128,31 @@ impl Application {
/// Builds a new application with the given settings and optional TCP listener.
///
/// If no listener is provided, one will be created based on the settings.
#[must_use]
pub fn build(
///
/// # Errors
///
/// Returns an error if dependency injection fails (currently always succeeds).
pub async fn build(
settings: Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self {
) -> Result<Self, Box<dyn std::error::Error>> {
let use_mock = cfg!(test) || std::env::var("CI").is_ok();
let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
let label_repository = create_label_repository(&settings.database.path, use_mock).await?;
let relay_api = RelayApi::new(relay_controller, label_repository);
let port = settings.application.port;
let host = settings.application.clone().host;
let app = Self::setup_app(&settings);
let app = Self::setup_app(&settings, relay_api);
let server = Self::setup_server(&settings, tcp_listener);
Self {
Ok(Self {
server,
app,
host,
port,
settings,
}
})
}
/// Converts the application into a runnable application.
@@ -187,63 +199,57 @@ mod tests {
}
}
#[test]
fn application_build_and_host() {
#[tokio::test]
async fn application_build_and_host() {
let settings = create_test_settings();
let app = Application::build(settings.clone(), None);
let app = Application::build(settings.clone(), None).await.unwrap();
assert_eq!(app.host(), settings.application.host);
}
#[test]
fn application_build_and_port() {
#[tokio::test]
async fn application_build_and_port() {
let settings = create_test_settings();
let app = Application::build(settings, None);
let app = Application::build(settings, None).await.unwrap();
assert_eq!(app.port(), 8080);
}
#[test]
fn application_host_returns_correct_value() {
#[tokio::test]
async fn application_host_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None);
let app = Application::build(settings, None).await.unwrap();
assert_eq!(app.host(), "127.0.0.1");
}
#[test]
fn application_port_returns_correct_value() {
#[tokio::test]
async fn application_port_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None);
let app = Application::build(settings, None).await.unwrap();
assert_eq!(app.port(), 8080);
}
#[test]
fn application_with_custom_listener() {
#[tokio::test]
async fn application_with_custom_listener() {
let settings = create_test_settings();
let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let port = tcp_listener.local_addr().unwrap().port();
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
let app = Application::build(settings, Some(listener));
let app = Application::build(settings, Some(listener)).await.unwrap();
assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), 8080);
}
// T015: Test that CORS middleware is configured from settings
#[test]
fn runnable_application_uses_cors_from_settings() {
// GIVEN: An application with custom CORS settings
#[tokio::test]
async fn runnable_application_uses_cors_from_settings() {
let mut settings = create_test_settings();
settings.cors = crate::settings::CorsSettings {
allowed_origins: vec!["http://localhost:5173".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
// WHEN: The application is converted to a runnable application
let app = Application::build(settings, None);
let app = Application::build(settings, None).await.unwrap();
let _runnable_app = app.make_app();
// THEN: The middleware chain should use CORS settings from configuration
// Note: This is a structural test - actual CORS behavior is tested in integration tests (T016)
// The fact that this compiles and runs without panic verifies that:
// 1. CORS settings are properly loaded
@@ -251,111 +257,20 @@ mod tests {
// 3. The middleware chain accepts the CORS configuration
}
// ============================================================================
// T039c: Dependency Injection Tests
// ============================================================================
// These tests verify that Application::build() correctly wires dependencies
// with graceful degradation and test mode detection.
// T039c: Test 1 - Application::build() succeeds in test mode
#[test]
fn test_application_build_succeeds_in_test_mode() {
// GIVEN: Settings configured for test mode
// When cfg!(test) is true, Application::build should use mock dependencies
#[tokio::test]
async fn test_application_build_succeeds_in_test_mode() {
let settings = create_test_settings();
// WHEN: Application::build() is called
let result = std::panic::catch_unwind(|| {
Application::build(settings, None)
});
// THEN: Should succeed without panicking
let app = Application::build(settings, None).await;
assert!(
result.is_ok(),
app.is_ok(),
"Application::build() should succeed in test mode"
);
let app = result.unwrap();
// Verify the application is configured correctly
let app = app.unwrap();
assert_eq!(app.port(), 8080);
assert_eq!(app.host(), "127.0.0.1");
// TODO (T039c implementation): After implementation, verify that:
// - Mock controller is used (not real Modbus hardware)
// - Mock label repository is used (not real SQLite)
// - Application can be converted to runnable state
}
// T039c: Test 2 - Application::build() creates correct mock dependencies when CI=true
#[test]
fn test_application_build_uses_mock_dependencies_in_ci() {
// GIVEN: CI environment variable is set
// SAFETY: This test modifies environment variables, which is inherently unsafe
// in a multi-threaded context. However, this is acceptable in tests because:
// 1. Cargo runs tests in parallel by default, but each test gets its own process
// 2. The cleanup happens immediately after use
// 3. This is a controlled test environment
unsafe {
std::env::set_var("CI", "true");
}
let settings = create_test_settings();
// WHEN: Application::build() is called
let result = std::panic::catch_unwind(|| {
Application::build(settings, None)
});
// Clean up environment variable
// SAFETY: Same rationale as set_var above
unsafe {
std::env::remove_var("CI");
}
// THEN: Should succeed and use mock dependencies
assert!(
result.is_ok(),
"Application::build() should succeed in CI environment"
);
let app = result.unwrap();
// Verify the application is configured
assert_eq!(app.port(), 8080);
// TODO (T039c implementation): After implementation, verify that:
// - Mock dependencies are used when CI=true
// - No real hardware connection is attempted
// - Application works without Modbus device or SQLite database
}
// T039c: Test 3 - Application::build() creates real dependencies when not in test mode
#[test]
#[ignore] // This test requires real Modbus hardware and should be run manually
fn test_application_build_uses_real_dependencies_in_production() {
// GIVEN: Production settings with real Modbus device configuration
// This test is #[ignore] because it requires actual hardware
let settings = create_test_settings();
// WHEN: Application::build() is called outside of test/CI environment
// (This would normally happen in production)
let result = std::panic::catch_unwind(|| {
Application::build(settings, None)
});
// THEN: Should attempt to create real dependencies
// In test environment, this will still use mocks due to cfg!(test)
// This test serves as documentation of the expected production behavior
assert!(
result.is_ok(),
"Application::build() should handle dependency creation"
);
// TODO (T039c implementation): After implementation, verify that:
// - Real ModbusRelayController is created when hardware is available
// - Real SqliteRelayLabelRepository is created
// - Graceful fallback to mock if hardware connection fails (FR-023)
let runnable_app = app.make_app();
let _app: App = runnable_app.into();
// Success - the application was built with dependencies and can run
}
// ============================================================================
@@ -364,57 +279,51 @@ mod tests {
// These tests verify that the RelayApi is properly registered in the route
// aggregator with correct OpenAPI tagging.
// T039d: Test 1 - OpenAPI spec includes /api/relays endpoints
#[test]
fn test_openapi_spec_includes_relay_endpoints() {
// GIVEN: An application with all routes configured
// T039d: Test 1 - OpenAPI spec includes /relays endpoints
#[tokio::test]
async fn test_openapi_spec_includes_relay_endpoints() {
let settings = create_test_settings();
let app = Application::build(settings, None);
let _runnable_app = app.make_app();
let app: App = Application::build(settings, None)
.await
.unwrap()
.make_app()
.into();
let cli = poem::test::TestClient::new(app);
// WHEN: The application is built and routes are set up
// (OpenAPI service is created in setup_app)
let resp = cli.get("/specs").send().await;
resp.assert_status_is_ok();
// THEN: OpenAPI spec should include relay endpoints
// TODO (T039d implementation): After implementation, verify that:
// - GET /api/relays endpoint exists in spec
// - POST /api/relays/{id}/toggle endpoint exists in spec
// - POST /api/relays/all/on endpoint exists in spec
// - POST /api/relays/all/off endpoint exists in spec
// - PUT /api/relays/{id}/label endpoint exists in spec
//
// This can be verified by:
// 1. Extracting the OpenAPI spec from the app
// 2. Parsing the spec JSON/YAML
// 3. Checking for the presence of these paths
let spec = resp.0.into_body().into_string().await.unwrap();
// For now, this test passes if the application builds successfully
// Full verification will be added during T039d implementation
assert!(
spec.contains("/relays:"),
"OpenAPI spec should include the /relays path, got:\n{spec}"
);
assert!(
spec.contains("/relays/{id}/toggle:"),
"OpenAPI spec should include the /relays/{{id}}/toggle path, got:\n{spec}"
);
}
// T039d: Test 2 - Swagger UI renders Relays tag
#[test]
fn test_swagger_ui_includes_relays_tag() {
// GIVEN: An application with RelayApi registered
// T039d: Test 2 - OpenAPI spec includes the Relays tag
#[tokio::test]
async fn test_swagger_ui_includes_relays_tag() {
let settings = create_test_settings();
let app = Application::build(settings, None);
let _runnable_app = app.make_app();
let app: App = Application::build(settings, None)
.await
.unwrap()
.make_app()
.into();
let cli = poem::test::TestClient::new(app);
// WHEN: The application is built with OpenAPI service
let resp = cli.get("/specs").send().await;
resp.assert_status_is_ok();
// THEN: Swagger UI should include "Relays" tag
// TODO (T039d implementation): After implementation, verify that:
// - OpenAPI spec includes a "Relays" tag
// - All relay endpoints are grouped under this tag
// - Tag has appropriate description
//
// This can be verified by:
// 1. Extracting the OpenAPI spec
// 2. Checking the "tags" section for "Relays"
// 3. Verifying relay endpoints reference this tag
let spec = resp.0.into_body().into_string().await.unwrap();
// For now, this test passes if the application builds successfully
// Full verification will be added during T039d implementation
assert!(
spec.contains("Relays"),
"OpenAPI spec should include a 'Relays' tag, got:\n{spec}"
);
}
}