Skip to main content

autumn_web/
test.rs

1//! First-party integration-testing utilities for Autumn applications.
2//!
3//! This module brings Autumn's testing story to parity with frameworks like
4//! Spring Boot's `@SpringBootTest` + `MockMvc` and Django's `TestCase` +
5//! `Client`. Import it in your integration tests:
6//!
7//! ```rust,ignore
8//! use autumn_web::test::{TestApp, TestClient};
9//! ```
10//!
11//! # Quick start
12//!
13//! ```rust,no_run
14//! use autumn_web::prelude::*;
15//! use autumn_web::test::TestApp;
16//!
17//! #[get("/hello")]
18//! async fn hello() -> &'static str { "hi" }
19//!
20//! #[tokio::test]
21//! async fn hello_returns_200() {
22//!     let client = TestApp::new()
23//!         .routes(routes![hello])
24//!         .build();
25//!
26//!     client.get("/hello").send().await
27//!         .assert_status(200)
28//!         .assert_body_contains("hi");
29//! }
30//! ```
31//!
32//! # What's included
33//!
34//! | Type | Spring Boot equivalent | Purpose |
35//! |------|----------------------|---------|
36//! | [`TestApp`] | `@SpringBootTest` | Boot a fully-configured app for testing |
37//! | [`TestClient`] | `MockMvc` / `WebTestClient` | Fluent HTTP request builder |
38//! | [`TestResponse`] | `MvcResult` | Response with assertion helpers |
39//! | `TestDb` | `@DataJpaTest` | Shared Postgres testcontainer with pool |
40//!
41//! # Test-data factories
42//!
43//! `#[model]` generates a `{Model}Factory` builder so tests only declare the
44//! fields that matter for the scenario under test — all others stay at
45//! `Default::default()`:
46//!
47//! ```rust
48//! mod schema {
49//!     autumn_web::reexports::diesel::table! {
50//!         notes (id) {
51//!             id -> Int8,
52//!             title -> Text,
53//!             body -> Text,
54//!             pinned -> Bool,
55//!         }
56//!     }
57//! }
58//! use schema::notes;
59//!
60//! #[autumn_web::model]
61//! pub struct Note {
62//!     #[id]
63//!     pub id: i64,
64//!     pub title: String,
65//!     pub body: String,
66//!     pub pinned: bool,
67//! }
68//!
69//! // Zero required args — every field defaults to its type's `Default`.
70//! let draft: NewNote = Note::factory().build();
71//! assert_eq!(draft.title, "");
72//! assert!(!draft.pinned);
73//!
74//! // Override only the fields relevant to your test.
75//! let draft = Note::factory().title("Hello").pinned(true).build();
76//! assert_eq!(draft.title, "Hello");
77//! assert!(draft.pinned);
78//! assert_eq!(draft.body, ""); // untouched
79//! ```
80//!
81//! To persist the record call `.create(&pool)` instead of `.build()` — it
82//! inserts via Diesel and returns the fully-populated model (PK included).
83//! Pair it with `TestDb` for a self-contained DB test:
84//!
85//! ```rust,ignore
86//! #[tokio::test]
87//! #[ignore = "requires Docker (testcontainers)"]
88//! async fn note_round_trip() {
89//!     let db = TestDb::shared().await;
90//!     // run CREATE TABLE ... against db.pool() first, then:
91//!     let note = Note::factory().title("TDD").create(&db.pool()).await;
92//!     assert!(note.id > 0);
93//!     assert_eq!(note.title, "TDD");
94//! }
95//! ```
96//!
97//! # Database testing
98//!
99//! For tests that need a real database, use `TestDb` to share a single
100//! Postgres container across your test suite (rather than one per test):
101//!
102//! ```rust,ignore
103//! use autumn_web::test::{TestApp, TestDb};
104//!
105//! #[tokio::test]
106//! async fn creates_user_in_db() {
107//!     let db = TestDb::shared().await;
108//!     let client = TestApp::new()
109//!         .routes(routes![create_user, get_user])
110//!         .with_db(db.pool())
111//!         .build();
112//!
113//!     client.post("/users")
114//!         .json(&serde_json::json!({"name": "Alice"}))
115//!         .send().await
116//!         .assert_status(201);
117//! }
118//! ```
119
120use axum::body::Body;
121use axum::http::{Method, Request, StatusCode};
122use tower::ServiceExt;
123
124use crate::config::AutumnConfig;
125use crate::route::Route;
126
127use crate::state::AppState;
128
129#[cfg(feature = "db")]
130use diesel_async::AsyncPgConnection;
131#[cfg(feature = "db")]
132use diesel_async::pooled_connection::deadpool::Pool;
133
134// ── TestApp ────────────────────────────────────────────────────
135
136/// Builder for constructing a fully-configured Autumn application in tests.
137///
138/// Analogous to Spring Boot's `@SpringBootTest` -- it wires up routes,
139/// middleware, config, and optionally a database pool, then produces a
140/// [`TestClient`] ready to fire requests.
141///
142/// # Examples
143///
144/// ```rust,no_run
145/// use autumn_web::prelude::*;
146/// use autumn_web::test::TestApp;
147///
148/// #[get("/ping")]
149/// async fn ping() -> &'static str { "pong" }
150///
151/// #[tokio::test]
152/// async fn ping_works() {
153///     let client = TestApp::new()
154///         .routes(routes![ping])
155///         .build();
156///
157///     client.get("/ping").send().await.assert_ok();
158/// }
159/// ```
160pub struct TestApp {
161    routes: Vec<Route>,
162    merge_routers: Vec<axum::Router<crate::state::AppState>>,
163    nest_routers: Vec<(String, axum::Router<crate::state::AppState>)>,
164    custom_layers: Vec<crate::app::CustomLayerRegistration>,
165    config: AutumnConfig,
166    #[cfg(feature = "openapi")]
167    openapi: Option<crate::openapi::OpenApiConfig>,
168    #[cfg(feature = "db")]
169    pool: Option<Pool<AsyncPgConnection>>,
170    #[cfg(feature = "db")]
171    replica_pool: Option<Pool<AsyncPgConnection>>,
172    /// Deferred policy / scope registrations applied during
173    /// [`TestApp::build`].
174    policy_registrations: Vec<TestPolicyRegistration>,
175    /// Override for [`AppState::forbidden_response`]. Defaults to
176    /// the value derived from
177    /// [`SecurityConfig::forbidden_response`](crate::security::SecurityConfig::forbidden_response).
178    forbidden_response_override: Option<crate::authorization::ForbiddenResponse>,
179}
180
181type TestPolicyRegistration = Box<dyn FnOnce(&crate::authorization::PolicyRegistry) + Send>;
182
183impl TestApp {
184    /// Create a new test app builder with default configuration.
185    #[must_use]
186    pub fn new() -> Self {
187        let mut config = AutumnConfig::default();
188        config.profile = Some("test".into());
189        // Disable CSRF for tests by default (like Spring Security's test support)
190        config.security.csrf.enabled = false;
191
192        Self {
193            routes: Vec::new(),
194            merge_routers: Vec::new(),
195            nest_routers: Vec::new(),
196            custom_layers: Vec::new(),
197            config,
198            #[cfg(feature = "openapi")]
199            openapi: None,
200            #[cfg(feature = "db")]
201            pool: None,
202            #[cfg(feature = "db")]
203            replica_pool: None,
204            policy_registrations: Vec::new(),
205            forbidden_response_override: None,
206        }
207    }
208
209    /// Register a [`Policy`](crate::authorization::Policy) for
210    /// resource type `R`. Mirrors
211    /// [`AppBuilder::policy`](crate::app::AppBuilder::policy).
212    #[must_use]
213    pub fn policy<R, P>(mut self, policy: P) -> Self
214    where
215        R: Send + Sync + 'static,
216        P: crate::authorization::Policy<R>,
217    {
218        self.policy_registrations.push(Box::new(move |registry| {
219            registry.register_policy::<R, _>(policy);
220        }));
221        self
222    }
223
224    /// Register a [`Scope`](crate::authorization::Scope) for resource
225    /// type `R`. Mirrors
226    /// [`AppBuilder::scope`](crate::app::AppBuilder::scope).
227    #[must_use]
228    pub fn scope<R, S>(mut self, scope: S) -> Self
229    where
230        R: Send + Sync + 'static,
231        S: crate::authorization::Scope<R>,
232    {
233        self.policy_registrations.push(Box::new(move |registry| {
234            registry.register_scope::<R, _>(scope);
235        }));
236        self
237    }
238
239    /// Override the deny-response shape used by `#[authorize]` and
240    /// `#[repository(policy = ...)]` handlers. Useful for
241    /// round-tripping the `403`-vs-`404` decision in tests.
242    #[must_use]
243    pub const fn forbidden_response(
244        mut self,
245        value: crate::authorization::ForbiddenResponse,
246    ) -> Self {
247        self.forbidden_response_override = Some(value);
248        self
249    }
250
251    /// Enable `OpenAPI` spec generation for the test app.
252    ///
253    /// Mirrors [`crate::app::AppBuilder::openapi`] so integration tests
254    /// can exercise the `/v3/api-docs` and `/swagger-ui` endpoints.
255    ///
256    /// Gated behind the `openapi` Cargo feature.
257    #[cfg(feature = "openapi")]
258    #[must_use]
259    pub fn openapi(mut self, config: crate::openapi::OpenApiConfig) -> Self {
260        self.openapi = Some(config);
261        self
262    }
263
264    /// Merge a router into the internal application state.
265    ///
266    /// This is useful when testing modular route definitions without building
267    /// the full application.
268    #[must_use]
269    pub fn merge(mut self, router: axum::Router<crate::state::AppState>) -> Self {
270        self.merge_routers.push(router);
271        self
272    }
273
274    /// Nest a router under a specific path prefix for testing.
275    ///
276    /// This is useful for testing sub-applications or API versions.
277    #[must_use]
278    pub fn nest(mut self, path: &str, router: axum::Router<crate::state::AppState>) -> Self {
279        self.nest_routers.push((path.to_owned(), router));
280        self
281    }
282
283    /// Apply a custom [`tower::Layer`] to the entire test application.
284    ///
285    /// Mirrors [`crate::app::AppBuilder::layer`] so tests can exercise the
286    /// exact middleware wiring that `AppBuilder::run()` produces.
287    #[must_use]
288    pub fn layer<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
289        self.custom_layers
290            .push(crate::app::CustomLayerRegistration {
291                type_id: std::any::TypeId::of::<L>(),
292                apply: Box::new(move |router| layer.apply_to(router)),
293            });
294        self
295    }
296
297    /// Construct a [`TestClient`] directly from an `axum::Router`.
298    ///
299    /// Useful for bypassing `TestApp` builder if you just want to write requests
300    /// against a standard axum Router.
301    #[must_use]
302    pub const fn from_router(router: axum::Router) -> TestClient {
303        TestClient { router }
304    }
305
306    /// Register a collection of routes to be built into the `TestApp`.
307    #[must_use]
308    pub fn routes(mut self, routes: Vec<Route>) -> Self {
309        self.routes.extend(routes);
310        self
311    }
312
313    /// Override the default test configuration.
314    #[must_use]
315    pub fn config(mut self, config: AutumnConfig) -> Self {
316        self.config = config;
317        self
318    }
319
320    /// Set the active profile (default is `"test"`).
321    #[must_use]
322    pub fn profile(mut self, profile: &str) -> Self {
323        self.config.profile = Some(profile.to_owned());
324        self
325    }
326
327    /// Attach a database connection pool to the test app.
328    #[cfg(feature = "db")]
329    #[must_use]
330    pub fn with_db(mut self, pool: Pool<AsyncPgConnection>) -> Self {
331        self.pool = Some(pool);
332        self
333    }
334
335    /// Build the application and return a [`TestClient`] ready for requests.
336    ///
337    /// This constructs the full Axum router with all middleware applied,
338    /// identical to what `AppBuilder::run()` produces -- without binding
339    /// a TCP listener.
340    ///
341    /// The process-level global cache is cleared unconditionally so that
342    /// `#[cached]` functions inside this test app always use their
343    /// per-function Moka stores and do not accidentally inherit a Redis or
344    /// other shared backend installed by a previous test.
345    #[must_use]
346    pub fn build(self) -> TestClient {
347        // Reset the global cache to prevent cross-test contamination.
348        crate::cache::clear_global_cache();
349        let state = AppState {
350            extensions: std::sync::Arc::new(std::sync::RwLock::new(
351                std::collections::HashMap::new(),
352            )),
353            #[cfg(feature = "db")]
354            pool: self.pool,
355            #[cfg(feature = "db")]
356            replica_pool: self.replica_pool,
357            profile: self.config.profile.clone(),
358            started_at: std::time::Instant::now(),
359            health_detailed: self.config.health.detailed,
360            probes: crate::probe::ProbeState::ready_for_test(),
361            metrics: crate::middleware::MetricsCollector::new(),
362            log_levels: crate::actuator::LogLevels::new(&self.config.log.level),
363            task_registry: crate::actuator::TaskRegistry::new(),
364            job_registry: crate::actuator::JobRegistry::new(),
365            config_props: crate::actuator::ConfigProperties::default(),
366            #[cfg(feature = "ws")]
367            channels: crate::channels::Channels::new(32),
368            #[cfg(feature = "ws")]
369            shutdown: tokio_util::sync::CancellationToken::new(),
370            policy_registry: crate::authorization::PolicyRegistry::default(),
371            forbidden_response: self
372                .forbidden_response_override
373                .unwrap_or(self.config.security.forbidden_response),
374            auth_session_key: self.config.auth.session_key.clone(),
375            shared_cache: None,
376        };
377
378        for register in self.policy_registrations {
379            register(state.policy_registry());
380        }
381        crate::app::install_webhook_registry(&state, &self.config);
382
383        let router = crate::router::try_build_router_inner(
384            self.routes,
385            &self.config,
386            state,
387            crate::router::RouterContext {
388                exception_filters: Vec::new(),
389                scoped_groups: Vec::new(),
390                merge_routers: self.merge_routers,
391                nest_routers: self.nest_routers,
392                custom_layers: self.custom_layers,
393                error_page_renderer: None,
394                session_store: None,
395                #[cfg(feature = "openapi")]
396                openapi: self.openapi,
397            },
398        )
399        .expect("failed to build test router");
400        TestClient { router }
401    }
402}
403
404impl Default for TestApp {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410// ── TestClient ─────────────────────────────────────────────────
411
412/// Fluent HTTP client for integration tests.
413///
414/// Analogous to Spring Boot's `MockMvc` or Django's `Client`.
415/// Fires requests through the full Axum middleware pipeline using
416/// `tower::ServiceExt::oneshot()` -- no TCP listener required.
417///
418/// Created by [`TestApp::build()`].
419///
420/// # Examples
421///
422/// ```rust,ignore
423/// let client = TestApp::new().routes(routes![handler]).build();
424///
425/// // GET request
426/// client.get("/path").send().await.assert_ok();
427///
428/// // POST with JSON body
429/// client.post("/items")
430///     .json(&serde_json::json!({"name": "foo"}))
431///     .send().await
432///     .assert_status(201);
433///
434/// // PUT with header
435/// client.put("/items/1")
436///     .header("authorization", "Bearer token")
437///     .json(&serde_json::json!({"name": "bar"}))
438///     .send().await
439///     .assert_ok();
440/// ```
441pub struct TestClient {
442    router: axum::Router,
443}
444
445impl TestClient {
446    /// Unwrap the underlying [`axum::Router`] out of the [`TestClient`].
447    pub fn into_router(self) -> axum::Router {
448        self.router
449    }
450
451    /// Start building a GET request.
452    #[must_use]
453    pub fn get(&self, uri: &str) -> RequestBuilder {
454        RequestBuilder::new(self.router.clone(), Method::GET, uri)
455    }
456
457    /// Start building a POST request.
458    #[must_use]
459    pub fn post(&self, uri: &str) -> RequestBuilder {
460        RequestBuilder::new(self.router.clone(), Method::POST, uri)
461    }
462
463    /// Start building a PUT request.
464    #[must_use]
465    pub fn put(&self, uri: &str) -> RequestBuilder {
466        RequestBuilder::new(self.router.clone(), Method::PUT, uri)
467    }
468
469    /// Start building a DELETE request.
470    #[must_use]
471    pub fn delete(&self, uri: &str) -> RequestBuilder {
472        RequestBuilder::new(self.router.clone(), Method::DELETE, uri)
473    }
474
475    /// Start building a PATCH request.
476    #[must_use]
477    pub fn patch(&self, uri: &str) -> RequestBuilder {
478        RequestBuilder::new(self.router.clone(), Method::PATCH, uri)
479    }
480}
481
482// ── RequestBuilder ─────────────────────────────────────────────
483
484/// Fluent builder for composing an HTTP request in tests.
485///
486/// Created by [`TestClient::get()`], [`TestClient::post()`], etc.
487/// Call [`.send()`](Self::send) to fire the request and get a
488/// [`TestResponse`].
489pub struct RequestBuilder {
490    router: axum::Router,
491    method: Method,
492    uri: String,
493    headers: Vec<(String, String)>,
494    body: Body,
495}
496
497impl RequestBuilder {
498    fn new(router: axum::Router, method: Method, uri: &str) -> Self {
499        Self {
500            router,
501            method,
502            uri: uri.to_owned(),
503            headers: Vec::new(),
504            body: Body::empty(),
505        }
506    }
507
508    /// Add a header to the request.
509    #[must_use]
510    pub fn header(mut self, name: &str, value: &str) -> Self {
511        self.headers.push((name.to_owned(), value.to_owned()));
512        self
513    }
514
515    /// Set the request body to a JSON-serialized value.
516    ///
517    /// Automatically sets `Content-Type: application/json`.
518    #[must_use]
519    pub fn json(mut self, value: &serde_json::Value) -> Self {
520        self.headers
521            .push(("content-type".to_owned(), "application/json".to_owned()));
522        self.body = Body::from(serde_json::to_vec(value).expect("failed to serialize JSON body"));
523        self
524    }
525
526    /// Set the request body to URL-encoded form data.
527    ///
528    /// Automatically sets `Content-Type: application/x-www-form-urlencoded`.
529    #[must_use]
530    pub fn form(mut self, body: &str) -> Self {
531        self.headers.push((
532            "content-type".to_owned(),
533            "application/x-www-form-urlencoded".to_owned(),
534        ));
535        self.body = Body::from(body.to_owned());
536        self
537    }
538
539    /// Set a raw string body.
540    #[must_use]
541    pub fn body(mut self, body: impl Into<Body>) -> Self {
542        self.body = body.into();
543        self
544    }
545
546    /// Fire the request through the full middleware pipeline and return
547    /// a [`TestResponse`].
548    pub async fn send(self) -> TestResponse {
549        let mut builder = Request::builder().method(self.method).uri(&self.uri);
550
551        for (name, value) in &self.headers {
552            builder = builder.header(name.as_str(), value.as_str());
553        }
554
555        let request = builder.body(self.body).expect("failed to build request");
556
557        let response = self.router.oneshot(request).await.expect("request failed");
558
559        let status = response.status();
560        let headers: Vec<(String, String)> = response
561            .headers()
562            .iter()
563            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_owned()))
564            .collect();
565        let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
566            .await
567            .expect("failed to read response body");
568
569        TestResponse {
570            status,
571            headers,
572            body: body_bytes.to_vec(),
573        }
574    }
575}
576
577// ── TestResponse ───────────────────────────────────────────────
578
579/// HTTP response from a test request with fluent assertion helpers.
580///
581/// All assertion methods return `&Self` for chaining:
582///
583/// ```rust,ignore
584/// client.get("/users/1").send().await
585///     .assert_ok()
586///     .assert_header("content-type", "application/json")
587///     .assert_body_contains("Alice");
588/// ```
589///
590/// Fields are public so you can construct a `TestResponse` directly in unit
591/// tests that don't need a full HTTP round-trip:
592///
593/// ```rust
594/// use autumn_web::test::TestResponse;
595/// use axum::http::StatusCode;
596///
597/// let resp = TestResponse {
598///     status: StatusCode::OK,
599///     headers: vec![
600///         ("content-type".into(), "application/json".into()),
601///         ("x-request-id".into(), "abc-123".into()),
602///     ],
603///     body: br#"{"name":"Alice"}"#.to_vec(),
604/// };
605///
606/// resp.assert_ok()
607///     .assert_header_contains("content-type", "json")
608///     .assert_body_contains("Alice");
609///
610/// assert_eq!(resp.header("x-request-id"), Some("abc-123"));
611/// ```
612pub struct TestResponse {
613    /// HTTP status code.
614    pub status: StatusCode,
615    /// Response headers as `(name, value)` pairs.
616    pub headers: Vec<(String, String)>,
617    /// Raw response body bytes.
618    pub body: Vec<u8>,
619}
620
621impl TestResponse {
622    /// Get the response body as a UTF-8 string.
623    ///
624    /// # Panics
625    ///
626    /// Panics if the body is not valid UTF-8.
627    #[must_use]
628    pub fn text(&self) -> String {
629        String::from_utf8(self.body.clone()).unwrap_or_else(|e| {
630            panic!(
631                "response body is not valid UTF-8: {e}\nRaw bytes: {:?}",
632                self.body
633            )
634        })
635    }
636
637    /// Deserialize the response body as JSON.
638    ///
639    /// # Panics
640    ///
641    /// Panics if the body is not valid JSON or cannot be deserialized
642    /// into `T`.
643    #[must_use]
644    pub fn json<T: serde::de::DeserializeOwned>(&self) -> T {
645        serde_json::from_slice(&self.body).unwrap_or_else(|e| {
646            panic!(
647                "failed to parse response body as JSON: {e}\nBody: {}",
648                String::from_utf8_lossy(&self.body)
649            )
650        })
651    }
652
653    /// Get the value of a response header.
654    #[must_use]
655    pub fn header(&self, name: &str) -> Option<&str> {
656        let name_lower = name.to_lowercase();
657        self.headers
658            .iter()
659            .find(|(k, _)| k.to_lowercase() == name_lower)
660            .map(|(_, v)| v.as_str())
661    }
662
663    // ── Assertion helpers ──────────────────────────────────────
664
665    /// Assert the response status is 200 OK.
666    #[track_caller]
667    pub fn assert_ok(&self) -> &Self {
668        assert_eq!(
669            self.status,
670            StatusCode::OK,
671            "expected 200 OK, got {}.\nBody: {}",
672            self.status,
673            String::from_utf8_lossy(&self.body)
674        );
675        self
676    }
677
678    /// Assert the response status matches the given code.
679    #[track_caller]
680    pub fn assert_status(&self, expected: u16) -> &Self {
681        assert_eq!(
682            self.status.as_u16(),
683            expected,
684            "expected status {expected}, got {}.\nBody: {}",
685            self.status,
686            String::from_utf8_lossy(&self.body)
687        );
688        self
689    }
690
691    /// Assert the response status indicates a successful request (2xx).
692    #[track_caller]
693    pub fn assert_success(&self) -> &Self {
694        assert!(
695            self.status.is_success(),
696            "expected 2xx success, got {}.\nBody: {}",
697            self.status,
698            String::from_utf8_lossy(&self.body)
699        );
700        self
701    }
702
703    /// Assert a response header exists and equals the expected value.
704    #[track_caller]
705    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
706        let value = self.header(name).unwrap_or_else(|| {
707            panic!(
708                "expected header `{name}` to be present.\nAvailable headers: {:?}",
709                self.headers
710            )
711        });
712        assert_eq!(
713            value, expected,
714            "header `{name}`: expected `{expected}`, got `{value}`"
715        );
716        self
717    }
718
719    /// Assert a response header exists and contains the expected substring.
720    #[track_caller]
721    pub fn assert_header_contains(&self, name: &str, substring: &str) -> &Self {
722        let value = self.header(name).unwrap_or_else(|| {
723            panic!(
724                "expected header `{name}` to be present.\nAvailable headers: {:?}",
725                self.headers
726            )
727        });
728        assert!(
729            value.contains(substring),
730            "header `{name}`: expected `{value}` to contain `{substring}`"
731        );
732        self
733    }
734
735    /// Assert the response body contains the given substring.
736    #[track_caller]
737    pub fn assert_body_contains(&self, substring: &str) -> &Self {
738        let body = self.text();
739        assert!(
740            body.contains(substring),
741            "expected body to contain `{substring}`.\nBody: {body}"
742        );
743        self
744    }
745
746    /// Assert the response body exactly equals the given string.
747    #[track_caller]
748    pub fn assert_body_eq(&self, expected: &str) -> &Self {
749        let body = self.text();
750        assert_eq!(body, expected, "body mismatch.\nActual Body: {body}");
751        self
752    }
753
754    /// Assert the response body deserializes to JSON matching the predicate.
755    #[track_caller]
756    pub fn assert_json<T, F>(&self, predicate: F) -> &Self
757    where
758        T: serde::de::DeserializeOwned,
759        F: FnOnce(&T),
760    {
761        let value: T = self.json();
762        predicate(&value);
763        self
764    }
765
766    /// Assert the response body is empty.
767    #[track_caller]
768    pub fn assert_body_empty(&self) -> &Self {
769        assert!(
770            self.body.is_empty(),
771            "expected empty body, got {} bytes: {}",
772            self.body.len(),
773            String::from_utf8_lossy(&self.body)
774        );
775        self
776    }
777}
778
779// ── TestDb ─────────────────────────────────────────────────────
780
781/// Shared Postgres testcontainer for database integration tests.
782///
783/// Rather than spinning up a new container per test (slow!), `TestDb`
784/// provides a shared container that all tests in a binary can reuse.
785/// This mirrors Spring Boot's `@Testcontainers` with `@Container` +
786/// `static` pattern.
787///
788/// Requires the `test-support` feature (and `db`):
789///
790/// ```toml
791/// [dev-dependencies]
792/// autumn-web = { path = "..", features = ["test-support"] }
793/// ```
794///
795/// # Examples
796///
797/// ```rust,ignore
798/// use autumn_web::test::{TestApp, TestDb};
799///
800/// #[tokio::test]
801/// #[ignore = "requires Docker"]
802/// async fn db_test() {
803///     let db = TestDb::shared().await;
804///     let client = TestApp::new()
805///         .routes(routes![my_handler])
806///         .with_db(db.pool())
807///         .build();
808///
809///     // Run migrations or seed data via db.pool()
810///     client.get("/data").send().await.assert_ok();
811/// }
812/// ```
813#[cfg(all(feature = "db", feature = "test-support"))]
814pub struct TestDb {
815    _container: testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>,
816    pool: Pool<AsyncPgConnection>,
817    url: String,
818}
819
820#[cfg(all(feature = "db", feature = "test-support"))]
821impl TestDb {
822    /// Start a new Postgres testcontainer and create a connection pool.
823    ///
824    /// For most test suites, prefer [`TestDb::shared()`] to reuse a
825    /// single container across all tests.
826    pub async fn new() -> Self {
827        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
828        use testcontainers::runners::AsyncRunner;
829        use testcontainers_modules::postgres::Postgres;
830
831        let container = Postgres::default()
832            .start()
833            .await
834            .expect("failed to start Postgres testcontainer (is Docker running?)");
835
836        let host = container
837            .get_host()
838            .await
839            .expect("failed to build test router");
840        let port = container
841            .get_host_port_ipv4(5432)
842            .await
843            .expect("failed to build test router");
844        let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
845
846        let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(&url);
847        let pool = Pool::builder(manager)
848            .max_size(5)
849            .build()
850            .expect("failed to build connection pool");
851
852        Self {
853            _container: container,
854            pool,
855            url,
856        }
857    }
858
859    /// Get a shared `TestDb` instance, starting the container on first use.
860    ///
861    /// Uses a process-global `OnceLock` so the container is started only
862    /// once per test binary, regardless of how many tests call this method.
863    /// This dramatically speeds up test suites with multiple DB tests.
864    ///
865    /// The container is automatically cleaned up when the process exits.
866    pub async fn shared() -> &'static Self {
867        use std::sync::OnceLock;
868        use tokio::sync::OnceCell;
869
870        // Two-phase init: OnceLock for the OnceCell, OnceCell for the async init.
871        static CELL: OnceLock<OnceCell<TestDb>> = OnceLock::new();
872        let once = CELL.get_or_init(OnceCell::new);
873        once.get_or_init(Self::new).await
874    }
875
876    /// Get the database connection pool.
877    #[must_use]
878    pub fn pool(&self) -> Pool<AsyncPgConnection> {
879        self.pool.clone()
880    }
881
882    /// Get the Postgres connection URL.
883    #[must_use]
884    pub fn url(&self) -> &str {
885        &self.url
886    }
887
888    /// Execute raw SQL against the test database.
889    ///
890    /// Useful for creating tables, seeding data, or running migrations
891    /// in tests.
892    ///
893    /// # Examples
894    ///
895    /// ```rust,ignore
896    /// let db = TestDb::shared().await;
897    /// db.execute_sql("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)")
898    ///     .await;
899    /// ```
900    pub async fn execute_sql(&self, sql: &str) {
901        use diesel_async::RunQueryDsl;
902        let mut conn = self.pool.get().await.expect("failed to get connection");
903        diesel::sql_query(sql)
904            .execute(&mut *conn)
905            .await
906            .unwrap_or_else(|e| panic!("SQL execution failed: {e}\nSQL: {sql}"));
907    }
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913
914    fn test_routes() -> Vec<Route> {
915        use axum::routing;
916
917        async fn hello() -> &'static str {
918            "hello"
919        }
920
921        async fn echo_json(
922            axum::Json(value): axum::Json<serde_json::Value>,
923        ) -> axum::Json<serde_json::Value> {
924            axum::Json(value)
925        }
926
927        async fn status_201() -> (StatusCode, &'static str) {
928            (StatusCode::CREATED, "created")
929        }
930
931        vec![
932            Route {
933                method: Method::GET,
934                path: "/hello",
935                handler: routing::get(hello),
936                name: "hello",
937                api_doc: crate::openapi::ApiDoc {
938                    method: "GET",
939                    path: "/hello",
940                    operation_id: "hello",
941                    success_status: 200,
942                    ..Default::default()
943                },
944                repository: None,
945            },
946            Route {
947                method: Method::POST,
948                path: "/echo",
949                handler: routing::post(echo_json),
950                name: "echo",
951                api_doc: crate::openapi::ApiDoc {
952                    method: "POST",
953                    path: "/echo",
954                    operation_id: "echo",
955                    success_status: 200,
956                    ..Default::default()
957                },
958                repository: None,
959            },
960            Route {
961                method: Method::POST,
962                path: "/create",
963                handler: routing::post(status_201),
964                name: "create",
965                api_doc: crate::openapi::ApiDoc {
966                    method: "POST",
967                    path: "/create",
968                    operation_id: "create",
969                    success_status: 201,
970                    ..Default::default()
971                },
972                repository: None,
973            },
974        ]
975    }
976
977    #[tokio::test]
978    async fn test_app_get_request() {
979        let client = TestApp::new().routes(test_routes()).build();
980        client.get("/hello").send().await.assert_ok();
981    }
982
983    #[tokio::test]
984    async fn test_app_post_json() {
985        let client = TestApp::new().routes(test_routes()).build();
986
987        client
988            .post("/echo")
989            .json(&serde_json::json!({"key": "value"}))
990            .send()
991            .await
992            .assert_ok()
993            .assert_body_contains("key");
994    }
995
996    #[tokio::test]
997    async fn test_response_assert_status() {
998        let client = TestApp::new().routes(test_routes()).build();
999
1000        client
1001            .post("/create")
1002            .send()
1003            .await
1004            .assert_status(201)
1005            .assert_body_eq("created");
1006    }
1007
1008    #[tokio::test]
1009    async fn test_response_assert_success() {
1010        let client = TestApp::new().routes(test_routes()).build();
1011        client.get("/hello").send().await.assert_success();
1012    }
1013
1014    #[tokio::test]
1015    async fn test_not_found() {
1016        let client = TestApp::new().routes(test_routes()).build();
1017        client.get("/nonexistent").send().await.assert_status(404);
1018    }
1019
1020    #[tokio::test]
1021    async fn test_response_json_deserialization() {
1022        let client = TestApp::new().routes(test_routes()).build();
1023
1024        let resp = client
1025            .post("/echo")
1026            .json(&serde_json::json!({"count": 42}))
1027            .send()
1028            .await;
1029
1030        resp.assert_ok().assert_json::<serde_json::Value, _>(|v| {
1031            assert_eq!(v["count"], 42);
1032        });
1033    }
1034
1035    #[tokio::test]
1036    async fn test_custom_header() {
1037        let client = TestApp::new().routes(test_routes()).build();
1038
1039        let resp = client
1040            .get("/hello")
1041            .header("x-custom", "test-value")
1042            .send()
1043            .await;
1044        resp.assert_ok();
1045    }
1046
1047    #[tokio::test]
1048    async fn test_client_default() {
1049        let _app = TestApp::default();
1050    }
1051}