From f5bc98342c9d114c7c3a73bf7e8cd1ef7503f6ae Mon Sep 17 00:00:00 2001 From: Tuntii Date: Tue, 23 Jun 2026 02:48:12 +0300 Subject: [PATCH] test(#201): exercise real run entrypoints via prepare_for_serve --- crates/rustapi-core/src/app/run.rs | 105 ++------ crates/rustapi-core/src/app/tests.rs | 286 +++++++++++++++++---- crates/rustapi-core/tests/http3_run_dev.rs | 21 ++ 3 files changed, 283 insertions(+), 129 deletions(-) create mode 100644 crates/rustapi-core/tests/http3_run_dev.rs diff --git a/crates/rustapi-core/src/app/run.rs b/crates/rustapi-core/src/app/run.rs index 208df203..284bb68d 100644 --- a/crates/rustapi-core/src/app/run.rs +++ b/crates/rustapi-core/src/app/run.rs @@ -8,18 +8,33 @@ use crate::middleware::BodyLimitLayer; use crate::response::IntoResponse; use crate::server::Server; impl RustApi { + async fn prepare_for_serve(&mut self, addr: &str) { + self.maybe_dump_openapi(); + self.print_hot_reload_banner(addr); + self.apply_health_endpoints(); + self.apply_status_page(); + #[cfg(feature = "dashboard")] + self.apply_dashboard(); + if let Some(limit) = self.body_limit { + self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); + } + for hook in std::mem::take(&mut self.lifecycle_hooks.on_start) { + hook().await; + } + } + pub(super) fn print_hot_reload_banner(&self, addr: &str) { if !self.hot_reload { return; } - // Set the env var so the CLI watcher can detect it - std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); - let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD") .map(|v| v == "1") .unwrap_or(false); + // Set the env var so the CLI watcher can detect it + std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); + tracing::info!("Hot-reload mode enabled"); if is_under_watcher { @@ -204,31 +219,7 @@ impl RustApi { self } pub async fn run(mut self, addr: &str) -> Result<(), Box> { - self.maybe_dump_openapi(); - - // Hot-reload mode banner - self.print_hot_reload_banner(addr); - - // Apply health endpoints if configured - self.apply_health_endpoints(); - - // Apply status page if configured - self.apply_status_page(); - - // Apply embedded dashboard if configured - #[cfg(feature = "dashboard")] - self.apply_dashboard(); - - // Apply body limit layer if configured (should be first in the chain) - if let Some(limit) = self.body_limit { - // Prepend body limit layer so it's the first to process requests - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } - - // Run on_start lifecycle hooks before accepting connections - for hook in self.lifecycle_hooks.on_start { - hook().await; - } + self.prepare_for_serve(addr).await; let server = Server::new(self.router, self.layers, self.interceptors); server.run(addr).await @@ -243,29 +234,7 @@ impl RustApi { where F: std::future::Future + Send + 'static, { - self.maybe_dump_openapi(); - - // Hot-reload mode banner - self.print_hot_reload_banner(addr.as_ref()); - - // Apply health endpoints if configured - self.apply_health_endpoints(); - - // Apply status page if configured - self.apply_status_page(); - - // Apply embedded dashboard if configured - #[cfg(feature = "dashboard")] - self.apply_dashboard(); - - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } - - // Run on_start lifecycle hooks before accepting connections - for hook in self.lifecycle_hooks.on_start { - hook().await; - } + self.prepare_for_serve(addr.as_ref()).await; // Wrap the shutdown signal to run on_shutdown hooks after signal fires let shutdown_hooks = self.lifecycle_hooks.on_shutdown; @@ -303,16 +272,8 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; - // Apply health endpoints if configured - self.apply_health_endpoints(); - - // Apply status page if configured - self.apply_status_page(); - - // Apply body limit layer if configured - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } + let addr = config.socket_addr(); + self.prepare_for_serve(&addr).await; let server = crate::http3::Http3Server::new( &config, @@ -345,16 +306,7 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; - // Apply health endpoints if configured - self.apply_health_endpoints(); - - // Apply status page if configured - self.apply_status_page(); - - // Apply body limit layer if configured - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } + self.prepare_for_serve(addr).await; let server = crate::http3::Http3Server::new_with_self_signed( addr, @@ -418,16 +370,7 @@ impl RustApi { config.port = http_socket.port(); let http_addr = http_socket.to_string(); - // Apply health endpoints if configured - self.apply_health_endpoints(); - - // Apply status page if configured - self.apply_status_page(); - - // Apply body limit layer if configured - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } + self.prepare_for_serve(&http_addr).await; let router = Arc::new(self.router); let layers = Arc::new(self.layers); diff --git a/crates/rustapi-core/src/app/tests.rs b/crates/rustapi-core/src/app/tests.rs index 9a3aadd0..119ca6c6 100644 --- a/crates/rustapi-core/src/app/tests.rs +++ b/crates/rustapi-core/src/app/tests.rs @@ -779,64 +779,254 @@ fn test_rustapi_nest_includes_routes_in_openapi_spec() { ); } -#[test] -fn apply_health_endpoints_registers_default_paths() { - let mut app = RustApi::new().health_endpoints(); - app.apply_health_endpoints(); - let router = app.into_router(); - let routes = router.registered_routes(); - assert!(routes.contains_key("/health")); - assert!(routes.contains_key("/ready")); - assert!(routes.contains_key("/live")); -} +mod run_entrypoints { + use super::RustApi; + use crate::router::post; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::oneshot; + + fn reserve_local_addr() -> (u16, String) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + (port, format!("127.0.0.1:{port}")) + } -#[test] -fn apply_status_page_registers_status_route() { - let mut app = RustApi::new().status_page(); - app.apply_status_page(); - let router = app.into_router(); - assert!(router.registered_routes().contains_key("/status")); -} + #[tokio::test] + async fn run_with_shutdown_serves_health_endpoints() { + let app = RustApi::new().health_endpoints(); + let (port, addr) = reserve_local_addr(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); -#[test] -fn print_hot_reload_banner_is_noop_when_disabled() { - let app = RustApi::new(); - app.print_hot_reload_banner("127.0.0.1:8080"); -} + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{port}"); + + for path in ["/health", "/ready", "/live"] { + let res = client + .get(format!("{base}{path}")) + .send() + .await + .expect("health request"); + assert_eq!(res.status(), 200, "{path} should return 200"); + } -#[test] -fn print_hot_reload_banner_logs_when_enabled() { - let app = RustApi::new().hot_reload(true); - app.print_hot_reload_banner("127.0.0.1:8080"); -} + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + } -#[tokio::test] -async fn on_start_hooks_execute_in_registration_order() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; + #[tokio::test] + async fn run_with_shutdown_serves_status_page() { + let app = RustApi::new().status_page(); + let (port, addr) = reserve_local_addr(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/status")) + .send() + .await + .expect("status request"); + assert_eq!(res.status(), 200); + assert!(res.text().await.unwrap().contains("System Status")); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + } - let order = Arc::new(AtomicUsize::new(0)); - let mut app = RustApi::new() - .on_start({ - let order = order.clone(); - move || { - let order = order.clone(); + #[tokio::test] + async fn run_with_shutdown_executes_on_start_and_on_shutdown_hooks() { + let on_start = Arc::new(AtomicBool::new(false)); + let on_shutdown = Arc::new(AtomicBool::new(false)); + let on_start_flag = on_start.clone(); + let on_shutdown_flag = on_shutdown.clone(); + + let app = RustApi::new() + .health_endpoints() + .on_start(move || { + let on_start_flag = on_start_flag.clone(); async move { - assert_eq!(order.fetch_add(1, Ordering::SeqCst), 0); + on_start_flag.store(true, Ordering::SeqCst); } - } - }) - .on_start({ - let order = order.clone(); - move || { - let order = order.clone(); + }) + .on_shutdown(move || { + let on_shutdown_flag = on_shutdown_flag.clone(); async move { - assert_eq!(order.fetch_add(1, Ordering::SeqCst), 1); + on_shutdown_flag.store(true, Ordering::SeqCst); } - } + }); + + let (port, addr) = reserve_local_addr(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + on_start.load(Ordering::SeqCst), + "on_start should run before accept" + ); + + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/health")) + .send() + .await + .expect("health request"); + assert_eq!(res.status(), 200); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + assert!( + on_shutdown.load(Ordering::SeqCst), + "on_shutdown should run after shutdown signal" + ); + } + + #[tokio::test] + async fn run_with_shutdown_runs_on_start_hooks_in_registration_order() { + let order = Arc::new(AtomicUsize::new(0)); + let first = order.clone(); + let second = order.clone(); + + let app = RustApi::new() + .on_start(move || { + let first = first.clone(); + async move { + assert_eq!(first.fetch_add(1, Ordering::SeqCst), 0); + } + }) + .on_start(move || { + let second = second.clone(); + async move { + assert_eq!(second.fetch_add(1, Ordering::SeqCst), 1); + } + }); + + let (_, addr) = reserve_local_addr(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + assert_eq!(order.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn run_entrypoint_serves_health_endpoints() { + let app = RustApi::new().health_endpoints(); + let (port, addr) = reserve_local_addr(); + + let server = tokio::spawn(async move { app.run(&addr).await }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/health")) + .send() + .await + .expect("health request"); + assert_eq!(res.status(), 200); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn run_with_shutdown_applies_body_limit() { + async fn echo(body: crate::extract::Body) -> String { + String::from_utf8_lossy(&body.0).into_owned() + } + + let app = RustApi::new().route("/echo", post(echo)).body_limit(8); + let (port, addr) = reserve_local_addr(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await }); - for hook in std::mem::take(&mut app.lifecycle_hooks.on_start) { - hook().await; + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let ok = client + .post(format!("http://127.0.0.1:{port}/echo")) + .body("short") + .send() + .await + .expect("small body"); + assert_eq!(ok.status(), 200); + + let rejected = client + .post(format!("http://127.0.0.1:{port}/echo")) + .body("this payload is too large") + .send() + .await + .expect("large body"); + assert_eq!(rejected.status(), 413); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + } + + #[test] + fn print_hot_reload_banner_reads_watcher_state_before_setting_env() { + let _guard = EnvVarGuard::remove("RUSTAPI_HOT_RELOAD"); + let app = RustApi::new().hot_reload(true); + app.print_hot_reload_banner("127.0.0.1:8080"); + assert_eq!( + std::env::var("RUSTAPI_HOT_RELOAD").ok().as_deref(), + Some("1") + ); + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn remove(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } } } diff --git a/crates/rustapi-core/tests/http3_run_dev.rs b/crates/rustapi-core/tests/http3_run_dev.rs new file mode 100644 index 00000000..476a2572 --- /dev/null +++ b/crates/rustapi-core/tests/http3_run_dev.rs @@ -0,0 +1,21 @@ +#![cfg(feature = "http3-dev")] + +use rustapi_core::RustApi; +use std::net::UdpSocket; +use std::time::Duration; + +#[tokio::test] +async fn run_http3_dev_entrypoint_prepares_health_routes() { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = socket.local_addr().unwrap().port(); + drop(socket); + + let addr = format!("127.0.0.1:{port}"); + let app = RustApi::new().health_endpoints(); + + let server = tokio::spawn(async move { app.run_http3_dev(&addr).await }); + + tokio::time::sleep(Duration::from_millis(500)).await; + server.abort(); + let _ = server.await; +}