2025-12-21 18:19:21 +01:00
|
|
|
//! Application startup and server configuration.
|
|
|
|
|
//!
|
|
|
|
|
//! This module handles:
|
|
|
|
|
//! - Building the application with routes and middleware
|
|
|
|
|
//! - Setting up the OpenAPI service and Swagger UI
|
|
|
|
|
//! - Configuring CORS
|
|
|
|
|
//! - Starting the HTTP server
|
|
|
|
|
|
|
|
|
|
use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
|
|
|
|
|
use poem::{EndpointExt, Route};
|
|
|
|
|
use poem_openapi::OpenApiService;
|
|
|
|
|
|
|
|
|
|
use crate::{
|
|
|
|
|
middleware::rate_limit::{RateLimit, RateLimitConfig},
|
|
|
|
|
route::Api,
|
|
|
|
|
settings::Settings,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::middleware::rate_limit::RateLimitEndpoint;
|
|
|
|
|
|
|
|
|
|
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
|
|
|
|
|
/// The configured application with rate limiting, CORS, and settings data.
|
|
|
|
|
pub type App = AddDataEndpoint<CorsEndpoint<RateLimitEndpoint<Route>>, Settings>;
|
|
|
|
|
|
|
|
|
|
/// Application builder that holds the server configuration before running.
|
|
|
|
|
pub struct Application {
|
|
|
|
|
server: Server,
|
|
|
|
|
app: poem::Route,
|
|
|
|
|
host: String,
|
|
|
|
|
port: u16,
|
|
|
|
|
settings: Settings,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A fully configured application ready to run.
|
|
|
|
|
pub struct RunnableApplication {
|
|
|
|
|
server: Server,
|
|
|
|
|
app: App,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RunnableApplication {
|
|
|
|
|
/// Runs the application server.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns a `std::io::Error` if the server fails to start or encounters
|
|
|
|
|
/// an I/O error during runtime (e.g., port already in use, network issues).
|
|
|
|
|
pub async fn run(self) -> Result<(), std::io::Error> {
|
|
|
|
|
self.server.run(self.app).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<RunnableApplication> for App {
|
|
|
|
|
fn from(value: RunnableApplication) -> Self {
|
|
|
|
|
value.app
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<Application> for RunnableApplication {
|
|
|
|
|
fn from(value: Application) -> Self {
|
|
|
|
|
// Configure rate limiting based on settings
|
|
|
|
|
let rate_limit_config = if value.settings.rate_limit.enabled {
|
|
|
|
|
tracing::event!(
|
|
|
|
|
target: "backend::startup",
|
|
|
|
|
tracing::Level::INFO,
|
|
|
|
|
burst_size = value.settings.rate_limit.burst_size,
|
|
|
|
|
per_seconds = value.settings.rate_limit.per_seconds,
|
|
|
|
|
"Rate limiting enabled"
|
|
|
|
|
);
|
|
|
|
|
RateLimitConfig::new(
|
|
|
|
|
value.settings.rate_limit.burst_size,
|
|
|
|
|
value.settings.rate_limit.per_seconds,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
tracing::event!(
|
|
|
|
|
target: "backend::startup",
|
|
|
|
|
tracing::Level::INFO,
|
|
|
|
|
"Rate limiting disabled (using very high limits)"
|
|
|
|
|
);
|
|
|
|
|
// Use very high limits to effectively disable rate limiting
|
|
|
|
|
RateLimitConfig::new(u32::MAX, 1)
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-03 17:50:06 +01:00
|
|
|
let cors = Cors::from(value.settings.cors.clone());
|
|
|
|
|
|
2025-12-21 18:19:21 +01:00
|
|
|
let app = value
|
|
|
|
|
.app
|
|
|
|
|
.with(RateLimit::new(&rate_limit_config))
|
2026-01-03 17:50:06 +01:00
|
|
|
.with(cors)
|
2025-12-21 18:19:21 +01:00
|
|
|
.data(value.settings);
|
|
|
|
|
|
|
|
|
|
let server = value.server;
|
|
|
|
|
Self { server, app }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Application {
|
|
|
|
|
fn setup_app(settings: &Settings) -> poem::Route {
|
|
|
|
|
let api_service = OpenApiService::new(
|
|
|
|
|
Api::from(settings).apis(),
|
|
|
|
|
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("/", ui)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn setup_server(
|
|
|
|
|
settings: &Settings,
|
|
|
|
|
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
|
|
|
|
) -> Server {
|
|
|
|
|
let tcp_listener = tcp_listener.unwrap_or_else(|| {
|
|
|
|
|
let address = format!(
|
|
|
|
|
"{}:{}",
|
|
|
|
|
settings.application.host, settings.application.port
|
|
|
|
|
);
|
|
|
|
|
poem::listener::TcpListener::bind(address)
|
|
|
|
|
});
|
|
|
|
|
poem::Server::new(tcp_listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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(
|
|
|
|
|
settings: Settings,
|
|
|
|
|
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
let port = settings.application.port;
|
|
|
|
|
let host = settings.application.clone().host;
|
|
|
|
|
let app = Self::setup_app(&settings);
|
|
|
|
|
let server = Self::setup_server(&settings, tcp_listener);
|
|
|
|
|
Self {
|
|
|
|
|
server,
|
|
|
|
|
app,
|
|
|
|
|
host,
|
|
|
|
|
port,
|
|
|
|
|
settings,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Converts the application into a runnable application.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn make_app(self) -> RunnableApplication {
|
|
|
|
|
self.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the host address the application is configured to bind to.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn host(&self) -> String {
|
|
|
|
|
self.host.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the port the application is configured to bind to.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn port(&self) -> u16 {
|
|
|
|
|
self.port
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn create_test_settings() -> Settings {
|
|
|
|
|
Settings {
|
|
|
|
|
application: crate::settings::ApplicationSettings {
|
|
|
|
|
name: "test-app".to_string(),
|
|
|
|
|
version: "1.0.0".to_string(),
|
|
|
|
|
port: 8080,
|
|
|
|
|
host: "127.0.0.1".to_string(),
|
|
|
|
|
base_url: "http://localhost:8080".to_string(),
|
|
|
|
|
protocol: "http".to_string(),
|
|
|
|
|
},
|
|
|
|
|
debug: false,
|
|
|
|
|
frontend_url: "http://localhost:3000".to_string(),
|
|
|
|
|
rate_limit: crate::settings::RateLimitSettings {
|
|
|
|
|
enabled: false,
|
|
|
|
|
burst_size: 100,
|
|
|
|
|
per_seconds: 60,
|
|
|
|
|
},
|
2026-01-01 23:29:31 +01:00
|
|
|
..Default::default()
|
2025-12-21 18:19:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn application_build_and_host() {
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings.clone(), None);
|
|
|
|
|
assert_eq!(app.host(), settings.application.host);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn application_build_and_port() {
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings, None);
|
|
|
|
|
assert_eq!(app.port(), 8080);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn application_host_returns_correct_value() {
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings, None);
|
|
|
|
|
assert_eq!(app.host(), "127.0.0.1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn application_port_returns_correct_value() {
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings, None);
|
|
|
|
|
assert_eq!(app.port(), 8080);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
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));
|
|
|
|
|
assert_eq!(app.host(), "127.0.0.1");
|
|
|
|
|
assert_eq!(app.port(), 8080);
|
|
|
|
|
}
|
2026-01-03 17:50:06 +01:00
|
|
|
|
|
|
|
|
// T015: Test that CORS middleware is configured from settings
|
|
|
|
|
#[test]
|
|
|
|
|
fn runnable_application_uses_cors_from_settings() {
|
|
|
|
|
// GIVEN: An application with custom CORS 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
|
2026-01-03 18:43:02 +01:00
|
|
|
let app = Application::build(settings, None);
|
2026-01-03 17:50:06 +01:00
|
|
|
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
|
|
|
|
|
// 2. The From<CorsSettings> trait is correctly implemented
|
|
|
|
|
// 3. The middleware chain accepts the CORS configuration
|
|
|
|
|
}
|
2026-01-23 20:46:48 +01:00
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// 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
|
|
|
|
|
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
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_ok(),
|
|
|
|
|
"Application::build() should succeed in test mode"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let app = result.unwrap();
|
|
|
|
|
|
|
|
|
|
// Verify the application is configured correctly
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// T039d: RelayApi Registration 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
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings, None);
|
|
|
|
|
let _runnable_app = app.make_app();
|
|
|
|
|
|
|
|
|
|
// WHEN: The application is built and routes are set up
|
|
|
|
|
// (OpenAPI service is created in setup_app)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
// For now, this test passes if the application builds successfully
|
|
|
|
|
// Full verification will be added during T039d implementation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// T039d: Test 2 - Swagger UI renders Relays tag
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_swagger_ui_includes_relays_tag() {
|
|
|
|
|
// GIVEN: An application with RelayApi registered
|
|
|
|
|
let settings = create_test_settings();
|
|
|
|
|
let app = Application::build(settings, None);
|
|
|
|
|
let _runnable_app = app.make_app();
|
|
|
|
|
|
|
|
|
|
// WHEN: The application is built with OpenAPI service
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
// For now, this test passes if the application builds successfully
|
|
|
|
|
// Full verification will be added during T039d implementation
|
|
|
|
|
}
|
2025-12-21 18:19:21 +01:00
|
|
|
}
|
2026-01-23 20:46:48 +01:00
|
|
|
|