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}