autumn_web/test.rs
1#![allow(clippy::type_complexity, clippy::too_many_lines)]
2//! First-party integration-testing utilities for Autumn applications.
3//!
4//! This module brings Autumn's testing story to parity with frameworks like
5//! Spring Boot's `@SpringBootTest` + `MockMvc` and Django's `TestCase` +
6//! `Client`. Import it in your integration tests:
7//!
8//! ```rust,ignore
9//! use autumn_web::test::{TestApp, TestClient};
10//! ```
11//!
12//! # Quick start
13//!
14//! ```rust,no_run
15//! use autumn_web::prelude::*;
16//! use autumn_web::test::TestApp;
17//!
18//! #[get("/hello")]
19//! async fn hello() -> &'static str { "hi" }
20//!
21//! #[tokio::test]
22//! async fn hello_returns_200() {
23//! let client = TestApp::new()
24//! .routes(routes![hello])
25//! .build();
26//!
27//! client.get("/hello").send().await
28//! .assert_status(200)
29//! .assert_body_contains("hi");
30//! }
31//! ```
32//!
33//! # What's included
34//!
35//! | Type | Spring Boot equivalent | Purpose |
36//! |------|----------------------|---------|
37//! | [`TestApp`] | `@SpringBootTest` | Boot a fully-configured app for testing |
38//! | [`TestClient`] | `MockMvc` / `WebTestClient` | Fluent HTTP request builder |
39//! | [`TestResponse`] | `MvcResult` | Response with assertion helpers |
40//! | `TestDb` | `@DataJpaTest` | Shared Postgres testcontainer with pool |
41//!
42//! # Structural HTML assertions
43//!
44//! Autumn renders server-side HTML (Maud + htmx), so tests should assert on a
45//! page's *structure* — "the table has exactly N rows", "this link points at
46//! `/notes/1`" — rather than brittle substrings. [`TestResponse`] parses the
47//! body with a real HTML parser and matches against a CSS-selector subset
48//! (tag, `.class`, `#id`, `[attr=…]`, plus descendant/child combinators), so
49//! assertions survive cosmetic template changes (whitespace, attribute order,
50//! wrapping markup) that would break [`TestResponse::assert_body_contains`].
51//! They work for full documents and for partial/fragment responses (htmx
52//! swaps) alike.
53//!
54//! The worked example below asserts a scaffolded notes-index page's row count
55//! and the link target of each row. Every assertion returns `&Self`, so they
56//! chain with the status/header/body matchers:
57//!
58//! ```rust
59//! use autumn_web::test::TestResponse;
60//! use axum::http::StatusCode;
61//!
62//! // The HTML a scaffolded `notes#index` view renders: a table with one
63//! // `<tr>` per note, each linking to `/notes/{id}`.
64//! let resp = TestResponse {
65//! status: StatusCode::OK,
66//! headers: vec![("content-type".into(), "text/html; charset=utf-8".into())],
67//! body: br#"
68//! <table class="notes">
69//! <tbody>
70//! <tr class="note-row"><td><a href="/notes/1">First note</a></td></tr>
71//! <tr class="note-row"><td><a href="/notes/2">Second note</a></td></tr>
72//! <tr class="note-row"><td><a href="/notes/3">Third note</a></td></tr>
73//! </tbody>
74//! </table>
75//! "#.to_vec(),
76//! ..Default::default()
77//! };
78//!
79//! resp.assert_ok()
80//! .assert_selector("table.notes") // the table is present
81//! .assert_selector_count("tbody tr.note-row", 3) // exactly three rows
82//! .assert_attr("tr.note-row a", "href", "/notes/1") // first row's link target
83//! .assert_text("tr.note-row a", "First note") // …and its visible text
84//! .assert_no_selector(".flash--error"); // no error flash rendered
85//!
86//! // Non-asserting accessors compose for custom checks:
87//! assert_eq!(
88//! resp.selector_attr("tbody tr.note-row a", "href"),
89//! vec![Some("/notes/1".into()), Some("/notes/2".into()), Some("/notes/3".into())],
90//! );
91//! assert_eq!(resp.selector_count("tr.note-row"), 3);
92//! ```
93//!
94//! # Test-data factories
95//!
96//! `#[model]` generates a `{Model}Factory` builder so tests only declare the
97//! fields that matter for the scenario under test — all others stay at
98//! `Default::default()`:
99//!
100//! ```rust
101//! mod schema {
102//! autumn_web::reexports::diesel::table! {
103//! notes (id) {
104//! id -> Int8,
105//! title -> Text,
106//! body -> Text,
107//! pinned -> Bool,
108//! }
109//! }
110//! }
111//! use schema::notes;
112//!
113//! #[autumn_web::model]
114//! pub struct Note {
115//! #[id]
116//! pub id: i64,
117//! pub title: String,
118//! pub body: String,
119//! pub pinned: bool,
120//! }
121//!
122//! // Zero required args — every field defaults to its type's `Default`.
123//! let draft: NewNote = Note::factory().build();
124//! assert_eq!(draft.title, "");
125//! assert!(!draft.pinned);
126//!
127//! // Override only the fields relevant to your test.
128//! let draft = Note::factory().title("Hello").pinned(true).build();
129//! assert_eq!(draft.title, "Hello");
130//! assert!(draft.pinned);
131//! assert_eq!(draft.body, ""); // untouched
132//! ```
133//!
134//! To persist the record call `.create(&pool)` instead of `.build()` — it
135//! inserts via Diesel and returns the fully-populated model (PK included).
136//! Pair it with `TestDb` for a self-contained DB test:
137//!
138//! ```rust,ignore
139//! #[tokio::test]
140//! #[ignore = "requires Docker (testcontainers)"]
141//! async fn note_round_trip() {
142//! let db = TestDb::shared().await;
143//! // run CREATE TABLE ... against db.pool() first, then:
144//! let note = Note::factory().title("TDD").create(&db.pool()).await;
145//! assert!(note.id > 0);
146//! assert_eq!(note.title, "TDD");
147//! }
148//! ```
149//!
150//! # Database testing
151//!
152//! For tests that need a real database, use `TestDb` to share a single
153//! Postgres container across your test suite (rather than one per test):
154//!
155//! ```rust,ignore
156//! use autumn_web::test::{TestApp, TestDb};
157//!
158//! #[tokio::test]
159//! async fn creates_user_in_db() {
160//! let db = TestDb::shared().await;
161//! let client = TestApp::new()
162//! .routes(routes![create_user, get_user])
163//! .with_db(db.pool())
164//! .build();
165//!
166//! client.post("/users")
167//! .json(&serde_json::json!({"name": "Alice"}))
168//! .send().await
169//! .assert_status(201);
170//! }
171//! ```
172//!
173//! # Asserting channel broadcasts
174//!
175//! Opt in with `TestApp::record_broadcasts` to capture every channel
176//! publication a request makes — no hand-written spy needed — then assert on
177//! it with `TestClient::assert_broadcast`,
178//! `TestClient::assert_broadcast_count`,
179//! `TestClient::assert_no_broadcasts`, or read them back in order with
180//! `TestClient::broadcasts` / `TestClient::broadcasts_on`. Both raw
181//! `publish` text and `publish_html` HTML/OOB payloads are recorded. The
182//! recorder is scoped to the client, so parallel tests never leak into one
183//! another, and nothing is installed unless you call it.
184//!
185//! ```rust
186//! # #[cfg(feature = "ws")]
187//! # mod broadcast_example {
188//! use autumn_web::prelude::*;
189//! use autumn_web::test::TestApp;
190//!
191//! #[post("/notes")]
192//! async fn create_note(State(state): State<AppState>) -> &'static str {
193//! state.broadcast().publish("notes", "created").unwrap();
194//! "ok"
195//! }
196//!
197//! pub fn run() {
198//! tokio::runtime::Runtime::new().unwrap().block_on(async {
199//! let client = TestApp::new()
200//! .routes(routes![create_note])
201//! .record_broadcasts()
202//! .build();
203//!
204//! client.post("/notes").send().await.assert_ok();
205//!
206//! client
207//! .assert_broadcast_count("notes", 1)
208//! .assert_broadcast("notes", |b| b.payload() == "created");
209//! });
210//! }
211//! # }
212//! # #[cfg(feature = "ws")]
213//! # broadcast_example::run();
214//! ```
215//!
216//! # Testing authenticated routes
217//!
218//! [`TestClient`] carries a **cookie jar**: every response's `Set-Cookie` is
219//! stored and replayed on later requests from the same client, so a real
220//! `POST /login` → `GET /dashboard` flow works with zero manual header
221//! threading — exactly like a browser session.
222//!
223//! When you only need an authenticated *identity* (not the login endpoint
224//! under test), [`TestClient::acting_as`] mints the session directly, so a
225//! secured route can be tested in ≤2 lines of setup:
226//!
227//! ```rust
228//! # mod acting_as_example {
229//! use autumn_web::prelude::*;
230//! use autumn_web::test::TestApp;
231//!
232//! #[get("/dashboard")]
233//! #[secured]
234//! async fn dashboard() -> &'static str {
235//! "welcome"
236//! }
237//!
238//! pub fn run() {
239//! tokio::runtime::Runtime::new().unwrap().block_on(async {
240//! let client = TestApp::new().routes(routes![dashboard]).build();
241//! client.acting_as(42).await; // ← authenticated as user 42
242//!
243//! client.get("/dashboard").send().await.assert_ok();
244//! });
245//! }
246//! # }
247//! # acting_as_example::run();
248//! ```
249//!
250//! `acting_as` sets **identity only** — authorization still runs, so a user it
251//! acts as who lacks a required role or scope is still denied.
252//! [`TestClient::log_out`] clears the session cookie, reverting the client to
253//! an unauthenticated state. These helpers mirror the auth-testing story in
254//! other frameworks:
255//!
256//! | Autumn | Laravel | Rails | Django | Phoenix |
257//! |--------|---------|-------|--------|---------|
258//! | [`acting_as`](TestClient::acting_as) / [`login_as`](TestClient::login_as) | `actingAs` | `sign_in` | `force_login` | `log_in_user` |
259//! | [`log_out`](TestClient::log_out) | `Auth::logout` | `sign_out` | `logout` | `log_out_user` |
260
261use axum::body::Body;
262use axum::http::{Method, Request, StatusCode};
263use tower::ServiceExt;
264
265use crate::config::AutumnConfig;
266use crate::route::Route;
267
268use crate::state::AppState;
269
270// Only the `test-support`-gated `TestDb` (a Postgres testcontainer helper) names
271// `AsyncPgConnection` by its short name now; the `TestClient` pool fields use the
272// `RuntimeConnection` alias, and the transactional establish path uses the fully
273// qualified path — so without `test-support` this import would be unused.
274#[cfg(all(feature = "db", feature = "test-support"))]
275use diesel_async::AsyncPgConnection;
276// Used by the Postgres transactional establish path and by the `test-support`
277// `TestDb`; neither is compiled in a `--features sqlite` build without
278// `test-support`, so this import would otherwise be unused there.
279#[cfg(all(feature = "db", any(not(feature = "sqlite"), feature = "test-support")))]
280use diesel_async::RunQueryDsl;
281#[cfg(feature = "db")]
282use diesel_async::pooled_connection::deadpool::Pool;
283
284// ── Mail recording helpers ─────────────────────────────────────
285
286/// Snapshot of an email captured by the built-in test mail recorder.
287///
288/// Available on [`TestClient`] via [`TestClient::sent_mail()`] when the `mail`
289/// feature is enabled.
290///
291/// # Example
292///
293/// ```rust,ignore
294/// use autumn_web::test::TestApp;
295///
296/// let client = TestApp::new().config(cfg).routes(routes![handler]).build();
297/// client.post("/signup").json(&body).send().await.assert_ok();
298///
299/// // ≤ 3 lines to assert an email was sent:
300/// client.assert_email_count(1);
301/// client.assert_email_sent(|m| m.to.iter().any(|a| a == "alice@example.com"));
302/// client.assert_email_sent(|m| m.subject == "Welcome!");
303/// ```
304#[cfg(feature = "mail")]
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct SentMail {
307 /// `From` header value (after mailer defaults are applied).
308 pub from: Option<String>,
309 /// `Reply-To` header value.
310 pub reply_to: Option<String>,
311 /// `To` recipients.
312 pub to: Vec<String>,
313 /// `Subject` header.
314 pub subject: String,
315 /// HTML body, if provided.
316 pub html: Option<String>,
317 /// Plain-text body, if provided.
318 pub text: Option<String>,
319 /// Files attached to this message, in declared order.
320 pub attachments: Vec<crate::mail::MailAttachment>,
321}
322
323#[cfg(feature = "mail")]
324impl From<&crate::mail::Mail> for SentMail {
325 fn from(m: &crate::mail::Mail) -> Self {
326 Self {
327 from: m.from.clone(),
328 reply_to: m.reply_to.clone(),
329 to: m.to.clone(),
330 subject: m.subject.clone(),
331 html: m.html.clone(),
332 text: m.text.clone(),
333 attachments: m.attachments.clone(),
334 }
335 }
336}
337
338/// Built-in per-`TestClient` recording mail interceptor.
339///
340/// Auto-installed by [`TestApp::build`] — no `.with_mail_interceptor()` needed.
341/// Composes with any user-supplied interceptor (the user's interceptor still runs).
342#[cfg(feature = "mail")]
343#[derive(Clone, Default)]
344struct MailRecorder {
345 mails: std::sync::Arc<std::sync::Mutex<Vec<SentMail>>>,
346}
347
348#[cfg(feature = "mail")]
349impl MailRecorder {
350 fn new() -> Self {
351 Self::default()
352 }
353
354 fn get_sent(&self) -> Vec<SentMail> {
355 self.mails.lock().unwrap().clone()
356 }
357}
358
359#[cfg(feature = "mail")]
360impl crate::interceptor::MailInterceptor for MailRecorder {
361 fn intercept<'a>(
362 &'a self,
363 mail: &'a crate::mail::Mail,
364 next: std::pin::Pin<
365 Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
366 >,
367 ) -> std::pin::Pin<
368 Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
369 > {
370 let snapshot = SentMail::from(mail);
371 let mails = std::sync::Arc::clone(&self.mails);
372 Box::pin(async move {
373 let result = next.await;
374 if result.is_ok() {
375 mails.lock().unwrap().push(snapshot);
376 }
377 result
378 })
379 }
380}
381
382/// Chains two [`MailInterceptor`](crate::interceptor::MailInterceptor)s so that
383/// `first` runs before `second`, both before the underlying transport.
384#[cfg(feature = "mail")]
385struct ChainedMailInterceptor {
386 first: std::sync::Arc<dyn crate::interceptor::MailInterceptor>,
387 second: std::sync::Arc<dyn crate::interceptor::MailInterceptor>,
388}
389
390#[cfg(feature = "mail")]
391impl crate::interceptor::MailInterceptor for ChainedMailInterceptor {
392 fn intercept<'a>(
393 &'a self,
394 mail: &'a crate::mail::Mail,
395 next: std::pin::Pin<
396 Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
397 >,
398 ) -> std::pin::Pin<
399 Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
400 > {
401 let second_next = self.second.intercept(mail, next);
402 self.first.intercept(mail, second_next)
403 }
404}
405
406/// A single background-job enqueue captured by the built-in test job recorder.
407///
408/// Available on [`TestClient`] via [`TestClient::enqueued_jobs`]. The recorder
409/// is always on for [`TestApp`]-built clients — no `.with_job_interceptor()`
410/// boilerplate is required. Both the registered job `name` and the fully
411/// serialized `payload` (the exact `serde_json::Value` handed to the backend)
412/// are captured, so assertions can match on name alone or name-and-payload.
413///
414/// # Example
415///
416/// ```rust,ignore
417/// use autumn_web::test::TestApp;
418/// use serde_json::json;
419///
420/// let client = TestApp::new().plugin(MyJobs).routes(routes![signup]).build();
421/// client.post("/signup").json(&body).send().await.assert_ok();
422///
423/// client.assert_job_enqueued_with("send_welcome", json!({ "user_id": 7 }));
424/// ```
425#[derive(Clone, Debug)]
426pub struct RecordedJob {
427 /// The registered name of the enqueued job.
428 pub name: String,
429 /// The JSON payload the job was enqueued with (the real serialized args).
430 pub payload: serde_json::Value,
431}
432
433/// Built-in per-`TestApp` recording job interceptor.
434///
435/// Auto-installed by [`TestApp::build`] — no `.with_job_interceptor()` needed.
436/// Composes with any user-supplied interceptor (the user's interceptor still
437/// runs, after the recorder). Records every enqueue — across `enqueue`,
438/// `enqueue_after_commit`, and `enqueue_in_tx`, which all funnel through the
439/// same enqueue interceptor seam — in the order they were enqueued.
440#[derive(Clone, Default)]
441struct JobRecorder {
442 jobs: std::sync::Arc<std::sync::Mutex<Vec<RecordedJob>>>,
443}
444
445impl JobRecorder {
446 fn new() -> Self {
447 Self::default()
448 }
449
450 fn recorded(&self) -> Vec<RecordedJob> {
451 self.jobs.lock().unwrap().clone()
452 }
453
454 /// Take the captured jobs, leaving the recorder empty — used by
455 /// [`TestClient::perform_enqueued_jobs`] to drain the queue exactly once.
456 fn drain(&self) -> Vec<RecordedJob> {
457 std::mem::take(&mut *self.jobs.lock().unwrap())
458 }
459}
460
461impl crate::interceptor::JobInterceptor for JobRecorder {
462 fn intercept_enqueue<'a>(
463 &'a self,
464 name: &'a str,
465 payload: &'a serde_json::Value,
466 next: std::pin::Pin<
467 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
468 >,
469 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
470 {
471 let record = RecordedJob {
472 name: name.to_string(),
473 payload: payload.clone(),
474 };
475 let jobs = std::sync::Arc::clone(&self.jobs);
476 Box::pin(async move {
477 // Record the enqueue intent up front, then let delivery proceed so
478 // the app's real backend/worker still sees the job (mirroring how
479 // the mail recorder does not suppress the underlying transport).
480 jobs.lock().unwrap().push(record);
481 next.await
482 })
483 }
484
485 fn intercept_execute<'a>(
486 &'a self,
487 _name: &'a str,
488 _payload: &'a serde_json::Value,
489 next: std::pin::Pin<
490 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
491 >,
492 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
493 {
494 // The recorder only observes enqueues; execution passes straight through.
495 next
496 }
497}
498
499/// Chains two [`JobInterceptor`](crate::interceptor::JobInterceptor)s so that
500/// `first` runs before `second`, both before the actual enqueue/execute.
501struct ChainedJobInterceptor {
502 first: std::sync::Arc<dyn crate::interceptor::JobInterceptor>,
503 second: std::sync::Arc<dyn crate::interceptor::JobInterceptor>,
504}
505
506impl crate::interceptor::JobInterceptor for ChainedJobInterceptor {
507 fn intercept_enqueue<'a>(
508 &'a self,
509 name: &'a str,
510 payload: &'a serde_json::Value,
511 next: std::pin::Pin<
512 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
513 >,
514 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
515 {
516 let second_next = self.second.intercept_enqueue(name, payload, next);
517 self.first.intercept_enqueue(name, payload, second_next)
518 }
519
520 fn intercept_execute<'a>(
521 &'a self,
522 name: &'a str,
523 payload: &'a serde_json::Value,
524 next: std::pin::Pin<
525 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
526 >,
527 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
528 {
529 let second_next = self.second.intercept_execute(name, payload, next);
530 self.first.intercept_execute(name, payload, second_next)
531 }
532}
533
534/// Outcome report returned by [`TestClient::perform_enqueued_jobs`].
535///
536/// Holds one `(job name, result)` entry per drained job, in the order the jobs
537/// were enqueued. Per-job handler errors are surfaced here rather than
538/// swallowed: inspect them with [`Self::failures`], or fail the test outright
539/// with [`Self::assert_all_succeeded`]. A captured job whose name has no
540/// registered handler is reported as a failure too — never silently skipped.
541///
542/// # Example
543///
544/// ```rust,ignore
545/// let report = client.perform_enqueued_jobs().await;
546/// report.assert_all_succeeded();
547/// ```
548#[derive(Debug)]
549pub struct PerformedJobs {
550 outcomes: Vec<(String, crate::AutumnResult<()>)>,
551}
552
553impl PerformedJobs {
554 /// Every performed job's `(name, result)`, in the order they were enqueued.
555 pub fn outcomes(&self) -> &[(String, crate::AutumnResult<()>)] {
556 &self.outcomes
557 }
558
559 /// The number of jobs that were drained and performed.
560 #[must_use]
561 pub const fn len(&self) -> usize {
562 self.outcomes.len()
563 }
564
565 /// Whether no jobs were performed (the queue was empty).
566 #[must_use]
567 pub const fn is_empty(&self) -> bool {
568 self.outcomes.is_empty()
569 }
570
571 /// The `(name, error)` pairs for every job whose handler returned `Err`
572 /// (or that had no registered handler).
573 #[must_use]
574 pub fn failures(&self) -> Vec<(&str, &crate::AutumnError)> {
575 self.outcomes
576 .iter()
577 .filter_map(|(name, result)| result.as_ref().err().map(|e| (name.as_str(), e)))
578 .collect()
579 }
580
581 /// Assert every performed job succeeded.
582 ///
583 /// # Panics
584 ///
585 /// Panics, listing each failing job's name and error, if any performed job
586 /// returned an error or had no registered handler.
587 pub fn assert_all_succeeded(&self) -> &Self {
588 let failures = self.failures();
589 assert!(
590 failures.is_empty(),
591 "expected all performed jobs to succeed, but {} failed:\n{}",
592 failures.len(),
593 failures
594 .iter()
595 .map(|(name, err)| format!(" - {name}: {err:?}"))
596 .collect::<Vec<_>>()
597 .join("\n")
598 );
599 self
600 }
601}
602
603/// Render a captured-job list for self-diagnosing assertion failures.
604fn format_recorded_jobs(jobs: &[RecordedJob]) -> String {
605 if jobs.is_empty() {
606 return " (no jobs were enqueued)".to_string();
607 }
608 jobs.iter()
609 .map(|j| format!(" - {} {}", j.name, j.payload))
610 .collect::<Vec<_>>()
611 .join("\n")
612}
613
614/// A single channel publication captured by the broadcast recorder.
615///
616/// Recorded by [`TestApp::record_broadcasts`] through the channels
617/// interceptor seam. Both raw `publish` text and `publish_html` HTML/OOB
618/// payloads are captured (they funnel through the same `ChannelMessage`).
619#[cfg(feature = "ws")]
620#[derive(Clone, Debug)]
621pub struct RecordedBroadcast {
622 /// The topic the message was published to.
623 pub topic: String,
624 /// The UTF-8 payload of the published `ChannelMessage`.
625 pub payload: String,
626}
627
628#[cfg(feature = "ws")]
629impl RecordedBroadcast {
630 /// The topic the message was published to.
631 #[must_use]
632 pub fn topic(&self) -> &str {
633 &self.topic
634 }
635
636 /// The UTF-8 payload of the published message.
637 #[must_use]
638 pub fn payload(&self) -> &str {
639 &self.payload
640 }
641}
642
643/// Built-in per-`TestClient` recording channels interceptor.
644///
645/// Opt-in via [`TestApp::record_broadcasts`] — no interceptor is installed
646/// unless the builder is called (zero-cost when unused). Records every
647/// publication in order, including publishes to zero subscribers.
648#[cfg(feature = "ws")]
649#[derive(Clone, Default)]
650struct BroadcastRecorder {
651 events: std::sync::Arc<std::sync::Mutex<Vec<RecordedBroadcast>>>,
652}
653
654#[cfg(feature = "ws")]
655impl BroadcastRecorder {
656 fn new() -> Self {
657 Self::default()
658 }
659
660 fn recorded(&self) -> Vec<RecordedBroadcast> {
661 self.events.lock().unwrap().clone()
662 }
663}
664
665#[cfg(feature = "ws")]
666impl crate::interceptor::ChannelsInterceptor for BroadcastRecorder {
667 fn intercept_publish(
668 &self,
669 topic: &str,
670 msg: &crate::channels::ChannelMessage,
671 next: &dyn Fn(
672 &str,
673 &crate::channels::ChannelMessage,
674 ) -> Result<usize, crate::channels::ChannelPublishError>,
675 ) -> Result<usize, crate::channels::ChannelPublishError> {
676 let result = next(topic, msg);
677 // Record the publication even when it reached zero subscribers — the
678 // publish still happened and tests assert on intent, not delivery.
679 self.events.lock().unwrap().push(RecordedBroadcast {
680 topic: topic.into(),
681 payload: msg.as_str().into(),
682 });
683 result
684 }
685}
686
687// ── TestApp ────────────────────────────────────────────────────
688
689/// Builder for constructing a fully-configured Autumn application in tests.
690///
691/// Analogous to Spring Boot's `@SpringBootTest` -- it wires up routes,
692/// middleware, config, and optionally a database pool, then produces a
693/// [`TestClient`] ready to fire requests.
694///
695/// # Examples
696///
697/// ```rust,no_run
698/// use autumn_web::prelude::*;
699/// use autumn_web::test::TestApp;
700///
701/// #[get("/ping")]
702/// async fn ping() -> &'static str { "pong" }
703///
704/// #[tokio::test]
705/// async fn ping_works() {
706/// let client = TestApp::new()
707/// .routes(routes![ping])
708/// .build();
709///
710/// client.get("/ping").send().await.assert_ok();
711/// }
712/// ```
713pub struct TestApp {
714 routes: Vec<Route>,
715 scoped_groups: Vec<crate::app::ScopedGroup>,
716 merge_routers: Vec<axum::Router<crate::state::AppState>>,
717 nest_routers: Vec<(String, axum::Router<crate::state::AppState>)>,
718 custom_layers: Vec<crate::app::CustomLayerRegistration>,
719 static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
720 config: AutumnConfig,
721 #[cfg(feature = "openapi")]
722 openapi: Option<crate::openapi::OpenApiConfig>,
723 #[cfg(feature = "mcp")]
724 mcp: Option<crate::mcp::McpRuntime>,
725 #[cfg(feature = "db")]
726 pool: Option<Pool<crate::db::RuntimeConnection>>,
727 #[cfg(feature = "db")]
728 replica_pool: Option<Pool<crate::db::RuntimeConnection>>,
729 #[cfg(feature = "db")]
730 transactional: bool,
731 #[cfg(feature = "db")]
732 transactional_url: Option<String>,
733 /// Deferred policy / scope registrations applied during
734 /// [`TestApp::build`].
735 policy_registrations: Vec<TestPolicyRegistration>,
736 /// Override for [`AppState::forbidden_response`]. Defaults to
737 /// the value derived from
738 /// [`SecurityConfig::forbidden_response`](crate::security::SecurityConfig::forbidden_response).
739 forbidden_response_override: Option<crate::authorization::ForbiddenResponse>,
740 #[cfg(feature = "mail")]
741 mail_interceptor: Option<std::sync::Arc<dyn crate::interceptor::MailInterceptor>>,
742 #[cfg(feature = "mail")]
743 mail_recorder: MailRecorder,
744 job_interceptor: Option<std::sync::Arc<dyn crate::interceptor::JobInterceptor>>,
745 /// Always-on job recorder capturing every enqueue. Composed ahead of any
746 /// user-supplied [`with_job_interceptor`](Self::with_job_interceptor).
747 job_recorder: JobRecorder,
748 #[cfg(feature = "db")]
749 db_interceptor: Option<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
750 #[cfg(feature = "ws")]
751 channels_interceptor: Option<std::sync::Arc<dyn crate::interceptor::ChannelsInterceptor>>,
752 /// Opt-in broadcast recorder, installed only when
753 /// [`record_broadcasts`](Self::record_broadcasts) is called.
754 #[cfg(feature = "ws")]
755 broadcast_recorder: Option<BroadcastRecorder>,
756 #[cfg(feature = "oauth2")]
757 http_interceptor: Option<std::sync::Arc<dyn crate::interceptor::HttpInterceptor>>,
758 /// Shared mock registry installed into `AppState` during [`build`](Self::build)
759 /// so that any [`Client`](crate::http_client::Client) extracted inside a
760 /// handler intercepts matching requests.
761 #[cfg(feature = "http-client")]
762 http_mock_registry: Option<std::sync::Arc<crate::http_client::MockRegistry>>,
763 state_initializers: Vec<Box<dyn FnOnce(&AppState) + Send>>,
764 jobs: Vec<crate::job::JobInfo>,
765 listeners: Vec<crate::events::ListenerInfo>,
766 exception_filters: Vec<std::sync::Arc<dyn crate::middleware::ExceptionFilter>>,
767 #[cfg(feature = "mail")]
768 suppression_store: Option<crate::mail::SuppressionStoreHandle>,
769 #[cfg(feature = "mail")]
770 mail_suppression_store: Option<crate::mail::suppression::SuppressionStoreHandle>,
771 registered_plugins: std::collections::HashSet<String>,
772 extensions: std::collections::HashMap<std::any::TypeId, Box<dyn std::any::Any + Send>>,
773 /// Injected clock; `None` means use [`crate::time::SystemClock`].
774 clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
775 /// Retained as `Arc<dyn Any>` so `TestClient::advance_clock` can downcast
776 /// to [`crate::time::TickingClock`] at runtime.
777 clock_as_any: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
778 api_versions: Vec<crate::app::ApiVersion>,
779 /// Plugin-contributed metrics sources registered via [`AppBuilder::metrics_source`].
780 metrics_sources: Vec<(String, std::sync::Arc<dyn crate::actuator::MetricsSource>)>,
781 /// Plugin-contributed health indicators registered via [`AppBuilder::health_indicator`].
782 health_indicators: Vec<(
783 String,
784 crate::actuator::IndicatorGroup,
785 std::sync::Arc<dyn crate::actuator::HealthIndicator>,
786 )>,
787 /// Inbound mail router registered via [`TestApp::inbound_mail_router`].
788 #[cfg(feature = "inbound-mail")]
789 inbound_mail_router: Option<std::sync::Arc<crate::inbound_mail::InboundMailRouter>>,
790}
791
792type TestPolicyRegistration = Box<dyn FnOnce(&crate::authorization::PolicyRegistry) + Send>;
793
794impl TestApp {
795 /// Create a new test app builder with default configuration.
796 #[must_use]
797 pub fn new() -> Self {
798 let mut config = AutumnConfig::default();
799 config.profile = Some("test".into());
800 // Disable CSRF for tests by default (like Spring Security's test support)
801 config.security.csrf.enabled = false;
802
803 Self {
804 routes: Vec::new(),
805 scoped_groups: Vec::new(),
806 merge_routers: Vec::new(),
807 nest_routers: Vec::new(),
808 custom_layers: Vec::new(),
809 static_gate_layers: Vec::new(),
810 config,
811 #[cfg(feature = "openapi")]
812 openapi: None,
813 #[cfg(feature = "mcp")]
814 mcp: None,
815 #[cfg(feature = "db")]
816 pool: None,
817 #[cfg(feature = "db")]
818 replica_pool: None,
819 #[cfg(feature = "db")]
820 transactional: false,
821 #[cfg(feature = "db")]
822 transactional_url: None,
823 policy_registrations: Vec::new(),
824 forbidden_response_override: None,
825 #[cfg(feature = "mail")]
826 mail_interceptor: None,
827 #[cfg(feature = "mail")]
828 mail_recorder: MailRecorder::new(),
829 job_interceptor: None,
830 job_recorder: JobRecorder::new(),
831 #[cfg(feature = "db")]
832 db_interceptor: None,
833 #[cfg(feature = "ws")]
834 channels_interceptor: None,
835 #[cfg(feature = "ws")]
836 broadcast_recorder: None,
837 #[cfg(feature = "oauth2")]
838 http_interceptor: None,
839 #[cfg(feature = "http-client")]
840 http_mock_registry: None,
841 state_initializers: Vec::new(),
842 jobs: Vec::new(),
843 listeners: Vec::new(),
844 exception_filters: Vec::new(),
845 #[cfg(feature = "mail")]
846 suppression_store: None,
847 #[cfg(feature = "mail")]
848 mail_suppression_store: None,
849 registered_plugins: std::collections::HashSet::new(),
850 extensions: std::collections::HashMap::new(),
851 clock: None,
852 clock_as_any: None,
853 api_versions: Vec::new(),
854 metrics_sources: Vec::new(),
855 health_indicators: Vec::new(),
856 #[cfg(feature = "inbound-mail")]
857 inbound_mail_router: None,
858 }
859 }
860
861 /// Register a [`Policy`](crate::authorization::Policy) for
862 /// resource type `R`. Mirrors
863 /// [`AppBuilder::policy`](crate::app::AppBuilder::policy).
864 #[must_use]
865 pub fn policy<R, P>(mut self, policy: P) -> Self
866 where
867 R: Send + Sync + 'static,
868 P: crate::authorization::Policy<R>,
869 {
870 self.policy_registrations.push(Box::new(move |registry| {
871 registry.register_policy::<R, _>(policy);
872 }));
873 self
874 }
875
876 /// Register a [`Scope`](crate::authorization::Scope) for resource
877 /// type `R`. Mirrors
878 /// [`AppBuilder::scope`](crate::app::AppBuilder::scope).
879 #[must_use]
880 pub fn scope<R, S>(mut self, scope: S) -> Self
881 where
882 R: Send + Sync + 'static,
883 S: crate::authorization::Scope<R>,
884 {
885 self.policy_registrations.push(Box::new(move |registry| {
886 registry.register_scope::<R, _>(scope);
887 }));
888 self
889 }
890
891 /// Register an inbound mail router for this test app.
892 ///
893 /// Mirrors [`crate::app::AppBuilder::inbound_mail_router`].
894 #[cfg(feature = "inbound-mail")]
895 #[must_use]
896 pub fn inbound_mail_router(mut self, router: crate::inbound_mail::InboundMailRouter) -> Self {
897 self.inbound_mail_router = Some(std::sync::Arc::new(router));
898 self
899 }
900
901 /// Override the deny-response shape used by `#[authorize]` and
902 /// `#[repository(policy = ...)]` handlers. Useful for
903 /// round-tripping the `403`-vs-`404` decision in tests.
904 #[must_use]
905 pub const fn forbidden_response(
906 mut self,
907 value: crate::authorization::ForbiddenResponse,
908 ) -> Self {
909 self.forbidden_response_override = Some(value);
910 self
911 }
912
913 /// Enable `OpenAPI` spec generation for the test app.
914 ///
915 /// Mirrors [`crate::app::AppBuilder::openapi`] so integration tests
916 /// can exercise the `/v3/api-docs` and `/swagger-ui` endpoints.
917 ///
918 /// Gated behind the `openapi` Cargo feature.
919 #[cfg(feature = "openapi")]
920 #[must_use]
921 pub fn openapi(mut self, config: crate::openapi::OpenApiConfig) -> Self {
922 self.openapi = Some(config);
923 self
924 }
925
926 /// Mount an MCP endpoint at `path`, mirroring
927 /// [`AppBuilder::mount_mcp`](crate::app::AppBuilder::mount_mcp) so
928 /// integration tests can drive `initialize`/`tools/list`/`tools/call`
929 /// through the in-process pipeline.
930 ///
931 /// Gated behind the `mcp` Cargo feature.
932 #[cfg(feature = "mcp")]
933 #[must_use]
934 pub fn mount_mcp(mut self, path: impl Into<String>) -> Self {
935 let path = path.into();
936 if let Some(rt) = self.mcp.as_mut() {
937 rt.mount_path = path;
938 } else {
939 self.mcp = Some(crate::mcp::McpRuntime::new(path));
940 }
941 self
942 }
943
944 /// Enable the whole-API MCP hatch, mirroring
945 /// [`AppBuilder::expose_all_as_mcp`](crate::app::AppBuilder::expose_all_as_mcp).
946 ///
947 /// Gated behind the `mcp` Cargo feature.
948 #[cfg(feature = "mcp")]
949 #[must_use]
950 pub fn expose_all_as_mcp(mut self) -> Self {
951 if let Some(rt) = self.mcp.as_mut() {
952 rt.expose_all = true;
953 } else {
954 let mut rt = crate::mcp::McpRuntime::new("/mcp");
955 rt.expose_all = true;
956 self.mcp = Some(rt);
957 }
958 self
959 }
960
961 /// Gate the entire MCP endpoint behind a tower `layer`, mirroring
962 /// [`AppBuilder::secure_mcp`](crate::app::AppBuilder::secure_mcp).
963 ///
964 /// Gated behind the `mcp` Cargo feature.
965 #[cfg(feature = "mcp")]
966 #[must_use]
967 pub fn secure_mcp<L>(mut self, layer: L) -> Self
968 where
969 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
970 L::Service: tower::Service<
971 axum::http::Request<axum::body::Body>,
972 Response = axum::http::Response<axum::body::Body>,
973 Error = std::convert::Infallible,
974 > + Clone
975 + Send
976 + Sync
977 + 'static,
978 <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
979 Send + 'static,
980 {
981 let applier: crate::mcp::McpEndpointLayer = Box::new(move |router| router.layer(layer));
982 if let Some(rt) = self.mcp.as_mut() {
983 rt.endpoint_layer = Some(applier);
984 } else {
985 let mut rt = crate::mcp::McpRuntime::new("/mcp");
986 rt.endpoint_layer = Some(applier);
987 self.mcp = Some(rt);
988 }
989 self
990 }
991
992 /// Merge a router into the internal application state.
993 ///
994 /// This is useful when testing modular route definitions without building
995 /// the full application.
996 #[must_use]
997 pub fn merge(mut self, router: axum::Router<crate::state::AppState>) -> Self {
998 self.merge_routers.push(router);
999 self
1000 }
1001
1002 /// Mount routes under a scoped prefix with a route-local layer.
1003 #[must_use]
1004 pub fn scoped<L>(mut self, prefix: &str, layer: L, routes: Vec<Route>) -> Self
1005 where
1006 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
1007 L::Service: tower::Service<
1008 axum::http::Request<axum::body::Body>,
1009 Response = axum::http::Response<axum::body::Body>,
1010 Error = std::convert::Infallible,
1011 > + Clone
1012 + Send
1013 + Sync
1014 + 'static,
1015 <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
1016 Send + 'static,
1017 {
1018 self.scoped_groups.push(crate::app::ScopedGroup {
1019 prefix: prefix.to_owned(),
1020 routes,
1021 source: crate::route_listing::RouteSource::User,
1022 apply_layer: Box::new(move |router| router.layer(layer)),
1023 });
1024 self
1025 }
1026
1027 /// Nest a router under a specific path prefix for testing.
1028 ///
1029 /// This is useful for testing sub-applications or API versions.
1030 #[must_use]
1031 pub fn nest(mut self, path: &str, router: axum::Router<crate::state::AppState>) -> Self {
1032 self.nest_routers.push((path.to_owned(), router));
1033 self
1034 }
1035
1036 /// Apply a custom [`tower::Layer`] to the entire test application.
1037 ///
1038 /// Mirrors [`crate::app::AppBuilder::layer`] so tests can exercise the
1039 /// exact middleware wiring that `AppBuilder::run()` produces.
1040 #[must_use]
1041 pub fn layer<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
1042 self.custom_layers
1043 .push(crate::app::CustomLayerRegistration {
1044 type_id: std::any::TypeId::of::<L>(),
1045 type_name: std::any::type_name::<L>(),
1046 apply: Box::new(move |router| layer.apply_to(router)),
1047 });
1048 self
1049 }
1050
1051 /// Register a pre-static gate layer for this test application.
1052 ///
1053 /// Mirrors [`crate::app::AppBuilder::static_gate`]: the layer runs
1054 /// outermost (outside session and before the static cache lookup) so tests
1055 /// can exercise auth-gating wiring that protects cached SSG/ISG pages.
1056 #[must_use]
1057 pub fn static_gate<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
1058 self.static_gate_layers
1059 .push(crate::app::CustomLayerRegistration {
1060 type_id: std::any::TypeId::of::<L>(),
1061 type_name: std::any::type_name::<L>(),
1062 apply: Box::new(move |router| layer.apply_to(router)),
1063 });
1064 self
1065 }
1066
1067 /// Register an [`ErrorReporter`](crate::reporting::ErrorReporter) for this
1068 /// test app.
1069 ///
1070 /// Mirrors [`crate::app::AppBuilder::with_error_reporter`]. Call multiple
1071 /// times to chain reporters; each receives every panic + 5xx event.
1072 #[cfg(feature = "reporting")]
1073 #[must_use]
1074 pub fn with_error_reporter<R: crate::reporting::ErrorReporter>(mut self, reporter: R) -> Self {
1075 let reporter =
1076 std::sync::Arc::new(reporter) as std::sync::Arc<dyn crate::reporting::ErrorReporter>;
1077 self.state_initializers.push(Box::new(move |state| {
1078 let mut reporters = state
1079 .extension::<crate::reporting::RegisteredReporters>()
1080 .map(|registered| registered.0.clone())
1081 .unwrap_or_default();
1082 reporters.push(reporter.clone());
1083 state.insert_extension(crate::reporting::RegisteredReporters(reporters));
1084 }));
1085 self
1086 }
1087
1088 /// Enable HTTP idempotency-key middleware for this test app.
1089 ///
1090 /// Mirrors [`crate::app::AppBuilder::idempotent`]: sets the
1091 /// `config.idempotency.enabled` flag so that the router wires up the layer
1092 /// with the same `MemoryIdempotencyStore` and `MetricsCollector` that
1093 /// production uses.
1094 #[must_use]
1095 pub const fn idempotent(mut self) -> Self {
1096 self.config.idempotency.enabled = Some(true);
1097 self
1098 }
1099
1100 /// Construct a [`TestClient`] directly from an `axum::Router`.
1101 ///
1102 /// Useful for bypassing `TestApp` builder if you just want to write requests
1103 /// against a standard axum Router. The probe state returned by
1104 /// [`TestClient::probes`] will be in the default ready state; it is not
1105 /// connected to any handler in the supplied router.
1106 ///
1107 /// **Note:** [`TestClient::sent_mail`] will always return an empty list for
1108 /// clients built this way. The built-in mail recorder is wired in during
1109 /// [`TestApp::build`]; because `from_router` receives an already-constructed
1110 /// `AppState` (with the mailer already installed), the recorder cannot be
1111 /// injected into its interceptor chain. Use [`TestApp::new().merge(router).build()`](TestApp::merge)
1112 /// to get recording support.
1113 #[must_use]
1114 pub fn from_router(router: axum::Router, state: AppState) -> TestClient {
1115 let auth_session_key = state.auth_session_key().to_owned();
1116 // Resolve the session cookie name from the router's config (installed in
1117 // state extensions by `build()`), falling back to the framework default
1118 // when it isn't present — so `log_out` clears the right cookie even when
1119 // the app configured a custom `session.cookie_name`.
1120 let session_cookie_name = state.extension::<AutumnConfig>().map_or_else(
1121 || crate::session::SessionConfig::default().cookie_name,
1122 |cfg| cfg.session.cookie_name.clone(),
1123 );
1124 TestClient {
1125 router,
1126 probes: crate::probe::ProbeState::ready_for_test(),
1127 state,
1128 _job_runtime: None,
1129 clock_as_any: None,
1130 #[cfg(feature = "mail")]
1131 mail_recorder: None,
1132 #[cfg(feature = "ws")]
1133 broadcast_recorder: None,
1134 job_recorder: None,
1135 jobs: Vec::new(),
1136 cookie_jar: std::sync::Arc::new(
1137 std::sync::Mutex::new(std::collections::HashMap::new()),
1138 ),
1139 // `from_router` receives an already-built router, so we have no
1140 // handle to whatever session store (if any) it installed. The jar
1141 // still works — cookies from real requests round-trip — but
1142 // `acting_as` cannot mint a session and panics, mirroring how
1143 // `sent_mail()` degrades for `from_router` clients.
1144 session_store: None,
1145 session_cookie_name,
1146 auth_session_key,
1147 session_signing_keys: None,
1148 }
1149 }
1150
1151 /// Register a collection of routes to be built into the `TestApp`.
1152 #[must_use]
1153 pub fn routes(mut self, routes: Vec<Route>) -> Self {
1154 self.routes.extend(routes);
1155 self
1156 }
1157
1158 /// Register a callback to configure/initialize the application state before building the router.
1159 #[must_use]
1160 pub fn state_initializer<F>(mut self, f: F) -> Self
1161 where
1162 F: FnOnce(&AppState) + Send + 'static,
1163 {
1164 self.state_initializers.push(Box::new(f));
1165 self
1166 }
1167
1168 /// Register a [`FlagStore`](crate::feature_flags::FlagStore) backend so
1169 /// the [`Flags`](crate::feature_flags::Flags) extractor works in test handlers.
1170 ///
1171 /// Mirrors [`crate::app::AppBuilder::with_flag_store`].
1172 #[must_use]
1173 pub fn with_flag_store<S>(mut self, store: S) -> Self
1174 where
1175 S: crate::feature_flags::FlagStore,
1176 {
1177 use std::sync::Arc;
1178 let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>);
1179 self.state_initializers.push(Box::new(move |state| {
1180 state.insert_extension(service);
1181 }));
1182 self
1183 }
1184
1185 /// Apply a plugin directly to the test app.
1186 #[must_use]
1187 pub fn plugin<P: crate::plugin::Plugin>(mut self, plugin: P) -> Self {
1188 let name = plugin.name().into_owned();
1189 if self.registered_plugins.contains(&name) {
1190 tracing::warn!(plugin = %name, "Duplicate plugin registration in TestApp; skipping");
1191 return self;
1192 }
1193
1194 let mut app_builder = crate::app();
1195 app_builder
1196 .registered_plugins
1197 .clone_from(&self.registered_plugins);
1198 app_builder.extensions = self.extensions;
1199 app_builder.state_initializers = std::mem::take(&mut self.state_initializers);
1200
1201 app_builder = app_builder.plugin(plugin);
1202
1203 self.registered_plugins = app_builder.registered_plugins;
1204 self.extensions = app_builder.extensions;
1205 self.state_initializers = app_builder.state_initializers;
1206
1207 // Merge properties from the plugin's app_builder into self:
1208 self.routes.extend(app_builder.routes);
1209 self.scoped_groups.extend(app_builder.scoped_groups);
1210 self.merge_routers.extend(app_builder.merge_routers);
1211 self.nest_routers.extend(app_builder.nest_routers);
1212 self.custom_layers.extend(app_builder.custom_layers);
1213 self.static_gate_layers
1214 .extend(app_builder.static_gate_layers);
1215 self.jobs.extend(app_builder.jobs);
1216 self.listeners.extend(app_builder.listeners);
1217 self.exception_filters.extend(app_builder.exception_filters);
1218 self.metrics_sources.extend(app_builder.metrics_sources);
1219 self.health_indicators.extend(app_builder.health_indicators);
1220 // Carry plugin-registered inbound mail router into the test app so
1221 // webhook plugins behave identically under TestApp.
1222 #[cfg(feature = "inbound-mail")]
1223 if let Some(router) = app_builder.inbound_mail_router {
1224 self.inbound_mail_router = Some(router);
1225 }
1226
1227 // Carry a plugin-registered suppression store (List-Unsubscribe storage)
1228 // into the test app so unsubscribe POSTs and send-time suppression behave
1229 // under TestApp exactly as they do under AppBuilder::run.
1230 #[cfg(feature = "mail")]
1231 if let Some(handle) = app_builder.suppression_store {
1232 self.suppression_store = Some(handle);
1233 }
1234
1235 // Carry a plugin-registered bounce/complaint suppression store (issue
1236 // #1247) into the test app so send-time suppression is consulted under
1237 // TestApp exactly as under AppBuilder::run — otherwise a plugin/app that
1238 // wired a PgSuppressionStore would silently test against the in-memory
1239 // default and hide production failures (e.g. a missing table).
1240 #[cfg(feature = "mail")]
1241 if let Some(handle) = app_builder.mail_suppression_store {
1242 self.mail_suppression_store = Some(handle);
1243 }
1244
1245 // Carry a plugin's `mount_unsubscribe_endpoint()` opt-in: production copies
1246 // this builder flag into config.mail before router assembly, so a plugin
1247 // that mounts the default unsubscribe endpoint must mount it under TestApp
1248 // too (otherwise /_autumn/unsubscribe 404s in tests but works in prod).
1249 #[cfg(feature = "mail")]
1250 if app_builder.mount_unsubscribe_endpoint {
1251 self.config.mail.mount_unsubscribe_endpoint = true;
1252 }
1253
1254 // Carry plugin-registered error reporters into the test app so
1255 // reporting-enabled plugins exercise the same behavior under `TestApp`
1256 // that they get from `AppBuilder::run`.
1257 #[cfg(feature = "reporting")]
1258 {
1259 let reporters = std::mem::take(&mut app_builder.error_reporters);
1260 if !reporters.is_empty() {
1261 self.state_initializers.push(Box::new(move |state| {
1262 let mut existing = state
1263 .extension::<crate::reporting::RegisteredReporters>()
1264 .map(|registered| registered.0.clone())
1265 .unwrap_or_default();
1266 existing.extend(reporters.iter().cloned());
1267 state.insert_extension(crate::reporting::RegisteredReporters(existing));
1268 }));
1269 }
1270 }
1271
1272 for hook in app_builder.startup_hooks {
1273 self.state_initializers.push(Box::new(move |state| {
1274 let state_owned = state.clone();
1275 if let Ok(handle) = tokio::runtime::Handle::try_current() {
1276 let thread_handle =
1277 std::thread::spawn(move || handle.block_on(hook(state_owned)));
1278 thread_handle
1279 .join()
1280 .expect("Plugin startup hook thread panicked")
1281 .expect("Plugin startup hook failed");
1282 } else {
1283 let thread_handle = std::thread::spawn(move || {
1284 let rt = tokio::runtime::Builder::new_multi_thread()
1285 .enable_all()
1286 .build()
1287 .expect("failed to build tokio runtime for test plugin startup hook");
1288 rt.block_on(hook(state_owned))
1289 });
1290 thread_handle
1291 .join()
1292 .expect("Plugin startup hook thread panicked")
1293 .expect("Plugin startup hook failed");
1294 }
1295 }));
1296 }
1297 self
1298 }
1299
1300 #[cfg(feature = "mail")]
1301 #[must_use]
1302 pub fn with_mail_interceptor(
1303 mut self,
1304 interceptor: impl crate::interceptor::MailInterceptor,
1305 ) -> Self {
1306 self.mail_interceptor = Some(std::sync::Arc::new(interceptor));
1307 self
1308 }
1309
1310 /// Register a [`SuppressionStore`](crate::mail::SuppressionStore) so
1311 /// List-Unsubscribe sends skip suppressed recipients and the unsubscribe
1312 /// endpoint records opt-outs. Mirrors
1313 /// [`AppBuilder::with_suppression_store`](crate::app::AppBuilder::with_suppression_store).
1314 #[cfg(feature = "mail")]
1315 #[must_use]
1316 pub fn with_suppression_store(
1317 mut self,
1318 store: impl crate::mail::SuppressionStore + 'static,
1319 ) -> Self {
1320 self.suppression_store = Some(crate::mail::SuppressionStoreHandle::new(store));
1321 self
1322 }
1323
1324 /// Register a bounce/complaint
1325 /// [`SuppressionStore`](crate::mail::suppression::SuppressionStore) so
1326 /// [`Mailer::send`](crate::mail::Mailer::send) skips hard-bounced/complained
1327 /// addresses under `TestApp` exactly as it does under `AppBuilder::run`.
1328 /// Mirrors
1329 /// [`AppBuilder::with_mail_suppression_store`](crate::app::AppBuilder::with_mail_suppression_store).
1330 #[cfg(feature = "mail")]
1331 #[must_use]
1332 pub fn with_mail_suppression_store(
1333 mut self,
1334 store: impl crate::mail::suppression::SuppressionStore + 'static,
1335 ) -> Self {
1336 self.mail_suppression_store =
1337 Some(crate::mail::suppression::SuppressionStoreHandle::new(store));
1338 self
1339 }
1340
1341 /// Mount the framework's default one-click unsubscribe endpoint (opt-in).
1342 /// Mirrors
1343 /// [`AppBuilder::mount_unsubscribe_endpoint`](crate::app::AppBuilder::mount_unsubscribe_endpoint).
1344 #[cfg(feature = "mail")]
1345 #[must_use]
1346 pub const fn mount_unsubscribe_endpoint(mut self) -> Self {
1347 self.config.mail.mount_unsubscribe_endpoint = true;
1348 self
1349 }
1350
1351 #[must_use]
1352 pub fn with_job_interceptor(
1353 mut self,
1354 interceptor: impl crate::interceptor::JobInterceptor,
1355 ) -> Self {
1356 self.job_interceptor = Some(std::sync::Arc::new(interceptor));
1357 self
1358 }
1359
1360 /// Register event listeners with the test app.
1361 ///
1362 /// Collect them with `listeners![..]`, exactly as in `AppBuilder::listeners`.
1363 /// Durable listeners run under the in-process test job runtime; sync
1364 /// listeners run in-request. Published events are always recorded, so
1365 /// [`TestClient::assert_event_published`] works without standing up jobs.
1366 #[must_use]
1367 pub fn listeners(mut self, listeners: Vec<crate::events::ListenerInfo>) -> Self {
1368 self.listeners.extend(listeners);
1369 self
1370 }
1371
1372 #[cfg(feature = "db")]
1373 #[must_use]
1374 pub fn with_db_interceptor(
1375 mut self,
1376 interceptor: impl crate::interceptor::DbConnectionInterceptor,
1377 ) -> Self {
1378 self.db_interceptor = Some(std::sync::Arc::new(interceptor));
1379 self
1380 }
1381
1382 #[cfg(feature = "ws")]
1383 #[must_use]
1384 pub fn with_channels_interceptor(
1385 mut self,
1386 interceptor: impl crate::interceptor::ChannelsInterceptor,
1387 ) -> Self {
1388 self.channels_interceptor = Some(std::sync::Arc::new(interceptor));
1389 self
1390 }
1391
1392 /// Opt in to recording every channel broadcast published while requests
1393 /// run, enabling [`TestClient::broadcasts`],
1394 /// [`TestClient::broadcasts_on`], and the `assert_broadcast*` helpers.
1395 ///
1396 /// No interceptor is installed — and channel publishing is untouched —
1397 /// unless this is called. Composes with a user-supplied
1398 /// [`with_channels_interceptor`](Self::with_channels_interceptor): the
1399 /// recorder runs first, then the user's interceptor.
1400 #[cfg(feature = "ws")]
1401 #[must_use]
1402 pub fn record_broadcasts(mut self) -> Self {
1403 self.broadcast_recorder = Some(BroadcastRecorder::new());
1404 self
1405 }
1406
1407 #[cfg(feature = "oauth2")]
1408 #[must_use]
1409 pub fn with_http_interceptor(
1410 mut self,
1411 interceptor: impl crate::interceptor::HttpInterceptor,
1412 ) -> Self {
1413 self.http_interceptor = Some(std::sync::Arc::new(interceptor));
1414 self
1415 }
1416
1417 /// Override the default test configuration.
1418 #[must_use]
1419 pub fn config(mut self, config: AutumnConfig) -> Self {
1420 self.config = config;
1421 self
1422 }
1423
1424 /// Set the active profile (default is `"test"`).
1425 #[must_use]
1426 pub fn profile(mut self, profile: &str) -> Self {
1427 self.config.profile = Some(profile.to_owned());
1428 self
1429 }
1430
1431 /// Inject a custom clock into the test app.
1432 ///
1433 /// All handlers that take a [`crate::time::Clock`] extractor will see time
1434 /// as reported by `clock`. Use [`crate::time::FixedClock`] to pin time to
1435 /// a known instant, or [`crate::time::TickingClock`] when you need to step
1436 /// the clock forward between requests via
1437 /// [`TestClient::advance_clock`].
1438 ///
1439 /// ```rust,no_run
1440 /// use autumn_web::test::TestApp;
1441 /// use autumn_web::time::{FixedClock, TickingClock};
1442 /// use chrono::{TimeZone, Utc};
1443 ///
1444 /// // Pin to a fixed instant:
1445 /// let _client = TestApp::new()
1446 /// .with_clock(FixedClock::at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()))
1447 /// .build();
1448 ///
1449 /// // Step forward in time:
1450 /// let clock = TickingClock::starting_at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap());
1451 /// let client = TestApp::new()
1452 /// .with_clock(clock.clone())
1453 /// .build();
1454 /// client.advance_clock(std::time::Duration::from_secs(3600));
1455 /// ```
1456 #[must_use]
1457 pub fn with_clock<C>(mut self, clock: C) -> Self
1458 where
1459 C: crate::time::ClockSource + 'static,
1460 {
1461 let arc: std::sync::Arc<C> = std::sync::Arc::new(clock);
1462 // Retain as dyn Any so TestClient::advance_clock can downcast to TickingClock.
1463 self.clock_as_any = Some(arc.clone() as std::sync::Arc<dyn std::any::Any + Send + Sync>);
1464 self.clock = Some(arc as std::sync::Arc<dyn crate::time::ClockSource>);
1465 self
1466 }
1467
1468 /// Register a single API version for testing.
1469 #[must_use]
1470 pub fn api_version(mut self, version: crate::app::ApiVersion) -> Self {
1471 self.api_versions.push(version);
1472 self
1473 }
1474
1475 /// Register multiple API versions for testing.
1476 #[must_use]
1477 pub fn api_versions(
1478 mut self,
1479 versions: impl IntoIterator<Item = crate::app::ApiVersion>,
1480 ) -> Self {
1481 self.api_versions.extend(versions);
1482 self
1483 }
1484
1485 /// Attach a database connection pool to the test app.
1486 #[cfg(feature = "db")]
1487 #[must_use]
1488 pub fn with_db(mut self, pool: Pool<crate::db::RuntimeConnection>) -> Self {
1489 self.pool = Some(pool);
1490 self
1491 }
1492
1493 /// Enable transactional test isolation using the database URL configured
1494 /// in the application's configuration.
1495 #[cfg(feature = "db")]
1496 #[must_use]
1497 pub const fn transactional(mut self) -> Self {
1498 self.transactional = true;
1499 self
1500 }
1501
1502 /// Enable transactional test isolation with an explicit database URL.
1503 #[cfg(feature = "db")]
1504 #[must_use]
1505 pub fn with_transactional_db(mut self, url: impl Into<String>) -> Self {
1506 self.transactional = true;
1507 self.transactional_url = Some(url.into());
1508 self
1509 }
1510
1511 /// Configure the application's horizontal shards programmatically, as if
1512 /// they were declared via `[[database.shards]]` in `autumn.toml`.
1513 ///
1514 /// This is the escape hatch for tests that spin up shard databases at
1515 /// runtime (e.g. one Postgres container per shard) and need to point the
1516 /// app at them without writing a config file. Combine with
1517 /// [`transactional`](Self::transactional) to get rolled-back shard writes.
1518 ///
1519 /// ```rust,no_run
1520 /// use autumn_web::test::TestApp;
1521 /// use autumn_web::config::ShardConfig;
1522 ///
1523 /// # fn example(shard0: String, shard1: String) {
1524 /// let client = TestApp::new()
1525 /// .with_transactional_db("postgres://localhost/control")
1526 /// .with_shards(vec![
1527 /// ShardConfig { name: "shard0".into(), primary_url: shard0, ..Default::default() },
1528 /// ShardConfig { name: "shard1".into(), primary_url: shard1, ..Default::default() },
1529 /// ])
1530 /// .build();
1531 /// # let _ = client;
1532 /// # }
1533 /// ```
1534 #[cfg(feature = "db")]
1535 #[must_use]
1536 pub fn with_shards(mut self, shards: Vec<crate::config::ShardConfig>) -> Self {
1537 self.config.database.shards = shards;
1538 self
1539 }
1540
1541 /// Register a canned HTTP response for outbound requests made via the
1542 /// [`Client`](crate::http_client::Client) extractor during this test.
1543 ///
1544 /// `alias` identifies the named service (must match the alias passed to
1545 /// [`Client::named`](crate::http_client::Client::named) in the handler, or
1546 /// the key used in `[http.client.base_urls]`).
1547 ///
1548 /// Returns a [`MockSetupBuilder`](crate::http_client::MockSetupBuilder) on
1549 /// which you chain the HTTP method and path before calling
1550 /// [`respond_with`](crate::http_client::MockSetupBuilder::respond_with) to
1551 /// register the entry and get a
1552 /// [`MockHandle`](crate::http_client::MockHandle) for later assertions.
1553 ///
1554 /// # Examples
1555 ///
1556 /// ```rust,no_run
1557 /// use autumn_web::test::TestApp;
1558 /// use serde_json::json;
1559 ///
1560 /// # async fn example() {
1561 /// let mut app = TestApp::new();
1562 /// let mock = app
1563 /// .http_mock("stripe")
1564 /// .post("/v1/charges")
1565 /// .respond_with(200, json!({"id": "ch_123", "amount": 1000}));
1566 ///
1567 /// let client = app.build();
1568 /// // … fire requests …
1569 /// mock.expect_called(1);
1570 /// # }
1571 /// ```
1572 #[cfg(feature = "http-client")]
1573 pub fn http_mock(&mut self, alias: &str) -> crate::http_client::MockSetupBuilder {
1574 let registry = self
1575 .http_mock_registry
1576 .get_or_insert_with(|| std::sync::Arc::new(crate::http_client::MockRegistry::new()))
1577 .clone();
1578
1579 crate::http_client::MockSetupBuilder {
1580 registry,
1581 alias: alias.to_owned(),
1582 method: None,
1583 path: None,
1584 }
1585 }
1586
1587 /// Build the application and return a [`TestClient`] ready for requests.
1588 ///
1589 /// This constructs the full Axum router with all middleware applied,
1590 /// identical to what `AppBuilder::run()` produces -- without binding
1591 /// a TCP listener.
1592 ///
1593 /// The process-level global cache is cleared unconditionally so that
1594 /// `#[cached]` functions inside this test app always use their
1595 /// per-function Moka stores and do not accidentally inherit a Redis or
1596 /// other shared backend installed by a previous test.
1597 #[must_use]
1598 #[cfg_attr(not(feature = "inbound-mail"), allow(unused_mut))]
1599 pub fn build(mut self) -> TestClient {
1600 // Reset the global cache to prevent cross-test contamination.
1601 crate::cache::clear_global_cache();
1602 // Reset the global event bus so a prior test's listeners/recorder do not
1603 // leak into this one (it is re-installed below).
1604 crate::events::clear_global_event_bus();
1605
1606 // Postgres transactional test isolation (`begin_test_transaction` +
1607 // SAVEPOINT rollback on a `max_size(1)` control pool) is Postgres-only;
1608 // SQLite has no equivalent, so under the `sqlite` feature the harness
1609 // uses the configured pool directly (no per-test rollback isolation).
1610 #[cfg(all(feature = "db", feature = "sqlite"))]
1611 let (pool, replica_pool, db_interceptor) = {
1612 let _ = self.transactional;
1613 // SQLite has no equivalent of the Postgres transactional-rollback
1614 // isolation (`begin_test_transaction` + SAVEPOINT on a `max_size(1)`
1615 // control pool), so a SQLite test DB gets a real pool but NOT
1616 // per-test transactional isolation. Even so, `with_transactional_db`
1617 // records an explicit SQLite database URL, and dropping it here would
1618 // leave a `TestApp` built that way with no pool at all -- every route
1619 // using the `Db` extractor would then return 503. So when no pool was
1620 // attached via `with_db` but an explicit URL was given, build a plain
1621 // (non-transactional) SQLite pool from it, reusing the runtime
1622 // `create_pool` path so the pool matches production behavior.
1623 let pool = if let Some(pool) = self.pool {
1624 Some(pool)
1625 } else if let Some(url) = self.transactional_url.as_deref() {
1626 let mut db_config = self.config.database.clone();
1627 db_config.primary_url = Some(url.to_owned());
1628 Some(
1629 crate::db::create_pool(&db_config)
1630 .expect("failed to build SQLite test pool from with_transactional_db URL")
1631 .expect(
1632 "with_transactional_db URL did not yield a SQLite pool (empty URL?)",
1633 ),
1634 )
1635 } else {
1636 None
1637 };
1638 (pool, self.replica_pool, self.db_interceptor)
1639 };
1640 #[cfg(all(feature = "db", not(feature = "sqlite")))]
1641 let (pool, replica_pool, db_interceptor) = if self.transactional {
1642 let url = self.transactional_url.as_deref()
1643 .or_else(|| self.config.database.effective_primary_url())
1644 .expect("Transactional isolation enabled but database URL is not configured. Use `with_transactional_db(url)` or configure database.primary_url/database.url");
1645
1646 let connect_timeout_secs = self.config.database.connect_timeout_secs;
1647 let timeout = std::time::Duration::from_secs(connect_timeout_secs);
1648
1649 let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::<
1650 diesel_async::AsyncPgConnection,
1651 >::new(url);
1652 let pool = Pool::builder(manager)
1653 .max_size(1)
1654 .wait_timeout(Some(timeout))
1655 .create_timeout(Some(timeout))
1656 .runtime(deadpool::Runtime::Tokio1)
1657 .post_create(deadpool::managed::Hook::async_fn(
1658 |conn: &mut diesel_async::AsyncPgConnection, _metrics| {
1659 Box::pin(async move {
1660 use diesel_async::AsyncConnection;
1661 use diesel_async::RunQueryDsl;
1662
1663 conn.begin_test_transaction().await.map_err(|e| {
1664 deadpool::managed::HookError::Backend(
1665 diesel_async::pooled_connection::PoolError::QueryError(e),
1666 )
1667 })?;
1668
1669 diesel::sql_query("SET autumn.test_transaction_started = 'true'")
1670 .execute(conn)
1671 .await
1672 .map_err(|e| {
1673 deadpool::managed::HookError::Backend(
1674 diesel_async::pooled_connection::PoolError::QueryError(e),
1675 )
1676 })?;
1677
1678 Ok(())
1679 })
1680 },
1681 ))
1682 .build()
1683 .expect("failed to build transactional pool of size 1");
1684
1685 let trans_interceptor = std::sync::Arc::new(TransactionalDbInterceptor);
1686 let interceptor = if let Some(user_interceptor) = self.db_interceptor {
1687 std::sync::Arc::new(ComposedDbInterceptor {
1688 first: user_interceptor,
1689 second: trans_interceptor,
1690 })
1691 as std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>
1692 } else {
1693 trans_interceptor as std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>
1694 };
1695
1696 (Some(pool), None, Some(interceptor))
1697 } else {
1698 (self.pool, self.replica_pool, self.db_interceptor)
1699 };
1700
1701 // Mirror production router selection (see `setup_database`): when the
1702 // test config enables directory routing, build a `DirectoryShardRouter`
1703 // over the control pool so tests that pin tenants in
1704 // `_autumn_shard_directory` route the same way production would.
1705 #[cfg(feature = "db")]
1706 let shard_router: std::sync::Arc<dyn crate::sharding::ShardRouter> =
1707 match (self.config.database.directory_shard_router, &pool) {
1708 (true, Some(control_pool)) => {
1709 let timeout_ms = self.config.database.statement_timeout.map_or(0, |d| {
1710 u64::try_from(d.as_millis())
1711 .unwrap_or(i32::MAX as u64)
1712 .min(i32::MAX as u64)
1713 });
1714 std::sync::Arc::new(
1715 crate::sharding::DirectoryShardRouter::new(control_pool.clone())
1716 .with_statement_timeout_ms(timeout_ms),
1717 )
1718 }
1719 // Production `setup_database` errors here (the directory router
1720 // needs a control DB), so fail the test app the same way rather
1721 // than silently routing by hash and passing a test the deployed
1722 // app would fail.
1723 (true, None) => panic!(
1724 "directory_shard_router is enabled but TestApp has no control database pool; \
1725 configure a control pool (with_db) or disable directory routing"
1726 ),
1727 (false, _) => std::sync::Arc::new(crate::sharding::HashShardRouter),
1728 };
1729
1730 let probes = crate::probe::ProbeState::ready_for_test();
1731 #[cfg(feature = "ws")]
1732 let test_channels = crate::channels::Channels::new(32);
1733 #[cfg_attr(not(feature = "ws"), allow(unused_mut))]
1734 let mut state = AppState {
1735 extensions: std::sync::Arc::new(std::sync::RwLock::new(
1736 std::collections::HashMap::new(),
1737 )),
1738 #[cfg(feature = "db")]
1739 pool,
1740 #[cfg(feature = "db")]
1741 replica_pool,
1742 // Build the shard set from the test config so handlers using
1743 // the sharding extractors behave as they would in production.
1744 // Pools are lazy, so this needs no running databases.
1745 //
1746 // Under transactional isolation each shard primary pool is built
1747 // with `max_size(1)` and a `begin_test_transaction` hook (mirroring
1748 // the control pool above) so writes routed to a shard are rolled
1749 // back at the end of the test — the same isolation the control pool
1750 // gets. Replicas are skipped; all shard reads run on the primary.
1751 #[cfg(all(feature = "db", not(feature = "sqlite")))]
1752 shards: if self.transactional {
1753 crate::sharding::create_shard_set_transactional(
1754 &self.config.database,
1755 shard_router.clone(),
1756 )
1757 .expect("transactional test shard pools should build from config")
1758 } else {
1759 crate::sharding::create_shard_set(&self.config.database, shard_router.clone())
1760 .expect("test shard pools should build from config")
1761 },
1762 // The transactional shard-set builder is Postgres-only (per-shard
1763 // `begin_test_transaction` isolation); under the `sqlite` feature the
1764 // harness always uses the plain builder (no shard rollback isolation).
1765 #[cfg(all(feature = "db", feature = "sqlite"))]
1766 shards: crate::sharding::create_shard_set(&self.config.database, shard_router.clone())
1767 .expect("test shard pools should build from config"),
1768 profile: self.config.profile.clone(),
1769 role: self.config.role,
1770 started_at: std::time::Instant::now(),
1771 health_detailed: self.config.health.detailed,
1772 probes: probes.clone(),
1773 metrics: crate::middleware::MetricsCollector::new(),
1774 log_levels: crate::actuator::LogLevels::new(&self.config.log.level),
1775 task_registry: crate::actuator::TaskRegistry::new(),
1776 job_registry: crate::actuator::JobRegistry::new(),
1777 config_props: crate::actuator::ConfigProperties::default(),
1778 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
1779 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
1780 #[cfg(feature = "presence")]
1781 presence: crate::presence::Presence::new(test_channels.clone()),
1782 #[cfg(feature = "ws")]
1783 channels: test_channels,
1784
1785 #[cfg(feature = "ws")]
1786 shutdown: tokio_util::sync::CancellationToken::new(),
1787 policy_registry: crate::authorization::PolicyRegistry::default(),
1788 forbidden_response: self
1789 .forbidden_response_override
1790 .unwrap_or(self.config.security.forbidden_response),
1791 auth_session_key: self.config.auth.session_key.clone(),
1792 shared_cache: None,
1793 clock: self
1794 .clock
1795 .unwrap_or_else(|| std::sync::Arc::new(crate::time::SystemClock)),
1796 app_id: crate::state::AppState::next_app_id(),
1797 };
1798
1799 for register in self.policy_registrations {
1800 register(state.policy_registry());
1801 }
1802 state.insert_extension(crate::app::RegisteredApiVersions(self.api_versions));
1803 crate::app::install_webhook_registry(&state, &self.config);
1804
1805 // Install AutumnConfig so DbState::statement_timeout / slow_query_threshold
1806 // and HTTP Client resilience can read the test-supplied config.
1807 state.insert_extension(self.config.clone());
1808
1809 #[cfg(feature = "mail")]
1810 let mail_recorder_for_client = {
1811 let recorder_for_client = self.mail_recorder.clone();
1812 let recorder = std::sync::Arc::new(self.mail_recorder);
1813 let effective: std::sync::Arc<dyn crate::interceptor::MailInterceptor> =
1814 if let Some(user) = self.mail_interceptor {
1815 std::sync::Arc::new(ChainedMailInterceptor {
1816 first: recorder,
1817 second: user,
1818 })
1819 } else {
1820 recorder
1821 };
1822 state.insert_extension(effective);
1823 recorder_for_client
1824 };
1825 // Always install the job recorder so `enqueued_jobs`/`assert_job_*` and
1826 // `perform_enqueued_jobs` work with no opt-in. The recorder runs first
1827 // and composes with any user-supplied `with_job_interceptor` (which
1828 // still runs, after the recorder). A single `Arc<dyn JobInterceptor>`
1829 // extension is what the job runtime reads, so we chain rather than
1830 // install two.
1831 let job_recorder_for_client = {
1832 let recorder_for_client = self.job_recorder.clone();
1833 let recorder: std::sync::Arc<dyn crate::interceptor::JobInterceptor> =
1834 std::sync::Arc::new(self.job_recorder);
1835 let effective: std::sync::Arc<dyn crate::interceptor::JobInterceptor> =
1836 if let Some(user) = self.job_interceptor {
1837 std::sync::Arc::new(ChainedJobInterceptor {
1838 first: recorder,
1839 second: user,
1840 })
1841 } else {
1842 recorder
1843 };
1844 state.insert_extension(effective);
1845 recorder_for_client
1846 };
1847 #[cfg(feature = "db")]
1848 if let Some(interceptor) = db_interceptor {
1849 state.insert_extension(interceptor);
1850 }
1851 #[cfg(feature = "ws")]
1852 let broadcast_recorder_for_client = {
1853 let mut interceptors: Vec<std::sync::Arc<dyn crate::interceptor::ChannelsInterceptor>> =
1854 Vec::new();
1855
1856 // Recorder runs first so it observes every publish before any
1857 // user-supplied interceptor can short-circuit the chain.
1858 let recorder_for_client = self.broadcast_recorder.clone();
1859 if let Some(recorder) = self.broadcast_recorder {
1860 interceptors.push(std::sync::Arc::new(recorder));
1861 }
1862 if let Some(interceptor) = self.channels_interceptor {
1863 // Preserve the existing `insert_extension` behavior so the
1864 // user's interceptor is discoverable from state.
1865 state.insert_extension(interceptor.clone());
1866 interceptors.push(interceptor);
1867 }
1868
1869 // AC6: install nothing (and leave production `Channels` untouched)
1870 // unless at least one interceptor was requested.
1871 if !interceptors.is_empty() {
1872 state.channels = crate::channels::Channels::with_shared_backend(
1873 std::sync::Arc::new(crate::channels::InterceptedChannelsBackend::new(
1874 state.channels.backend().clone(),
1875 interceptors,
1876 )),
1877 );
1878 #[cfg(feature = "presence")]
1879 {
1880 state.presence = crate::presence::Presence::new(state.channels.clone());
1881 }
1882 }
1883 recorder_for_client
1884 };
1885 #[cfg(feature = "oauth2")]
1886 if let Some(interceptor) = self.http_interceptor {
1887 state.insert_extension(interceptor);
1888 }
1889
1890 #[cfg(feature = "mail")]
1891 {
1892 if let Some(handle) = self.suppression_store.clone() {
1893 state.insert_extension(handle);
1894 }
1895 // Mirror AppBuilder::run: register the bounce/complaint suppression
1896 // handle before install_mailer so the test mailer actually consults
1897 // it (install_mailer reads it back via the extension).
1898 if let Some(handle) = self.mail_suppression_store.clone() {
1899 state.insert_extension(handle);
1900 }
1901 crate::mail::install_mailer(&state, &self.config.mail, false)
1902 .expect("Failed to configure test mailer");
1903 }
1904
1905 // Install HTTP client config so the Client extractor can read it.
1906 #[cfg(feature = "http-client")]
1907 state.insert_extension(self.config.http.clone());
1908
1909 // Register the shared reqwest::Client so Client::from_state reuses the
1910 // connection pool in tests, mirroring the production build_state path.
1911 #[cfg(feature = "http-client")]
1912 state.insert_extension(crate::http_client::SharedReqwestClient {
1913 client: crate::http_client::Client::build_inner(&self.config.http.client),
1914 timeout_secs: self.config.http.client.timeout_secs,
1915 });
1916
1917 // Install mock registry when http_mock() was called.
1918 #[cfg(feature = "http-client")]
1919 if let Some(registry) = self.http_mock_registry {
1920 state.insert_extension(crate::http_client::HttpMockRegistryExt(registry));
1921 }
1922
1923 // Register metrics sources before state initializers — mirrors production
1924 // AppBuilder::run ordering so initializers can observe the registry.
1925 for (name, source) in self.metrics_sources {
1926 if let Err(e) = state.metrics_source_registry.register(name, source) {
1927 tracing::warn!("{e}");
1928 }
1929 }
1930 for (name, group, indicator) in self.health_indicators {
1931 if let Err(e) = state
1932 .health_indicator_registry
1933 .register(name, group, indicator)
1934 {
1935 tracing::warn!("{e}");
1936 }
1937 }
1938
1939 // Mirror production `AppBuilder` wiring: surface each configured shard's
1940 // replica readiness as a `db:shard:<name>` indicator so `/ready`
1941 // refreshes shard replica health (gating `fail_readiness` shards and
1942 // marking healthy replicas ready for `ShardedDb` read routing).
1943 #[cfg(feature = "db")]
1944 if let Some(set) = state.shards() {
1945 crate::sharding::register_shard_health_indicators(
1946 set,
1947 &state.health_indicator_registry,
1948 );
1949 }
1950
1951 for initializer in self.state_initializers {
1952 initializer(&state);
1953 }
1954
1955 // Wire the event bus: always install a recorder so tests can assert on
1956 // published events without a job runner, register the listener registry
1957 // for the `Events` extractor, and fold durable listeners into the jobs
1958 // started below so they dispatch through the in-process test runtime.
1959 state.insert_extension(crate::events::EventRecorder::default());
1960 let event_recorder = state
1961 .extension::<crate::events::EventRecorder>()
1962 .expect("event recorder just installed");
1963 let event_registry =
1964 crate::events::EventRegistry::from_listeners(std::mem::take(&mut self.listeners));
1965 self.jobs.extend(event_registry.durable_job_infos());
1966 state.insert_extension(event_registry.clone());
1967 crate::events::init_global_event_bus(&event_registry, &state, Some(event_recorder));
1968
1969 for job in &self.jobs {
1970 state.job_registry.register(&job.name);
1971 }
1972
1973 let job_runtime = if self.jobs.is_empty() {
1974 None
1975 } else {
1976 let shutdown = tokio_util::sync::CancellationToken::new();
1977 crate::job::start_runtime(
1978 self.jobs.clone(),
1979 &state,
1980 &shutdown,
1981 &self.config.jobs,
1982 true,
1983 )
1984 .expect("Failed to start job runtime in test");
1985 Some(TestJobRuntime { shutdown })
1986 };
1987
1988 // Retain the registered job metadata so `perform_enqueued_jobs` can look
1989 // up each captured job's handler by name and dispatch it directly.
1990 let jobs_for_client = self.jobs.clone();
1991
1992 #[cfg_attr(not(feature = "inbound-mail"), allow(unused_mut))]
1993 let mut merge_routers = self.merge_routers;
1994 #[cfg(feature = "inbound-mail")]
1995 if let Some(ref im_router) = self.inbound_mail_router {
1996 let mut registered_inbound: std::collections::HashSet<String> =
1997 std::collections::HashSet::new();
1998 for (path, axum_router) in crate::inbound_mail::build_routes(im_router) {
1999 if self
2000 .routes
2001 .iter()
2002 .any(|r| r.method == Method::POST && r.path == path)
2003 || self.scoped_groups.iter().any(|g| {
2004 g.routes.iter().any(|r| {
2005 r.method == Method::POST
2006 && crate::router::join_nested_path(&g.prefix, r.path)
2007 == path.as_str()
2008 })
2009 })
2010 || self.nest_routers.iter().any(|(nest_path, _)| {
2011 let p = nest_path.as_str();
2012 path.as_str() == p
2013 || path.starts_with(p)
2014 && (p.ends_with('/') || path.as_bytes().get(p.len()) == Some(&b'/'))
2015 })
2016 {
2017 tracing::warn!(
2018 path = %path,
2019 "inbound_mail: skipping webhook route — a POST handler is \
2020 already registered at this path by the application"
2021 );
2022 continue;
2023 }
2024 if !registered_inbound.insert(path.clone()) {
2025 tracing::warn!(
2026 path = %path,
2027 "inbound_mail: skipping duplicate inbound webhook path"
2028 );
2029 continue;
2030 }
2031 self.config.security.csrf.exempt_paths.push(path.clone());
2032 self.config.security.captcha_exempt_paths.push(path);
2033 merge_routers.push(axum_router);
2034 }
2035 }
2036
2037 // Explicitly build the session store the router's `SessionLayer` will
2038 // use, so the client keeps a handle for `acting_as` to mint sessions
2039 // (#1359). For the default in-memory backend we install a `MemoryStore`
2040 // and pass it as the custom store; for other backends we leave it to
2041 // config-driven selection (`None`) and the client's `session_store`
2042 // handle stays `None`, so `acting_as` panics with a clear message.
2043 let session_backed_by_memory = matches!(
2044 self.config.session.backend,
2045 crate::session::SessionBackend::Memory
2046 );
2047 let test_session_store: Option<std::sync::Arc<dyn crate::session::BoxedSessionStore>> =
2048 if session_backed_by_memory {
2049 Some(std::sync::Arc::new(crate::session::MemoryStore::new()))
2050 } else {
2051 None
2052 };
2053 let session_cookie_name = self.config.session.cookie_name.clone();
2054 let auth_session_key = self.config.auth.session_key.clone();
2055 // Mirror the router's session-cookie signing decision (router.rs): only
2056 // thread signing keys when a secret is configured or in production.
2057 let session_signing_keys = {
2058 let is_production =
2059 matches!(self.config.profile.as_deref(), Some("prod" | "production"));
2060 if self.config.security.signing_secret.secret.is_some() || is_production {
2061 Some(std::sync::Arc::new(
2062 crate::security::config::resolve_signing_keys(
2063 &self.config.security.signing_secret,
2064 ),
2065 ))
2066 } else {
2067 None
2068 }
2069 };
2070
2071 let router = crate::router::try_build_router_inner(
2072 self.routes,
2073 &self.config,
2074 state.clone(),
2075 crate::router::RouterContext {
2076 exception_filters: self.exception_filters,
2077 scoped_groups: self.scoped_groups,
2078 merge_routers,
2079 nest_routers: self.nest_routers,
2080 custom_layers: self.custom_layers,
2081 static_gate_layers: self.static_gate_layers,
2082 #[cfg(feature = "maud")]
2083 error_page_renderer: None,
2084 session_store: test_session_store.clone(),
2085 #[cfg(feature = "openapi")]
2086 openapi: self.openapi,
2087 #[cfg(feature = "mcp")]
2088 mcp: self.mcp,
2089 },
2090 )
2091 .expect("failed to build test router");
2092 // Mirror production's outermost access-log fallback (#999): in
2093 // production it is applied in `apply_startup_barrier`, outside the
2094 // session and exception-filter layers, and emits only for responses
2095 // the primary in-stack layer never saw (e.g. session-store outage
2096 // 503s), so tests observe the same access-log behavior an operator
2097 // would.
2098 let router = if self.config.log.access_log {
2099 router.layer(crate::middleware::AccessLogLayer::fallback(
2100 self.config.log.access_log_exclude.clone(),
2101 ))
2102 } else {
2103 router
2104 };
2105 // Mirror production's outermost Server-Timing fallback (#1348): in
2106 // production it is applied in `apply_startup_barrier`, outside the
2107 // primary `ServerTimingLayer` and the late `/mcp` merge, and appends a
2108 // `total` only for responses the primary never saw — short-circuits and
2109 // the late-merged `/mcp` envelope. Without mirroring it here a
2110 // `tools/call` would carry no outer `total` in tests, unlike production,
2111 // so tests would not observe the real `/mcp` timing an operator sees.
2112 // Applied outer to the access-log fallback, matching production order.
2113 let router = if crate::config::server_timing_enabled(&self.config) {
2114 router.layer(crate::middleware::ServerTimingLayer::fallback(true))
2115 } else {
2116 router
2117 };
2118 TestClient {
2119 router,
2120 probes,
2121 state,
2122 _job_runtime: job_runtime,
2123 clock_as_any: self.clock_as_any,
2124 #[cfg(feature = "mail")]
2125 mail_recorder: Some(mail_recorder_for_client),
2126 #[cfg(feature = "ws")]
2127 broadcast_recorder: broadcast_recorder_for_client,
2128 job_recorder: Some(job_recorder_for_client),
2129 jobs: jobs_for_client,
2130 cookie_jar: std::sync::Arc::new(
2131 std::sync::Mutex::new(std::collections::HashMap::new()),
2132 ),
2133 session_store: test_session_store,
2134 session_cookie_name,
2135 auth_session_key,
2136 session_signing_keys,
2137 }
2138 }
2139}
2140
2141impl Default for TestApp {
2142 fn default() -> Self {
2143 Self::new()
2144 }
2145}
2146
2147// ── TestClient ─────────────────────────────────────────────────
2148
2149/// Fluent HTTP client for integration tests.
2150///
2151/// Analogous to Spring Boot's `MockMvc` or Django's `Client`.
2152/// Fires requests through the full Axum middleware pipeline using
2153/// `tower::ServiceExt::oneshot()` -- no TCP listener required.
2154///
2155/// Created by [`TestApp::build()`].
2156///
2157/// # Examples
2158///
2159/// ```rust,ignore
2160/// let client = TestApp::new().routes(routes![handler]).build();
2161///
2162/// // GET request
2163/// client.get("/path").send().await.assert_ok();
2164///
2165/// // POST with JSON body
2166/// client.post("/items")
2167/// .json(&serde_json::json!({"name": "foo"}))
2168/// .send().await
2169/// .assert_status(201);
2170///
2171/// // PUT with header
2172/// client.put("/items/1")
2173/// .header("authorization", "Bearer token")
2174/// .json(&serde_json::json!({"name": "bar"}))
2175/// .send().await
2176/// .assert_ok();
2177/// ```
2178pub struct TestClient {
2179 router: axum::Router,
2180 probes: crate::probe::ProbeState,
2181 pub(crate) state: AppState,
2182 _job_runtime: Option<TestJobRuntime>,
2183 /// Retained so `advance_clock` can downcast to [`crate::time::TickingClock`].
2184 clock_as_any: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
2185 /// `None` when built via [`TestApp::from_router`], which bypasses recorder
2186 /// wiring. `Some` for all clients produced by [`TestApp::build`].
2187 #[cfg(feature = "mail")]
2188 mail_recorder: Option<MailRecorder>,
2189 /// `Some` only when [`TestApp::record_broadcasts`] opted in; otherwise
2190 /// `None` (also for clients built via [`TestApp::from_router`]).
2191 #[cfg(feature = "ws")]
2192 broadcast_recorder: Option<BroadcastRecorder>,
2193 /// Built-in job recorder. `None` for clients built via
2194 /// [`TestApp::from_router`], which bypasses recorder wiring; `Some` for all
2195 /// clients produced by [`TestApp::build`].
2196 job_recorder: Option<JobRecorder>,
2197 /// Registered job metadata, retained so [`TestClient::perform_enqueued_jobs`]
2198 /// can dispatch each captured job through its handler. Empty for
2199 /// [`TestApp::from_router`] clients.
2200 jobs: Vec<crate::job::JobInfo>,
2201 /// Per-client cookie jar (`name → value + optional expiry`). Every
2202 /// response's `Set-Cookie` is folded in here, its `Max-Age`/`Expires`
2203 /// recorded, and it is replayed on subsequent requests until it expires
2204 /// against the client's clock, so a real
2205 /// `POST /login` → `GET /dashboard` flow works with no manual header
2206 /// threading. Shared with each [`RequestBuilder`] via a cloned `Arc`.
2207 cookie_jar: CookieJar,
2208 /// Handle to the session store the router's `SessionLayer` reads, so
2209 /// [`TestClient::acting_as`] can mint an authenticated session directly.
2210 /// `None` for clients built via [`TestApp::from_router`] or configured
2211 /// with a non-memory session backend; `acting_as` panics for those.
2212 session_store: Option<std::sync::Arc<dyn crate::session::BoxedSessionStore>>,
2213 /// Name of the session cookie (`session.cookie_name`, default
2214 /// `"autumn.sid"`); the cookie `acting_as` seeds and `log_out` clears.
2215 session_cookie_name: String,
2216 /// Session key the auth stack reads for identity (`auth.session_key`,
2217 /// default `"user_id"`); the key `acting_as` writes.
2218 auth_session_key: String,
2219 /// Session cookie signing keys when `security.signing_secret` is set (or
2220 /// in production), mirroring how the router signs session cookies. When
2221 /// present, `acting_as` signs the seeded cookie so the `SessionLayer`
2222 /// accepts it.
2223 session_signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
2224}
2225
2226/// A cookie stored in the jar: its value plus an optional absolute expiry.
2227///
2228/// `expires_at: None` is a session cookie that never client-expires; `Some(t)`
2229/// records the instant (from `Max-Age`/`Expires`) past which the cookie must no
2230/// longer be replayed, evaluated against the client's (possibly virtual) clock.
2231#[derive(Clone)]
2232struct StoredCookie {
2233 value: String,
2234 expires_at: Option<chrono::DateTime<chrono::Utc>>,
2235}
2236
2237/// Shared per-client cookie store: cookie name → stored cookie (value + expiry).
2238type CookieJar = std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, StoredCookie>>>;
2239
2240struct TestJobRuntime {
2241 shutdown: tokio_util::sync::CancellationToken,
2242}
2243
2244impl Drop for TestJobRuntime {
2245 fn drop(&mut self) {
2246 self.shutdown.cancel();
2247 crate::job::clear_global_job_client();
2248 }
2249}
2250
2251impl TestClient {
2252 /// Returns a reference to the [`AppState`] wired into this test app's router.
2253 #[must_use]
2254 pub const fn state(&self) -> &AppState {
2255 &self.state
2256 }
2257
2258 /// Every recorded publication of event type `E`, deserialized.
2259 ///
2260 /// Events are recorded synchronously at publish time, so this works whether
2261 /// or not the listeners (sync or durable) have run.
2262 #[must_use]
2263 pub fn published_events<E: crate::events::Event>(&self) -> Vec<E> {
2264 self.state
2265 .extension::<crate::events::EventRecorder>()
2266 .map(|recorder| recorder.published::<E>())
2267 .unwrap_or_default()
2268 }
2269
2270 /// Assert that at least one event of type `E` was published during the test.
2271 ///
2272 /// # Panics
2273 ///
2274 /// Panics if no event of type `E` was recorded.
2275 pub fn assert_event_published<E: crate::events::Event>(&self) {
2276 let count = self
2277 .state
2278 .extension::<crate::events::EventRecorder>()
2279 .map_or(0, |recorder| recorder.count::<E>());
2280 assert!(
2281 count > 0,
2282 "expected event `{}` to have been published, but none were recorded",
2283 E::NAME,
2284 );
2285 }
2286
2287 /// Step the test clock forward by `duration`.
2288 ///
2289 /// Only effective when the app was configured with a
2290 /// [`crate::time::TickingClock`] via [`TestApp::with_clock`]. Calling this
2291 /// with a [`crate::time::FixedClock`] or without any custom clock is a
2292 /// safe no-op — time stays where it is.
2293 ///
2294 /// This method only affects the wall-clock time reported by the
2295 /// [`crate::time::Clock`] extractor. Tokio's runtime timer (used by
2296 /// `tokio::time::sleep`, `tokio::time::Instant`, etc.) is not affected.
2297 ///
2298 /// ```rust,no_run
2299 /// use autumn_web::test::TestApp;
2300 /// use autumn_web::time::TickingClock;
2301 /// use chrono::{TimeZone, Utc};
2302 /// use std::time::Duration;
2303 ///
2304 /// # #[tokio::main]
2305 /// # async fn main() {
2306 /// let clock = TickingClock::starting_at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap());
2307 /// let client = TestApp::new().with_clock(clock).build();
2308 ///
2309 /// client.advance_clock(Duration::from_secs(86400)); // advance 1 day
2310 /// # }
2311 /// ```
2312 pub fn advance_clock(&self, duration: std::time::Duration) {
2313 if let Some(any) = &self.clock_as_any {
2314 let cloned = std::sync::Arc::clone(any);
2315 if let Ok(ticking) = cloned.downcast::<crate::time::TickingClock>() {
2316 ticking.advance(duration);
2317 }
2318 // FixedClock or other types: advance_clock is a no-op.
2319 }
2320 // No clock installed: also a no-op.
2321 }
2322
2323 /// Unwrap the underlying [`axum::Router`] out of the [`TestClient`].
2324 pub fn into_router(self) -> axum::Router {
2325 self.router
2326 }
2327
2328 /// Return the [`crate::probe::ProbeState`] wired into this test app's router.
2329 ///
2330 /// Use this to drive readiness/liveness transitions in integration tests
2331 /// and verify the HTTP probe endpoints reflect state changes.
2332 pub const fn probes(&self) -> &crate::probe::ProbeState {
2333 &self.probes
2334 }
2335
2336 /// Returns all emails sent during this test, in the order they were sent.
2337 ///
2338 /// The built-in recorder is installed automatically — no
2339 /// `.with_mail_interceptor(…)` call is required.
2340 ///
2341 /// # Example
2342 ///
2343 /// ```rust,ignore
2344 /// client.post("/signup").json(&body).send().await.assert_ok();
2345 /// let mail = &client.sent_mail()[0];
2346 /// assert_eq!(mail.subject, "Welcome!");
2347 /// ```
2348 #[cfg(feature = "mail")]
2349 #[must_use]
2350 pub fn sent_mail(&self) -> Vec<SentMail> {
2351 self.mail_recorder
2352 .as_ref()
2353 .expect("sent_mail() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead")
2354 .get_sent()
2355 }
2356
2357 /// Asserts that exactly `n` emails were sent, panicking with a list of
2358 /// what was actually sent on failure.
2359 ///
2360 /// Returns `&self` for chaining.
2361 ///
2362 /// # Panics
2363 ///
2364 /// Panics when the count does not match.
2365 #[cfg(feature = "mail")]
2366 pub fn assert_email_count(&self, n: usize) -> &Self {
2367 let sent = self.sent_mail();
2368 assert_eq!(
2369 sent.len(),
2370 n,
2371 "expected {n} email(s) to have been sent, got {};\nactually sent: {sent:#?}",
2372 sent.len(),
2373 );
2374 self
2375 }
2376
2377 /// Asserts that no emails were sent.
2378 ///
2379 /// Returns `&self` for chaining.
2380 ///
2381 /// # Panics
2382 ///
2383 /// Panics when any emails were sent.
2384 #[cfg(feature = "mail")]
2385 pub fn assert_no_email_sent(&self) -> &Self {
2386 self.assert_email_count(0)
2387 }
2388
2389 /// Asserts that at least one sent email satisfies `predicate`, panicking
2390 /// with a list of what was actually sent on failure.
2391 ///
2392 /// Returns `&self` for chaining.
2393 ///
2394 /// # Panics
2395 ///
2396 /// Panics when no sent email matches.
2397 ///
2398 /// # Example
2399 ///
2400 /// ```rust,ignore
2401 /// client
2402 /// .assert_email_sent(|m| m.to.iter().any(|a| a == "alice@example.com"))
2403 /// .assert_email_sent(|m| m.subject == "Welcome!");
2404 /// ```
2405 #[cfg(feature = "mail")]
2406 pub fn assert_email_sent(&self, predicate: impl Fn(&SentMail) -> bool) -> &Self {
2407 let sent = self.sent_mail();
2408 assert!(
2409 sent.iter().any(predicate),
2410 "no sent email matched the predicate;\nactually sent: {sent:#?}",
2411 );
2412 self
2413 }
2414
2415 // ── Broadcast recorder accessors & assertions (issue #1043) ──────────
2416
2417 /// Every recorded channel publication, in publish order.
2418 ///
2419 /// Requires opting in with [`TestApp::record_broadcasts`]. Captures both
2420 /// raw `publish` text and `publish_html` HTML/OOB payloads.
2421 ///
2422 /// # Panics
2423 ///
2424 /// Panics if [`TestApp::record_broadcasts`] was not called.
2425 #[cfg(feature = "ws")]
2426 #[must_use]
2427 pub fn broadcasts(&self) -> Vec<RecordedBroadcast> {
2428 self.broadcast_recorder
2429 .as_ref()
2430 .expect(
2431 "broadcasts() requires opting in via TestApp::record_broadcasts() before build()",
2432 )
2433 .recorded()
2434 }
2435
2436 /// Recorded publications on `topic`, in publish order.
2437 ///
2438 /// # Panics
2439 ///
2440 /// Panics if [`TestApp::record_broadcasts`] was not called.
2441 #[cfg(feature = "ws")]
2442 #[must_use]
2443 pub fn broadcasts_on(&self, topic: &str) -> Vec<RecordedBroadcast> {
2444 self.broadcasts()
2445 .into_iter()
2446 .filter(|b| b.topic == topic)
2447 .collect()
2448 }
2449
2450 /// Builds a self-diagnosing failure message listing what was actually
2451 /// published to `topic` and, grouped, to every other topic.
2452 #[cfg(feature = "ws")]
2453 fn broadcast_failure_message(&self, topic: &str, headline: &str) -> String {
2454 use std::collections::BTreeMap;
2455 use std::fmt::Write as _;
2456 let all = self.broadcasts();
2457 let on_topic: Vec<&str> = all
2458 .iter()
2459 .filter(|b| b.topic == topic)
2460 .map(|b| b.payload.as_str())
2461 .collect();
2462
2463 let mut others: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2464 for b in &all {
2465 if b.topic != topic {
2466 others
2467 .entry(b.topic.as_str())
2468 .or_default()
2469 .push(b.payload.as_str());
2470 }
2471 }
2472
2473 let mut msg = format!("{headline}\n");
2474 let _ = writeln!(
2475 msg,
2476 "published to {topic:?} ({} total): {on_topic:#?}",
2477 on_topic.len(),
2478 );
2479 if others.is_empty() {
2480 msg.push_str("no publications on any other topic");
2481 } else {
2482 let _ = write!(msg, "other topics published: {others:#?}");
2483 }
2484 msg
2485 }
2486
2487 /// Asserts that at least one publication on `topic` satisfies `predicate`.
2488 ///
2489 /// Returns `&Self` for chaining.
2490 ///
2491 /// # Panics
2492 ///
2493 /// Panics when no matching publication is found, dumping what *was*
2494 /// published to `topic` and nearby topics.
2495 #[cfg(feature = "ws")]
2496 pub fn assert_broadcast(
2497 &self,
2498 topic: &str,
2499 predicate: impl Fn(&RecordedBroadcast) -> bool,
2500 ) -> &Self {
2501 let matched = self.broadcasts_on(topic).iter().any(predicate);
2502 assert!(
2503 matched,
2504 "{}",
2505 self.broadcast_failure_message(
2506 topic,
2507 &format!("no broadcast on {topic:?} matched the predicate;"),
2508 )
2509 );
2510 self
2511 }
2512
2513 /// Asserts that exactly `n` publications were made to `topic`.
2514 ///
2515 /// Returns `&Self` for chaining.
2516 ///
2517 /// # Panics
2518 ///
2519 /// Panics when the count does not match, dumping what *was* published to
2520 /// `topic` and nearby topics.
2521 #[cfg(feature = "ws")]
2522 pub fn assert_broadcast_count(&self, topic: &str, n: usize) -> &Self {
2523 let count = self.broadcasts_on(topic).len();
2524 assert!(
2525 count == n,
2526 "{}",
2527 self.broadcast_failure_message(
2528 topic,
2529 &format!("expected {n} broadcast(s) on {topic:?}, got {count};"),
2530 )
2531 );
2532 self
2533 }
2534
2535 /// Asserts that nothing was published to `topic`.
2536 ///
2537 /// Returns `&Self` for chaining.
2538 ///
2539 /// # Panics
2540 ///
2541 /// Panics when any publication was made to `topic`, dumping what *was*
2542 /// published to `topic` and nearby topics.
2543 #[cfg(feature = "ws")]
2544 pub fn assert_no_broadcasts(&self, topic: &str) -> &Self {
2545 self.assert_broadcast_count(topic, 0)
2546 }
2547
2548 // ── Background-job recorder ────────────────────────────────────
2549
2550 /// Every background-job enqueue captured by the built-in recorder, in the
2551 /// order they were enqueued (across `enqueue`, `enqueue_after_commit`, and
2552 /// `enqueue_in_tx`).
2553 ///
2554 /// The recorder is always on for [`TestApp::build`] clients — no opt-in.
2555 ///
2556 /// # Panics
2557 ///
2558 /// Panics if called on a [`TestClient`] built via [`TestApp::from_router`],
2559 /// which bypasses recorder wiring.
2560 #[must_use]
2561 pub fn enqueued_jobs(&self) -> Vec<RecordedJob> {
2562 self.job_recorder
2563 .as_ref()
2564 .expect(
2565 "enqueued_jobs() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead",
2566 )
2567 .recorded()
2568 }
2569
2570 /// Assert at least one job with the given registered `name` was enqueued.
2571 ///
2572 /// # Panics
2573 ///
2574 /// Panics, listing every job that *was* enqueued, if no enqueue with that
2575 /// name was captured.
2576 pub fn assert_job_enqueued(&self, name: &str) -> &Self {
2577 let jobs = self.enqueued_jobs();
2578 assert!(
2579 jobs.iter().any(|j| j.name == name),
2580 "expected a job named '{name}' to have been enqueued, but it was not.\nEnqueued jobs:\n{}",
2581 format_recorded_jobs(&jobs)
2582 );
2583 self
2584 }
2585
2586 /// Assert at least one job was enqueued with **both** the given registered
2587 /// `name` and an exactly-equal JSON `payload`.
2588 ///
2589 /// # Panics
2590 ///
2591 /// Panics, listing every job that *was* enqueued, if no enqueue matched
2592 /// both the name and payload.
2593 // Takes the payload by value for call-site ergonomics — `json!({..})`
2594 // reads cleanly without a leading `&`, mirroring the acceptance criteria.
2595 #[allow(clippy::needless_pass_by_value)]
2596 pub fn assert_job_enqueued_with(&self, name: &str, payload: serde_json::Value) -> &Self {
2597 let jobs = self.enqueued_jobs();
2598 // Strip the opt-in schema-version envelope (issue #1205) so payload
2599 // assertions stay on clean args even for `#[job(version = N)]` jobs
2600 // whose stored payload is wrapped as `{__autumn_schema_version, args}`.
2601 assert!(
2602 jobs.iter().any(|j| j.name == name
2603 && *crate::payload_version::split_version(&j.payload).1 == payload),
2604 "expected a job named '{name}' enqueued with payload {payload}, but no match was found.\nEnqueued jobs:\n{}",
2605 format_recorded_jobs(&jobs)
2606 );
2607 self
2608 }
2609
2610 /// Assert no jobs were enqueued at all.
2611 ///
2612 /// # Panics
2613 ///
2614 /// Panics, listing every captured enqueue, if any job was enqueued.
2615 pub fn assert_no_jobs_enqueued(&self) -> &Self {
2616 let jobs = self.enqueued_jobs();
2617 assert!(
2618 jobs.is_empty(),
2619 "expected no jobs to have been enqueued, but {} were:\n{}",
2620 jobs.len(),
2621 format_recorded_jobs(&jobs)
2622 );
2623 self
2624 }
2625
2626 /// Drain every captured job and dispatch it through its registered handler,
2627 /// awaiting each in enqueue order, so a test can assert the resulting side
2628 /// effects synchronously.
2629 ///
2630 /// Each captured payload is handed to the same handler the runtime would
2631 /// invoke, so the real deserialization path runs: a payload that cannot be
2632 /// deserialized into the job's args surfaces as a per-job failure (not a
2633 /// silent miss). The queue is emptied — a second call performs nothing
2634 /// until more jobs are enqueued.
2635 ///
2636 /// Returns a [`PerformedJobs`] report carrying each job's `(name, result)`;
2637 /// per-job handler errors (and captured jobs with no registered handler)
2638 /// are surfaced there rather than swallowed. See
2639 /// [`PerformedJobs::assert_all_succeeded`].
2640 ///
2641 /// # Note
2642 ///
2643 /// [`TestApp::build`] starts the in-process job worker by default, and that
2644 /// worker *also* drains and runs the same enqueued jobs. Calling this method
2645 /// therefore executes a job's side effect an **additional** time, on top of
2646 /// the worker's own run. It is primarily for asserting that a job runs to
2647 /// completion — surfacing handler/deserialization errors synchronously — not
2648 /// for counting side effects. Any assertion on a side effect's *count* must
2649 /// account for the worker's run as well (as the job-recorder integration
2650 /// tests do: they settle the worker's run first, then attribute the next
2651 /// increment to this call).
2652 ///
2653 /// The helper invokes each job's registered handler directly and does *not*
2654 /// run it through a user-installed
2655 /// [`JobInterceptor::intercept_execute`](crate::interceptor::JobInterceptor::intercept_execute),
2656 /// so
2657 /// execution-interceptor effects (context injection, metrics, error
2658 /// injection) are exercised by the in-process worker path, not by this
2659 /// helper.
2660 ///
2661 /// # Panics
2662 ///
2663 /// Panics if called on a [`TestClient`] built via [`TestApp::from_router`].
2664 pub async fn perform_enqueued_jobs(&self) -> PerformedJobs {
2665 let recorder = self.job_recorder.as_ref().expect(
2666 "perform_enqueued_jobs() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead",
2667 );
2668 let drained = recorder.drain();
2669 let mut outcomes = Vec::with_capacity(drained.len());
2670 for job in drained {
2671 let handler = self
2672 .jobs
2673 .iter()
2674 .find(|info| info.name == job.name)
2675 .map(|info| info.handler);
2676 let result = match handler {
2677 Some(handler) => (handler)(self.state.clone(), job.payload).await,
2678 None => Err(crate::AutumnError::internal_server_error(
2679 std::io::Error::other(format!(
2680 "no registered handler for enqueued job '{}'; register it via AppBuilder::jobs()",
2681 job.name
2682 )),
2683 )),
2684 };
2685 outcomes.push((job.name, result));
2686 }
2687 PerformedJobs { outcomes }
2688 }
2689
2690 /// The app's configured N+1 detection threshold
2691 /// (`dev.inspector_n_plus_one_threshold`), threaded into every
2692 /// [`RequestBuilder`] so the resulting [`TestResponse`] can default
2693 /// [`TestResponse::assert_no_n_plus_one`] to it.
2694 fn n_plus_one_threshold(&self) -> usize {
2695 self.state.config().dev.inspector_n_plus_one_threshold
2696 }
2697
2698 /// Start building a GET request.
2699 #[must_use]
2700 pub fn get(&self, uri: &str) -> RequestBuilder {
2701 RequestBuilder::new(
2702 self.router.clone(),
2703 Method::GET,
2704 uri,
2705 self.cookie_jar.clone(),
2706 Some(self.state.clock.clone()),
2707 self.n_plus_one_threshold(),
2708 )
2709 }
2710
2711 /// Start building a POST request.
2712 #[must_use]
2713 pub fn post(&self, uri: &str) -> RequestBuilder {
2714 RequestBuilder::new(
2715 self.router.clone(),
2716 Method::POST,
2717 uri,
2718 self.cookie_jar.clone(),
2719 Some(self.state.clock.clone()),
2720 self.n_plus_one_threshold(),
2721 )
2722 }
2723
2724 /// Start building a PUT request.
2725 #[must_use]
2726 pub fn put(&self, uri: &str) -> RequestBuilder {
2727 RequestBuilder::new(
2728 self.router.clone(),
2729 Method::PUT,
2730 uri,
2731 self.cookie_jar.clone(),
2732 Some(self.state.clock.clone()),
2733 self.n_plus_one_threshold(),
2734 )
2735 }
2736
2737 /// Start building a DELETE request.
2738 #[must_use]
2739 pub fn delete(&self, uri: &str) -> RequestBuilder {
2740 RequestBuilder::new(
2741 self.router.clone(),
2742 Method::DELETE,
2743 uri,
2744 self.cookie_jar.clone(),
2745 Some(self.state.clock.clone()),
2746 self.n_plus_one_threshold(),
2747 )
2748 }
2749
2750 /// Start building a PATCH request.
2751 #[must_use]
2752 pub fn patch(&self, uri: &str) -> RequestBuilder {
2753 RequestBuilder::new(
2754 self.router.clone(),
2755 Method::PATCH,
2756 uri,
2757 self.cookie_jar.clone(),
2758 Some(self.state.clock.clone()),
2759 self.n_plus_one_threshold(),
2760 )
2761 }
2762
2763 /// Start building an OPTIONS request (e.g. a CORS preflight).
2764 #[must_use]
2765 pub fn options(&self, uri: &str) -> RequestBuilder {
2766 RequestBuilder::new(
2767 self.router.clone(),
2768 Method::OPTIONS,
2769 uri,
2770 self.cookie_jar.clone(),
2771 Some(self.state.clock.clone()),
2772 self.n_plus_one_threshold(),
2773 )
2774 }
2775
2776 // ── Authentication helpers (#1359) ─────────────────────────
2777
2778 /// Establish an authenticated session for `user_id` *without* calling the
2779 /// login endpoint, then return `&Self` for chaining.
2780 ///
2781 /// Mints a fresh session containing the app's configured
2782 /// `auth.session_key` (default `"user_id"`) set to `user_id`, saves it to
2783 /// the session store the router reads, and seeds the cookie jar with the
2784 /// session cookie. A subsequent request to a `#[secured]` / [`Auth`](crate::auth::Auth)-gated
2785 /// route then extracts the same identity a real login would produce.
2786 ///
2787 /// This sets **identity only** — authorization still runs. A user acted-as
2788 /// here who lacks a required role or scope is still denied.
2789 ///
2790 /// Analogous to Laravel's `actingAs`, Rails' `sign_in`, Django's
2791 /// `force_login`, and Phoenix's `log_in_user`.
2792 ///
2793 /// # Panics
2794 ///
2795 /// Panics if the client has no handle to a session store — i.e. it was
2796 /// built via [`TestApp::from_router`], or configured with a non-memory
2797 /// session backend. Use [`TestApp::build`] with the default (memory)
2798 /// session backend for `acting_as` support.
2799 ///
2800 /// # Examples
2801 ///
2802 /// ```rust,ignore
2803 /// let client = TestApp::new().routes(routes![dashboard]).build();
2804 /// client.acting_as(42).await;
2805 /// client.get("/dashboard").send().await.assert_ok();
2806 /// ```
2807 pub async fn acting_as(&self, user_id: impl std::fmt::Display) -> &Self {
2808 let store = self.session_store.as_ref().unwrap_or_else(|| {
2809 panic!(
2810 "acting_as requires a session store handle, which is only available on clients \
2811 built via `TestApp::build()` with the default in-memory session backend. \
2812 Clients from `TestApp::from_router` or configured with a non-memory backend \
2813 cannot mint sessions this way."
2814 )
2815 });
2816
2817 let session_id = uuid::Uuid::new_v4().to_string();
2818 let mut data = std::collections::HashMap::new();
2819 data.insert(self.auth_session_key.clone(), user_id.to_string());
2820 store
2821 .boxed_save(&session_id, data)
2822 .await
2823 .expect("failed to save acting_as session to the test session store");
2824
2825 // Match the router's cookie encoding: sign the id when signing keys
2826 // are active, otherwise store the raw id.
2827 let cookie_value = self.session_signing_keys.as_ref().map_or_else(
2828 || session_id.clone(),
2829 |keys| format!("{session_id}.{}", keys.sign(session_id.as_bytes())),
2830 );
2831 self.cookie_jar
2832 .lock()
2833 .expect("cookie jar mutex poisoned")
2834 .insert(
2835 self.session_cookie_name.clone(),
2836 StoredCookie {
2837 value: cookie_value,
2838 expires_at: None,
2839 },
2840 );
2841
2842 self
2843 }
2844
2845 /// Alias for [`acting_as`](Self::acting_as).
2846 ///
2847 /// Provided for readers coming from frameworks whose helper is spelled
2848 /// `login_as` / `sign_in`.
2849 ///
2850 /// # Panics
2851 ///
2852 /// See [`acting_as`](Self::acting_as).
2853 pub async fn login_as(&self, user_id: impl std::fmt::Display) -> &Self {
2854 self.acting_as(user_id).await
2855 }
2856
2857 /// Clear the session cookie from the jar, reverting the client to an
2858 /// unauthenticated state, then return `&Self` for chaining.
2859 ///
2860 /// After `log_out`, a request to a secured route returns its
2861 /// unauthenticated status (401 / redirect) again. The corresponding
2862 /// server-side session (if any) is left to expire naturally.
2863 ///
2864 /// Analogous to Laravel's `Auth::logout`, Rails' `sign_out`, and Django's
2865 /// `logout`.
2866 pub fn log_out(&self) -> &Self {
2867 self.cookie_jar
2868 .lock()
2869 .expect("cookie jar mutex poisoned")
2870 .remove(&self.session_cookie_name);
2871 self
2872 }
2873}
2874
2875/// Fold a single `Set-Cookie` header value into the cookie jar.
2876///
2877/// Stores `name=value` along with any absolute expiry parsed from the header's
2878/// `Max-Age`/`Expires` attributes (so a live cookie stops being replayed once
2879/// the clock passes it), or removes the cookie when the header marks it for
2880/// immediate deletion (`Max-Age=0`, a non-positive `Max-Age`, or an `Expires`
2881/// in the past — the encodings the session layer and CSRF layer use to clear
2882/// cookies).
2883///
2884/// When both `Max-Age` and `Expires` are present, `Max-Age` wins (per
2885/// RFC 6265). A live cookie with no expiry attributes is stored as a session
2886/// cookie (`expires_at: None`) that never client-expires.
2887///
2888/// `now` is the reference instant for evaluating `Max-Age`/`Expires`; callers
2889/// pass the framework's (possibly virtual) clock so a test that pins or
2890/// advances time sees deterministic expiry.
2891fn apply_set_cookie(
2892 jar: &mut std::collections::HashMap<String, StoredCookie>,
2893 header: &str,
2894 now: chrono::DateTime<chrono::Utc>,
2895) {
2896 let mut parts = header.split(';');
2897 let Some(pair) = parts.next() else {
2898 return;
2899 };
2900 let Some((name, value)) = pair.split_once('=') else {
2901 return;
2902 };
2903 let name = name.trim();
2904 let value = value.trim();
2905 if name.is_empty() {
2906 return;
2907 }
2908
2909 // The jar reliably recognizes Autumn's own cookie-clear encodings: an empty
2910 // value (handled below) and a non-positive `Max-Age`, which the session and
2911 // CSRF layers use to delete cookies. Third-party `Expires`-based deletions
2912 // are only best-effort — an RFC 2822 timestamp in the past is honored, but
2913 // other date encodings a foreign server might send are not fully parsed.
2914 let mut deletes = false;
2915 // Absolute expiry parsed from a positive `Max-Age`/future `Expires`. When
2916 // both are present, `Max-Age` wins (RFC 6265), so track them separately and
2917 // resolve at the end.
2918 let mut max_age_expiry: Option<chrono::DateTime<chrono::Utc>> = None;
2919 let mut expires_expiry: Option<chrono::DateTime<chrono::Utc>> = None;
2920 let mut saw_max_age = false;
2921 for attr in parts {
2922 let attr = attr.trim();
2923 // Both `Max-Age=` and `Expires=` are 8-byte ASCII prefixes; match them
2924 // case-insensitively (`EXPIRES=` etc. are equally valid per RFC 6265).
2925 let prefix = attr.get(..8);
2926 if prefix.is_some_and(|p| p.eq_ignore_ascii_case("Max-Age=")) {
2927 if let Ok(secs) = attr[8..].trim().parse::<i64>() {
2928 saw_max_age = true;
2929 if secs <= 0 {
2930 deletes = true;
2931 } else {
2932 max_age_expiry = now.checked_add_signed(chrono::Duration::seconds(secs));
2933 }
2934 }
2935 } else if prefix.is_some_and(|p| p.eq_ignore_ascii_case("Expires="))
2936 && let Ok(when) = chrono::DateTime::parse_from_rfc2822(attr[8..].trim())
2937 {
2938 let when = when.with_timezone(&chrono::Utc);
2939 if when <= now {
2940 deletes = true;
2941 } else {
2942 expires_expiry = Some(when);
2943 }
2944 }
2945 }
2946
2947 if deletes || value.is_empty() {
2948 jar.remove(name);
2949 } else {
2950 // `Max-Age` takes precedence over `Expires` when both are present.
2951 let expires_at = if saw_max_age {
2952 max_age_expiry
2953 } else {
2954 expires_expiry
2955 };
2956 jar.insert(
2957 name.to_owned(),
2958 StoredCookie {
2959 value: value.to_owned(),
2960 expires_at,
2961 },
2962 );
2963 }
2964}
2965
2966// ── RequestBuilder ─────────────────────────────────────────────
2967
2968/// Fluent builder for composing an HTTP request in tests.
2969///
2970/// Created by [`TestClient::get()`], [`TestClient::post()`], etc.
2971/// Call [`.send()`](Self::send) to fire the request and get a
2972/// [`TestResponse`].
2973pub struct RequestBuilder {
2974 router: axum::Router,
2975 method: Method,
2976 uri: String,
2977 headers: Vec<(String, String)>,
2978 body: Body,
2979 /// Shared with the originating [`TestClient`]: cookies are read from here
2980 /// to compose the request `Cookie` header, and `Set-Cookie` from the
2981 /// response is folded back in. `None` when the builder was constructed
2982 /// without a client (not reachable through the public API today).
2983 cookie_jar: Option<CookieJar>,
2984 /// The originating client's clock, used to evaluate `Expires` when folding
2985 /// `Set-Cookie` back into the jar. `None` falls back to [`chrono::Utc::now`].
2986 clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
2987 /// Default N+1 detection threshold (`dev.inspector_n_plus_one_threshold`),
2988 /// propagated to the resulting [`TestResponse`] so
2989 /// [`TestResponse::assert_no_n_plus_one`] can honour the app's config.
2990 n_plus_one_threshold: usize,
2991}
2992
2993impl RequestBuilder {
2994 fn new(
2995 router: axum::Router,
2996 method: Method,
2997 uri: &str,
2998 cookie_jar: CookieJar,
2999 clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
3000 n_plus_one_threshold: usize,
3001 ) -> Self {
3002 Self {
3003 router,
3004 method,
3005 uri: uri.to_owned(),
3006 headers: Vec::new(),
3007 body: Body::empty(),
3008 cookie_jar: Some(cookie_jar),
3009 clock,
3010 n_plus_one_threshold,
3011 }
3012 }
3013
3014 /// Add a header to the request.
3015 #[must_use]
3016 pub fn header(mut self, name: &str, value: &str) -> Self {
3017 self.headers.push((name.to_owned(), value.to_owned()));
3018 self
3019 }
3020
3021 /// Set the request body to a JSON-serialized value.
3022 ///
3023 /// Automatically sets `Content-Type: application/json`.
3024 #[must_use]
3025 pub fn json(mut self, value: &serde_json::Value) -> Self {
3026 self.headers
3027 .push(("content-type".to_owned(), "application/json".to_owned()));
3028 self.body = Body::from(serde_json::to_vec(value).expect("failed to serialize JSON body"));
3029 self
3030 }
3031
3032 /// Set the request body to URL-encoded form data.
3033 ///
3034 /// Automatically sets `Content-Type: application/x-www-form-urlencoded`
3035 /// and `Sec-Fetch-Site: same-origin` to mirror what a real browser
3036 /// would send for a same-origin `<form method="post">` — which is
3037 /// what the method-override middleware requires to honour
3038 /// `_method=PUT|PATCH|DELETE` overrides.
3039 #[must_use]
3040 pub fn form(mut self, body: &str) -> Self {
3041 self.headers.push((
3042 "content-type".to_owned(),
3043 "application/x-www-form-urlencoded".to_owned(),
3044 ));
3045 self.headers
3046 .push(("sec-fetch-site".to_owned(), "same-origin".to_owned()));
3047 self.body = Body::from(body.to_owned());
3048 self
3049 }
3050
3051 /// Set a raw string body.
3052 #[must_use]
3053 pub fn body(mut self, body: impl Into<Body>) -> Self {
3054 self.body = body.into();
3055 self
3056 }
3057
3058 /// Fire the request through the full middleware pipeline and return
3059 /// a [`TestResponse`].
3060 pub async fn send(self) -> TestResponse {
3061 // Captured for failure messages and the N+1 default threshold on the
3062 // resulting `TestResponse`.
3063 let request_method = self.method.to_string();
3064 let request_path = self.uri.clone();
3065 let n_plus_one_threshold = self.n_plus_one_threshold;
3066
3067 let mut builder = Request::builder().method(self.method).uri(&self.uri);
3068
3069 // Replay the cookie jar: compose a `Cookie` header from stored cookies
3070 // unless the caller already set one explicitly (an explicit header
3071 // wins, so tests can still exercise raw cookie behavior).
3072 let caller_set_cookie = self
3073 .headers
3074 .iter()
3075 .any(|(name, _)| name.eq_ignore_ascii_case("cookie"));
3076 if !caller_set_cookie && let Some(jar) = &self.cookie_jar {
3077 // Evaluate expiry against the same clock the jar folds `Set-Cookie`
3078 // with, so a virtual-clock test sees cookies stop replaying once it
3079 // advances past their `Max-Age`/`Expires`. Prune expired entries in
3080 // passing so they don't linger.
3081 let now = self
3082 .clock
3083 .as_ref()
3084 .map_or_else(chrono::Utc::now, |c| c.now());
3085 let cookie_header = {
3086 let mut jar = jar.lock().expect("cookie jar mutex poisoned");
3087 jar.retain(|_, cookie| cookie.expires_at.is_none_or(|t| t > now));
3088 jar.iter()
3089 .map(|(name, cookie)| format!("{name}={}", cookie.value))
3090 .collect::<Vec<_>>()
3091 .join("; ")
3092 };
3093 if !cookie_header.is_empty() {
3094 builder = builder.header(http::header::COOKIE, cookie_header);
3095 }
3096 }
3097
3098 for (name, value) in &self.headers {
3099 builder = builder.header(name.as_str(), value.as_str());
3100 }
3101
3102 let request = builder.body(self.body).expect("failed to build request");
3103
3104 // Wrap the router with MethodOverrideLayer the same way the production
3105 // serve site does, so a POST with a `_method=DELETE` form field reaches
3106 // the declared DELETE handler in tests too. The layer is a no-op for
3107 // non-POST methods and non-form bodies, so it's safe to apply
3108 // unconditionally.
3109 let service =
3110 tower::Layer::layer(&crate::middleware::MethodOverrideLayer::new(), self.router);
3111
3112 // Drive the request under a per-request `REQUEST_QUERY_CAPTURE` scope so
3113 // the connection-level `RequestQueryTimer` (installed at `Db::checkout`
3114 // whenever this capture lane is active) records every SQL statement the
3115 // handler issues into the capture sink — no manual `DbInterceptor`
3116 // wiring required. This lane is independent of the `Server-Timing`
3117 // timing accumulator (`REQUEST_DB_TIMINGS`), so query capture is
3118 // unaffected by however `ServerTimingLayer` scopes (and nests) its
3119 // per-scope DB metrics. `oneshot` runs on this same task, so the
3120 // task-local is visible to the checkout. When the `db` feature is off
3121 // there is no DB, so the captured query list is simply empty.
3122 //
3123 // The response body is drained (`to_bytes`) *inside* the scope so that
3124 // handlers returning a lazy or streaming body (`Sse`, `Body::from_stream`,
3125 // …) which perform DB work when the stream is polled still record those
3126 // body-time checkouts into the capture sink. The sink is read only after
3127 // the body is fully collected, so nothing is missed.
3128 #[cfg(feature = "db")]
3129 let (status, headers, body_bytes, queries) = {
3130 let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3131 let (status, headers, body_bytes) = crate::db::REQUEST_QUERY_CAPTURE
3132 .scope(std::sync::Arc::clone(&capture), async move {
3133 let response = service.oneshot(request).await.expect("request failed");
3134 let status = response.status();
3135 let headers: Vec<(String, String)> = response
3136 .headers()
3137 .iter()
3138 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_owned()))
3139 .collect();
3140 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
3141 .await
3142 .expect("failed to read response body");
3143 (status, headers, body_bytes)
3144 })
3145 .await;
3146 let queries = capture.lock().map(|v| v.clone()).unwrap_or_default();
3147 (status, headers, body_bytes, queries)
3148 };
3149 #[cfg(not(feature = "db"))]
3150 let (status, headers, body_bytes, queries): (
3151 _,
3152 _,
3153 _,
3154 Vec<crate::inspector::QueryRecord>,
3155 ) = {
3156 let response = service.oneshot(request).await.expect("request failed");
3157 let status = response.status();
3158 let headers: Vec<(String, String)> = response
3159 .headers()
3160 .iter()
3161 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_owned()))
3162 .collect();
3163 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
3164 .await
3165 .expect("failed to read response body");
3166 (status, headers, body_bytes, Vec::new())
3167 };
3168
3169 // Fold every `Set-Cookie` from the response back into the jar so the
3170 // next request from the same client replays it. Cookies whose
3171 // attributes mark them for deletion (`Max-Age=0` or a past `Expires`)
3172 // are removed instead of stored.
3173 if let Some(jar) = &self.cookie_jar {
3174 let now = self
3175 .clock
3176 .as_ref()
3177 .map_or_else(chrono::Utc::now, |c| c.now());
3178 let mut jar = jar.lock().expect("cookie jar mutex poisoned");
3179 for (name, value) in &headers {
3180 if name.eq_ignore_ascii_case("set-cookie") {
3181 apply_set_cookie(&mut jar, value, now);
3182 }
3183 }
3184 }
3185
3186 TestResponse {
3187 status,
3188 headers,
3189 body: body_bytes.to_vec(),
3190 queries,
3191 request_method,
3192 request_path,
3193 n_plus_one_threshold,
3194 }
3195 }
3196}
3197
3198// ── TestResponse ───────────────────────────────────────────────
3199
3200/// HTTP response from a test request with fluent assertion helpers.
3201///
3202/// All assertion methods return `&Self` for chaining:
3203///
3204/// ```rust,ignore
3205/// client.get("/users/1").send().await
3206/// .assert_ok()
3207/// .assert_header("content-type", "application/json")
3208/// .assert_body_contains("Alice");
3209/// ```
3210///
3211/// The `status`, `headers`, and `body` fields are public so you can construct a
3212/// `TestResponse` directly in unit tests that don't need a full HTTP
3213/// round-trip. Fill the remaining (query-capture) fields with
3214/// `..Default::default()`:
3215///
3216/// ```rust
3217/// use autumn_web::test::TestResponse;
3218/// use axum::http::StatusCode;
3219///
3220/// let resp = TestResponse {
3221/// status: StatusCode::OK,
3222/// headers: vec![
3223/// ("content-type".into(), "application/json".into()),
3224/// ("x-request-id".into(), "abc-123".into()),
3225/// ],
3226/// body: br#"{"name":"Alice"}"#.to_vec(),
3227/// ..Default::default()
3228/// };
3229///
3230/// resp.assert_ok()
3231/// .assert_header_contains("content-type", "json")
3232/// .assert_body_contains("Alice");
3233///
3234/// assert_eq!(resp.header("x-request-id"), Some("abc-123"));
3235/// ```
3236///
3237/// # Query-count and N+1 assertions
3238///
3239/// When the response was produced by [`RequestBuilder::send`] against a
3240/// database-backed app, every SQL statement the handler issued is captured
3241/// automatically (no manual interceptor wiring). Assert on it with
3242/// [`TestResponse::query_count`], [`TestResponse::assert_max_queries`], and
3243/// [`TestResponse::assert_no_n_plus_one`]:
3244///
3245/// ```rust,no_run
3246/// # async fn ex(client: autumn_web::test::TestClient) {
3247/// client.get("/posts").send().await
3248/// .assert_ok()
3249/// .assert_max_queries(3) // fails, naming GET /posts, if > 3 queries ran
3250/// .assert_no_n_plus_one(); // fails if a query repeats >= the dev threshold
3251/// # }
3252/// ```
3253pub struct TestResponse {
3254 /// HTTP status code.
3255 pub status: StatusCode,
3256 /// Response headers as `(name, value)` pairs.
3257 pub headers: Vec<(String, String)>,
3258 /// Raw response body bytes.
3259 pub body: Vec<u8>,
3260 /// SQL queries captured while handling the request, in execution order.
3261 ///
3262 /// Populated automatically by [`RequestBuilder::send`] for
3263 /// database-backed apps; empty for directly-constructed responses or when
3264 /// the `db` feature is disabled. Prefer the [`TestResponse::queries`]
3265 /// accessor for reading.
3266 pub queries: Vec<crate::inspector::QueryRecord>,
3267 /// HTTP method of the originating request, for assertion failure messages.
3268 pub request_method: String,
3269 /// Path of the originating request, for assertion failure messages.
3270 pub request_path: String,
3271 /// Default N+1 threshold (`dev.inspector_n_plus_one_threshold`) used by
3272 /// [`TestResponse::assert_no_n_plus_one`].
3273 pub n_plus_one_threshold: usize,
3274}
3275
3276impl Default for TestResponse {
3277 fn default() -> Self {
3278 Self {
3279 status: StatusCode::OK,
3280 headers: Vec::new(),
3281 body: Vec::new(),
3282 queries: Vec::new(),
3283 request_method: String::new(),
3284 request_path: String::new(),
3285 // Inherit the detector's default (5) — not a zero-filled `0`, which
3286 // `inspector::detect_n_plus_one` treats as DISABLED — so the
3287 // documented `TestResponse { .. ..Default::default() }` construction
3288 // still catches N+1 patterns.
3289 n_plus_one_threshold: crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD,
3290 }
3291 }
3292}
3293
3294impl TestResponse {
3295 /// Get the response body as a UTF-8 string.
3296 ///
3297 /// # Panics
3298 ///
3299 /// Panics if the body is not valid UTF-8.
3300 #[must_use]
3301 pub fn text(&self) -> String {
3302 String::from_utf8(self.body.clone()).unwrap_or_else(|e| {
3303 panic!(
3304 "response body is not valid UTF-8: {e}\nRaw bytes: {:?}",
3305 self.body
3306 )
3307 })
3308 }
3309
3310 /// Deserialize the response body as JSON.
3311 ///
3312 /// # Panics
3313 ///
3314 /// Panics if the body is not valid JSON or cannot be deserialized
3315 /// into `T`.
3316 #[must_use]
3317 pub fn json<T: serde::de::DeserializeOwned>(&self) -> T {
3318 serde_json::from_slice(&self.body).unwrap_or_else(|e| {
3319 panic!(
3320 "failed to parse response body as JSON: {e}\nBody: {}",
3321 String::from_utf8_lossy(&self.body)
3322 )
3323 })
3324 }
3325
3326 /// Get the value of a response header.
3327 #[must_use]
3328 pub fn header(&self, name: &str) -> Option<&str> {
3329 let name_lower = name.to_lowercase();
3330 self.headers
3331 .iter()
3332 .find(|(k, _)| k.to_lowercase() == name_lower)
3333 .map(|(_, v)| v.as_str())
3334 }
3335
3336 // ── Assertion helpers ──────────────────────────────────────
3337
3338 /// Assert the response status is 200 OK.
3339 #[track_caller]
3340 pub fn assert_ok(&self) -> &Self {
3341 assert_eq!(
3342 self.status,
3343 StatusCode::OK,
3344 "expected 200 OK, got {}.\nBody: {}",
3345 self.status,
3346 String::from_utf8_lossy(&self.body)
3347 );
3348 self
3349 }
3350
3351 /// Assert the response status matches the given code.
3352 #[track_caller]
3353 pub fn assert_status(&self, expected: u16) -> &Self {
3354 assert_eq!(
3355 self.status.as_u16(),
3356 expected,
3357 "expected status {expected}, got {}.\nBody: {}",
3358 self.status,
3359 String::from_utf8_lossy(&self.body)
3360 );
3361 self
3362 }
3363
3364 /// Assert the response status indicates a successful request (2xx).
3365 #[track_caller]
3366 pub fn assert_success(&self) -> &Self {
3367 assert!(
3368 self.status.is_success(),
3369 "expected 2xx success, got {}.\nBody: {}",
3370 self.status,
3371 String::from_utf8_lossy(&self.body)
3372 );
3373 self
3374 }
3375
3376 /// Assert a response header exists and equals the expected value.
3377 #[track_caller]
3378 pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
3379 let value = self.header(name).unwrap_or_else(|| {
3380 panic!(
3381 "expected header `{name}` to be present.\nAvailable headers: {:?}",
3382 self.headers
3383 )
3384 });
3385 assert_eq!(
3386 value, expected,
3387 "header `{name}`: expected `{expected}`, got `{value}`"
3388 );
3389 self
3390 }
3391
3392 /// Assert a response header exists and contains the expected substring.
3393 #[track_caller]
3394 pub fn assert_header_contains(&self, name: &str, substring: &str) -> &Self {
3395 let value = self.header(name).unwrap_or_else(|| {
3396 panic!(
3397 "expected header `{name}` to be present.\nAvailable headers: {:?}",
3398 self.headers
3399 )
3400 });
3401 assert!(
3402 value.contains(substring),
3403 "header `{name}`: expected `{value}` to contain `{substring}`"
3404 );
3405 self
3406 }
3407
3408 /// Assert the response body contains the given substring.
3409 #[track_caller]
3410 pub fn assert_body_contains(&self, substring: &str) -> &Self {
3411 let body = self.text();
3412 assert!(
3413 body.contains(substring),
3414 "expected body to contain `{substring}`.\nBody: {body}"
3415 );
3416 self
3417 }
3418
3419 /// Assert the response body exactly equals the given string.
3420 #[track_caller]
3421 pub fn assert_body_eq(&self, expected: &str) -> &Self {
3422 let body = self.text();
3423 assert_eq!(body, expected, "body mismatch.\nActual Body: {body}");
3424 self
3425 }
3426
3427 /// Assert the response body deserializes to JSON matching the predicate.
3428 #[track_caller]
3429 pub fn assert_json<T, F>(&self, predicate: F) -> &Self
3430 where
3431 T: serde::de::DeserializeOwned,
3432 F: FnOnce(&T),
3433 {
3434 let value: T = self.json();
3435 predicate(&value);
3436 self
3437 }
3438
3439 /// Assert the response body is empty.
3440 #[track_caller]
3441 pub fn assert_body_empty(&self) -> &Self {
3442 assert!(
3443 self.body.is_empty(),
3444 "expected empty body, got {} bytes: {}",
3445 self.body.len(),
3446 String::from_utf8_lossy(&self.body)
3447 );
3448 self
3449 }
3450
3451 // ── CSS-selector HTML assertions ────────────────────────────
3452 //
3453 // Autumn renders server-side HTML (Maud + htmx), so tests want to assert on
3454 // page *structure* — "the table has exactly 3 rows", "there is a `<form>`
3455 // posting to `/notes`" — rather than brittle substrings. These helpers parse
3456 // the body with a real HTML parser and match against a CSS-selector subset
3457 // (tag, `.class`, `#id`, `[attr=…]`, plus descendant/child combinators), so
3458 // assertions survive cosmetic template changes (whitespace, attribute order,
3459 // wrapping markup) that would break [`assert_body_contains`].
3460 //
3461 // They work for full documents and for partial/fragment responses (htmx
3462 // swaps) alike, and compose with the other matchers — every method returns
3463 // `&Self` for chaining.
3464 //
3465 // ```rust,ignore
3466 // client.get("/notes").send().await
3467 // .assert_ok()
3468 // .assert_selector_count("tbody tr.note-row", 3) // exactly 3 rows
3469 // .assert_attr("tr.note-row:first-child a", "href", "/notes/1")
3470 // .assert_text("h1", "Notes");
3471 // ```
3472
3473 /// Parse the response body as HTML once for a selector assertion.
3474 fn parse_html(&self) -> Vec<crate::test_html::Node> {
3475 crate::test_html::parse(&self.text())
3476 }
3477
3478 /// Compile a CSS selector, panicking with an actionable message on a
3479 /// malformed selector.
3480 #[track_caller]
3481 fn compile_selector(css: &str) -> crate::test_html::SelectorList {
3482 crate::test_html::SelectorList::parse(css)
3483 .unwrap_or_else(|e| panic!("invalid CSS selector `{css}`: {e}"))
3484 }
3485
3486 /// A truncated, indented outline of the parsed HTML for failure messages.
3487 fn html_outline(nodes: &[crate::test_html::Node]) -> String {
3488 crate::test_html::outline(nodes, 1200)
3489 }
3490
3491 /// Return the normalized text content of every element matching `css`, in
3492 /// document order. Non-asserting accessor for custom assertions.
3493 ///
3494 /// Whitespace within each element's text is collapsed and trimmed so values
3495 /// are stable across indentation and line-wrapping changes.
3496 #[must_use]
3497 #[track_caller]
3498 pub fn selector_text(&self, css: &str) -> Vec<String> {
3499 let selector = Self::compile_selector(css);
3500 let nodes = self.parse_html();
3501 selector
3502 .matches(&nodes)
3503 .iter()
3504 .map(|el| crate::test_html::normalize_ws(&el.text()))
3505 .collect()
3506 }
3507
3508 /// Return the value of attribute `attr` for every element matching `css`,
3509 /// in document order (`None` for matches lacking the attribute).
3510 /// Non-asserting accessor for custom assertions.
3511 #[must_use]
3512 #[track_caller]
3513 pub fn selector_attr(&self, css: &str, attr: &str) -> Vec<Option<String>> {
3514 let selector = Self::compile_selector(css);
3515 let nodes = self.parse_html();
3516 selector
3517 .matches(&nodes)
3518 .iter()
3519 .map(|el| el.attr(attr).map(str::to_string))
3520 .collect()
3521 }
3522
3523 /// Return the number of elements matching `css`. Non-asserting accessor.
3524 #[must_use]
3525 #[track_caller]
3526 pub fn selector_count(&self, css: &str) -> usize {
3527 let selector = Self::compile_selector(css);
3528 let nodes = self.parse_html();
3529 selector.matches(&nodes).len()
3530 }
3531
3532 /// Assert at least one element matches the CSS selector.
3533 #[track_caller]
3534 pub fn assert_selector(&self, css: &str) -> &Self {
3535 let selector = Self::compile_selector(css);
3536 let nodes = self.parse_html();
3537 let count = selector.matches(&nodes).len();
3538 assert!(
3539 count > 0,
3540 "no elements matched selector `{css}`.\nParsed HTML:\n{}",
3541 Self::html_outline(&nodes)
3542 );
3543 self
3544 }
3545
3546 /// Assert that *no* element matches the CSS selector.
3547 #[track_caller]
3548 pub fn assert_no_selector(&self, css: &str) -> &Self {
3549 let selector = Self::compile_selector(css);
3550 let nodes = self.parse_html();
3551 let count = selector.matches(&nodes).len();
3552 assert!(
3553 count == 0,
3554 "expected no elements matching selector `{css}`, but found {count}.\nParsed HTML:\n{}",
3555 Self::html_outline(&nodes)
3556 );
3557 self
3558 }
3559
3560 /// Assert exactly `expected` elements match the CSS selector.
3561 #[track_caller]
3562 pub fn assert_selector_count(&self, css: &str, expected: usize) -> &Self {
3563 let selector = Self::compile_selector(css);
3564 let nodes = self.parse_html();
3565 let actual = selector.matches(&nodes).len();
3566 assert!(
3567 actual == expected,
3568 "expected {expected} element(s) matching selector `{css}`, found {actual}.\n\
3569 Parsed HTML:\n{}",
3570 Self::html_outline(&nodes)
3571 );
3572 self
3573 }
3574
3575 /// Assert the first element matching `css` has text content equal to
3576 /// `expected` (whitespace-normalized on both sides).
3577 #[track_caller]
3578 pub fn assert_text(&self, css: &str, expected: &str) -> &Self {
3579 let selector = Self::compile_selector(css);
3580 let nodes = self.parse_html();
3581 let matched = selector.matches(&nodes);
3582 let Some(first) = matched.into_iter().next() else {
3583 panic!(
3584 "no elements matched selector `{css}`.\nParsed HTML:\n{}",
3585 Self::html_outline(&nodes)
3586 );
3587 };
3588 let actual = crate::test_html::normalize_ws(&first.text());
3589 let expected_norm = crate::test_html::normalize_ws(expected);
3590 assert!(
3591 actual == expected_norm,
3592 "text mismatch for selector `{css}`:\n expected: {expected_norm:?}\n \
3593 actual: {actual:?}\nParsed HTML:\n{}",
3594 Self::html_outline(&nodes)
3595 );
3596 self
3597 }
3598
3599 /// Assert the first element matching `css` has text content containing
3600 /// `substring` (whitespace-normalized on both sides).
3601 #[track_caller]
3602 pub fn assert_text_contains(&self, css: &str, substring: &str) -> &Self {
3603 let selector = Self::compile_selector(css);
3604 let nodes = self.parse_html();
3605 let matched = selector.matches(&nodes);
3606 let Some(first) = matched.into_iter().next() else {
3607 panic!(
3608 "no elements matched selector `{css}`.\nParsed HTML:\n{}",
3609 Self::html_outline(&nodes)
3610 );
3611 };
3612 let actual = crate::test_html::normalize_ws(&first.text());
3613 let needle = crate::test_html::normalize_ws(substring);
3614 assert!(
3615 actual.contains(&needle),
3616 "text for selector `{css}` did not contain {needle:?}.\n actual: {actual:?}\n\
3617 Parsed HTML:\n{}",
3618 Self::html_outline(&nodes)
3619 );
3620 self
3621 }
3622
3623 /// Assert the first element matching `css` has attribute `attr` equal to
3624 /// `expected`.
3625 #[track_caller]
3626 pub fn assert_attr(&self, css: &str, attr: &str, expected: &str) -> &Self {
3627 let selector = Self::compile_selector(css);
3628 let nodes = self.parse_html();
3629 let matched = selector.matches(&nodes);
3630 let Some(first) = matched.into_iter().next() else {
3631 panic!(
3632 "no elements matched selector `{css}`.\nParsed HTML:\n{}",
3633 Self::html_outline(&nodes)
3634 );
3635 };
3636 match first.attr(attr) {
3637 Some(actual) => assert!(
3638 actual == expected,
3639 "attribute `{attr}` mismatch for selector `{css}`:\n expected: {expected:?}\n \
3640 actual: {actual:?}\nParsed HTML:\n{}",
3641 Self::html_outline(&nodes)
3642 ),
3643 None => panic!(
3644 "element matching selector `{css}` has no `{attr}` attribute.\n\
3645 Parsed HTML:\n{}",
3646 Self::html_outline(&nodes)
3647 ),
3648 }
3649 self
3650 }
3651
3652 // ── Database query assertions (#1262) ──────────────────────
3653
3654 /// Number of SQL queries the request issued.
3655 ///
3656 /// Captured automatically by [`RequestBuilder::send`] for database-backed
3657 /// apps; `0` for directly-constructed responses or when the `db` feature
3658 /// is disabled.
3659 #[must_use]
3660 pub const fn query_count(&self) -> usize {
3661 self.queries.len()
3662 }
3663
3664 /// The SQL queries the request issued, in execution order.
3665 ///
3666 /// Lets a test assert on specific normalized SQL. Empty for
3667 /// directly-constructed responses or when the `db` feature is disabled.
3668 #[must_use]
3669 pub fn queries(&self) -> &[crate::inspector::QueryRecord] {
3670 &self.queries
3671 }
3672
3673 /// A per-query listing for assertion failure messages: one line per query
3674 /// (`#N <elapsed>ms <sql>`), followed by repetition counts per
3675 /// normalized statement so the offending pattern is obvious.
3676 fn query_report(&self) -> String {
3677 use std::collections::BTreeMap;
3678 use std::fmt::Write as _;
3679 let mut out = String::new();
3680 for (i, q) in self.queries.iter().enumerate() {
3681 let _ = write!(
3682 out,
3683 "\n #{n:<3} {ms:>4}ms {sql}",
3684 n = i + 1,
3685 ms = q.elapsed_ms,
3686 sql = q.sql,
3687 );
3688 }
3689 // Counts per normalized statement (stable, sorted for determinism).
3690 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
3691 for q in &self.queries {
3692 *counts
3693 .entry(q.sql.split_whitespace().collect::<Vec<_>>().join(" "))
3694 .or_insert(0) += 1;
3695 }
3696 if counts.len() != self.queries.len() {
3697 out.push_str("\n ── counts per statement ──");
3698 for (sql, count) in &counts {
3699 let _ = write!(out, "\n {count}x {sql}");
3700 }
3701 }
3702 out
3703 }
3704
3705 /// Assert the request issued at most `n` SQL queries.
3706 ///
3707 /// Passes when `query_count() <= n`. Panics otherwise with a message
3708 /// naming the request (method + path), the expected and actual counts, and
3709 /// the full query list.
3710 #[track_caller]
3711 pub fn assert_max_queries(&self, n: usize) -> &Self {
3712 let actual = self.queries.len();
3713 assert!(
3714 actual <= n,
3715 "assert_max_queries failed for {method} {path}: expected <= {n} queries, issued {actual}.{report}",
3716 method = self.request_method,
3717 path = self.request_path,
3718 report = self.query_report(),
3719 );
3720 self
3721 }
3722
3723 /// Assert the request contains no N+1 query pattern, using the app's
3724 /// configured `dev.inspector_n_plus_one_threshold` (default 5).
3725 ///
3726 /// Reuses [`crate::inspector::detect_n_plus_one`]. Panics, naming the
3727 /// request and the offending normalized query + repetition count, when a
3728 /// single normalized statement was issued at least `threshold` times.
3729 ///
3730 /// Use [`TestResponse::assert_no_n_plus_one_with_threshold`] to override
3731 /// the threshold explicitly.
3732 #[track_caller]
3733 pub fn assert_no_n_plus_one(&self) -> &Self {
3734 self.assert_no_n_plus_one_with_threshold(self.n_plus_one_threshold)
3735 }
3736
3737 /// Like [`TestResponse::assert_no_n_plus_one`] but with an explicit
3738 /// repetition `threshold` instead of the configured default.
3739 #[track_caller]
3740 pub fn assert_no_n_plus_one_with_threshold(&self, threshold: usize) -> &Self {
3741 match crate::inspector::detect_n_plus_one(&self.queries, threshold) {
3742 Some(w) => panic!(
3743 "assert_no_n_plus_one failed for {method} {path}: query repeated {count} times \
3744 (threshold {threshold}):\n {sql}{report}",
3745 method = self.request_method,
3746 path = self.request_path,
3747 count = w.count,
3748 sql = w.sql_template,
3749 report = self.query_report(),
3750 ),
3751 None => self,
3752 }
3753 }
3754}
3755
3756// Constructed only by the Postgres transactional test-isolation establish path,
3757// which is cfg'd out under the `sqlite` feature — so gate these out too.
3758#[cfg(all(feature = "db", not(feature = "sqlite")))]
3759struct TransactionalDbInterceptor;
3760
3761#[cfg(all(feature = "db", not(feature = "sqlite")))]
3762impl crate::interceptor::DbConnectionInterceptor for TransactionalDbInterceptor {
3763 fn intercept_checkout<'a>(
3764 &'a self,
3765 _ctx: crate::interceptor::DbCheckoutContext,
3766 next: std::pin::Pin<
3767 Box<
3768 dyn std::future::Future<
3769 Output = Result<crate::db::PooledConnection, crate::AutumnError>,
3770 > + Send
3771 + 'a,
3772 >,
3773 >,
3774 ) -> std::pin::Pin<
3775 Box<
3776 dyn std::future::Future<
3777 Output = Result<crate::db::PooledConnection, crate::AutumnError>,
3778 > + Send
3779 + 'a,
3780 >,
3781 > {
3782 Box::pin(async move {
3783 let mut conn = next.await?;
3784
3785 // Check if transaction has already been started on this connection
3786 let guc_result = diesel::select(diesel::dsl::sql::<
3787 diesel::sql_types::Nullable<diesel::sql_types::Text>,
3788 >(
3789 "current_setting('autumn.test_transaction_started', true)",
3790 ))
3791 .get_result::<Option<String>>(&mut *conn)
3792 .await;
3793
3794 match guc_result {
3795 Ok(Some(ref s)) if s == "true" => {
3796 // Already started and healthy
3797 }
3798 Ok(_) => {
3799 use diesel_async::AsyncConnection;
3800 use diesel_async::RunQueryDsl;
3801
3802 conn.begin_test_transaction().await.map_err(|e| {
3803 crate::AutumnError::internal_server_error_msg(format!(
3804 "failed to start test transaction: {e}"
3805 ))
3806 })?;
3807
3808 diesel::sql_query("SET autumn.test_transaction_started = 'true'")
3809 .execute(&mut *conn)
3810 .await
3811 .map_err(|e| {
3812 crate::AutumnError::internal_server_error_msg(format!(
3813 "failed to set transaction session GUC: {e}"
3814 ))
3815 })?;
3816 }
3817 Err(_) => {
3818 // The GUC query failed. This happens when the connection is in a failed/aborted transaction block.
3819 // Since the transaction is already active (but aborted), do not retry begin_test_transaction!
3820 }
3821 }
3822 Ok(conn)
3823 })
3824 }
3825
3826 fn is_transactional_test(&self) -> bool {
3827 true
3828 }
3829}
3830
3831// See `TransactionalDbInterceptor`: only the Postgres transactional establish
3832// path composes interceptors, so this is dead under the `sqlite` feature.
3833#[cfg(all(feature = "db", not(feature = "sqlite")))]
3834struct ComposedDbInterceptor {
3835 first: std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>,
3836 second: std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>,
3837}
3838
3839#[cfg(all(feature = "db", not(feature = "sqlite")))]
3840impl crate::interceptor::DbConnectionInterceptor for ComposedDbInterceptor {
3841 fn intercept_checkout<'a>(
3842 &'a self,
3843 ctx: crate::interceptor::DbCheckoutContext,
3844 next: std::pin::Pin<
3845 Box<
3846 dyn std::future::Future<
3847 Output = Result<crate::db::PooledConnection, crate::AutumnError>,
3848 > + Send
3849 + 'a,
3850 >,
3851 >,
3852 ) -> std::pin::Pin<
3853 Box<
3854 dyn std::future::Future<
3855 Output = Result<crate::db::PooledConnection, crate::AutumnError>,
3856 > + Send
3857 + 'a,
3858 >,
3859 > {
3860 let next_wrapped = self.second.intercept_checkout(ctx.clone(), next);
3861 self.first.intercept_checkout(ctx, next_wrapped)
3862 }
3863
3864 fn is_transactional_test(&self) -> bool {
3865 self.first.is_transactional_test() || self.second.is_transactional_test()
3866 }
3867}
3868
3869// ── TestDb ─────────────────────────────────────────────────────
3870
3871/// Shared Postgres testcontainer for database integration tests.
3872///
3873/// Rather than spinning up a new container per test (slow!), `TestDb`
3874/// provides a shared container that all tests in a binary can reuse.
3875/// This mirrors Spring Boot's `@Testcontainers` with `@Container` +
3876/// `static` pattern.
3877///
3878/// Requires the `test-support` feature (and `db`):
3879///
3880/// ```toml
3881/// [dev-dependencies]
3882/// autumn-web = { path = "..", features = ["test-support"] }
3883/// ```
3884///
3885/// # Examples
3886///
3887/// ```rust,ignore
3888/// use autumn_web::test::{TestApp, TestDb};
3889///
3890/// #[tokio::test]
3891/// #[ignore = "requires Docker"]
3892/// async fn db_test() {
3893/// let db = TestDb::shared().await;
3894/// let client = TestApp::new()
3895/// .routes(routes![my_handler])
3896/// .with_db(db.pool())
3897/// .build();
3898///
3899/// // Run migrations or seed data via db.pool()
3900/// client.get("/data").send().await.assert_ok();
3901/// }
3902/// ```
3903#[cfg(all(feature = "db", feature = "test-support"))]
3904pub struct TestDb {
3905 _container: testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>,
3906 pool: Pool<AsyncPgConnection>,
3907 url: String,
3908}
3909
3910#[cfg(all(feature = "db", feature = "test-support"))]
3911impl TestDb {
3912 /// Start a new Postgres testcontainer and create a connection pool.
3913 ///
3914 /// For most test suites, prefer [`TestDb::shared()`] to reuse a
3915 /// single container across all tests.
3916 pub async fn new() -> Self {
3917 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
3918 use testcontainers::runners::AsyncRunner;
3919 use testcontainers_modules::postgres::Postgres;
3920
3921 let container = Postgres::default()
3922 .start()
3923 .await
3924 .expect("failed to start Postgres testcontainer (is Docker running?)");
3925
3926 let host = container
3927 .get_host()
3928 .await
3929 .expect("failed to build test router");
3930 let port = container
3931 .get_host_port_ipv4(5432)
3932 .await
3933 .expect("failed to build test router");
3934 let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
3935
3936 let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(&url);
3937 let pool = Pool::builder(manager)
3938 .max_size(5)
3939 .build()
3940 .expect("failed to build connection pool");
3941
3942 Self {
3943 _container: container,
3944 pool,
3945 url,
3946 }
3947 }
3948
3949 /// Get a shared `TestDb` instance, starting the container on first use.
3950 ///
3951 /// Uses a process-global `OnceLock` so the container is started only
3952 /// once per test binary, regardless of how many tests call this method.
3953 /// This dramatically speeds up test suites with multiple DB tests.
3954 ///
3955 /// The container is automatically cleaned up when the process exits.
3956 pub async fn shared() -> &'static Self {
3957 use std::sync::OnceLock;
3958 use tokio::sync::OnceCell;
3959
3960 // Two-phase init: OnceLock for the OnceCell, OnceCell for the async init.
3961 static CELL: OnceLock<OnceCell<TestDb>> = OnceLock::new();
3962 let once = CELL.get_or_init(OnceCell::new);
3963 once.get_or_init(Self::new).await
3964 }
3965
3966 /// Get the database connection pool.
3967 #[must_use]
3968 pub fn pool(&self) -> Pool<AsyncPgConnection> {
3969 self.pool.clone()
3970 }
3971
3972 /// Get the Postgres connection URL.
3973 #[must_use]
3974 pub fn url(&self) -> &str {
3975 &self.url
3976 }
3977
3978 /// Execute raw SQL against the test database.
3979 ///
3980 /// Useful for creating tables, seeding data, or running migrations
3981 /// in tests.
3982 ///
3983 /// # Examples
3984 ///
3985 /// ```rust,ignore
3986 /// let db = TestDb::shared().await;
3987 /// db.execute_sql("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)")
3988 /// .await;
3989 /// ```
3990 pub async fn execute_sql(&self, sql: &str) {
3991 use diesel_async::RunQueryDsl;
3992 let mut conn = self.pool.get().await.expect("failed to get connection");
3993 diesel::sql_query(sql)
3994 .execute(&mut *conn)
3995 .await
3996 .unwrap_or_else(|e| panic!("SQL execution failed: {e}\nSQL: {sql}"));
3997 }
3998}
3999
4000#[cfg(test)]
4001mod tests {
4002 use super::*;
4003
4004 fn cleanup_probe_job(
4005 _state: crate::state::AppState,
4006 _payload: serde_json::Value,
4007 ) -> std::pin::Pin<
4008 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'static>,
4009 > {
4010 Box::pin(async move { Ok(()) })
4011 }
4012
4013 struct CleanupJobPlugin;
4014
4015 impl crate::plugin::Plugin for CleanupJobPlugin {
4016 fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
4017 app.jobs(vec![crate::job::JobInfo {
4018 version: 1,
4019 name: "cleanup_probe".to_string(),
4020 max_attempts: 1,
4021 initial_backoff_ms: 1,
4022 queue: "default".to_string(),
4023 uniqueness: None,
4024 concurrency: None,
4025 handler: cleanup_probe_job,
4026 }])
4027 }
4028 }
4029
4030 fn test_routes() -> Vec<Route> {
4031 use axum::routing;
4032
4033 async fn hello() -> &'static str {
4034 "hello"
4035 }
4036
4037 async fn echo_json(
4038 axum::Json(value): axum::Json<serde_json::Value>,
4039 ) -> axum::Json<serde_json::Value> {
4040 axum::Json(value)
4041 }
4042
4043 async fn status_201() -> (StatusCode, &'static str) {
4044 (StatusCode::CREATED, "created")
4045 }
4046
4047 vec![
4048 Route {
4049 method: Method::GET,
4050 path: "/hello",
4051 handler: routing::get(hello),
4052 name: "hello",
4053 api_doc: crate::openapi::ApiDoc {
4054 method: "GET",
4055 path: "/hello",
4056 operation_id: "hello",
4057 success_status: 200,
4058 ..Default::default()
4059 },
4060 repository: None,
4061 idempotency: crate::route::RouteIdempotency::Direct,
4062 timeout: crate::route::RouteTimeout::Inherit,
4063 api_version: None,
4064 sunset_opt_out: false,
4065 },
4066 Route {
4067 method: Method::POST,
4068 path: "/echo",
4069 handler: routing::post(echo_json),
4070 name: "echo",
4071 api_doc: crate::openapi::ApiDoc {
4072 method: "POST",
4073 path: "/echo",
4074 operation_id: "echo",
4075 success_status: 200,
4076 ..Default::default()
4077 },
4078 repository: None,
4079 idempotency: crate::route::RouteIdempotency::Direct,
4080 timeout: crate::route::RouteTimeout::Inherit,
4081 api_version: None,
4082 sunset_opt_out: false,
4083 },
4084 Route {
4085 method: Method::POST,
4086 path: "/create",
4087 handler: routing::post(status_201),
4088 name: "create",
4089 api_doc: crate::openapi::ApiDoc {
4090 method: "POST",
4091 path: "/create",
4092 operation_id: "create",
4093 success_status: 201,
4094 ..Default::default()
4095 },
4096 repository: None,
4097 idempotency: crate::route::RouteIdempotency::Direct,
4098 timeout: crate::route::RouteTimeout::Inherit,
4099 api_version: None,
4100 sunset_opt_out: false,
4101 },
4102 ]
4103 }
4104
4105 #[tokio::test]
4106 async fn test_app_get_request() {
4107 let client = TestApp::new().routes(test_routes()).build();
4108 client.get("/hello").send().await.assert_ok();
4109 }
4110
4111 #[tokio::test]
4112 async fn test_app_post_json() {
4113 let client = TestApp::new().routes(test_routes()).build();
4114
4115 client
4116 .post("/echo")
4117 .json(&serde_json::json!({"key": "value"}))
4118 .send()
4119 .await
4120 .assert_ok()
4121 .assert_body_contains("key");
4122 }
4123
4124 #[tokio::test]
4125 async fn test_response_assert_status() {
4126 let client = TestApp::new().routes(test_routes()).build();
4127
4128 client
4129 .post("/create")
4130 .send()
4131 .await
4132 .assert_status(201)
4133 .assert_body_eq("created");
4134 }
4135
4136 #[tokio::test]
4137 async fn test_response_assert_success() {
4138 let client = TestApp::new().routes(test_routes()).build();
4139 client.get("/hello").send().await.assert_success();
4140 }
4141
4142 #[tokio::test]
4143 async fn test_not_found() {
4144 let client = TestApp::new().routes(test_routes()).build();
4145 client.get("/nonexistent").send().await.assert_status(404);
4146 }
4147
4148 #[tokio::test]
4149 async fn test_response_json_deserialization() {
4150 let client = TestApp::new().routes(test_routes()).build();
4151
4152 let resp = client
4153 .post("/echo")
4154 .json(&serde_json::json!({"count": 42}))
4155 .send()
4156 .await;
4157
4158 resp.assert_ok().assert_json::<serde_json::Value, _>(|v| {
4159 assert_eq!(v["count"], 42);
4160 });
4161 }
4162
4163 #[tokio::test]
4164 async fn test_custom_header() {
4165 let client = TestApp::new().routes(test_routes()).build();
4166
4167 let resp = client
4168 .get("/hello")
4169 .header("x-custom", "test-value")
4170 .send()
4171 .await;
4172 resp.assert_ok();
4173 }
4174
4175 #[tokio::test]
4176 async fn test_client_default() {
4177 let _app = TestApp::default();
4178 }
4179
4180 #[tokio::test]
4181 async fn dropping_test_client_stops_test_started_job_runtime() {
4182 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
4183 crate::job::clear_global_job_client();
4184
4185 let client = TestApp::new().plugin(CleanupJobPlugin).build();
4186 let leaked_client = crate::job::global_job_client().expect("test job runtime should start");
4187
4188 drop(client);
4189
4190 assert!(
4191 crate::job::global_job_client().is_none(),
4192 "dropping a TestClient with jobs must clear its global job client"
4193 );
4194
4195 let mut last_enqueue_error = None;
4196 for _ in 0..25 {
4197 match leaked_client
4198 .enqueue("cleanup_probe", serde_json::json!({}))
4199 .await
4200 {
4201 Ok(()) => tokio::time::sleep(std::time::Duration::from_millis(10)).await,
4202 Err(error) => {
4203 last_enqueue_error = Some(error.to_string());
4204 break;
4205 }
4206 }
4207 }
4208
4209 assert!(
4210 last_enqueue_error
4211 .as_deref()
4212 .is_some_and(|message| message.contains("failed to enqueue job")),
4213 "captured pre-drop job client must stop accepting jobs after TestClient drop; \
4214 last error: {last_enqueue_error:?}"
4215 );
4216
4217 crate::job::clear_global_job_client();
4218 }
4219
4220 #[cfg(feature = "mail")]
4221 #[test]
4222 fn plugin_suppression_store_and_endpoint_optin_carry_into_test_app() {
4223 struct SuppressionPlugin;
4224 impl crate::plugin::Plugin for SuppressionPlugin {
4225 fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
4226 app.with_suppression_store(crate::mail::InMemorySuppressionStore::new())
4227 .mount_unsubscribe_endpoint()
4228 }
4229 }
4230
4231 // A plugin that wires List-Unsubscribe storage and opts into the default
4232 // endpoint must propagate both into the TestApp, so unsubscribe POSTs /
4233 // send-time suppression behave under TestApp exactly as in production
4234 // without every test repeating the setup manually.
4235 let app = TestApp::new().plugin(SuppressionPlugin);
4236 assert!(
4237 app.suppression_store.is_some(),
4238 "plugin-registered suppression store must be carried into TestApp"
4239 );
4240 assert!(
4241 app.config.mail.mount_unsubscribe_endpoint,
4242 "plugin endpoint opt-in must be carried into TestApp config"
4243 );
4244 }
4245
4246 /// End-to-end acceptance for issue #605: a plain `<form method="post">`
4247 /// carrying `_method=DELETE` reaches the declared DELETE handler when
4248 /// dispatched through the same router/middleware stack the production
4249 /// app builder uses.
4250 #[tokio::test]
4251 async fn test_app_routes_html_method_override_to_delete() {
4252 use axum::routing;
4253 async fn deleted() -> &'static str {
4254 "deleted"
4255 }
4256 let routes = vec![Route {
4257 method: Method::DELETE,
4258 path: "/items/{id}",
4259 handler: routing::delete(deleted),
4260 name: "items_delete",
4261 api_doc: crate::openapi::ApiDoc {
4262 method: "DELETE",
4263 path: "/items/{id}",
4264 operation_id: "items_delete",
4265 success_status: 200,
4266 ..Default::default()
4267 },
4268 repository: None,
4269 idempotency: crate::route::RouteIdempotency::Direct,
4270 timeout: crate::route::RouteTimeout::Inherit,
4271 api_version: None,
4272 sunset_opt_out: false,
4273 }];
4274 let client = TestApp::new().routes(routes).build();
4275
4276 client
4277 .post("/items/1")
4278 .form("_method=DELETE")
4279 .send()
4280 .await
4281 .assert_ok()
4282 .assert_body_eq("deleted");
4283 }
4284
4285 // ── CSS-selector HTML assertions (issue #1147) ─────────────────────────
4286 //
4287 // These tests are the executable specification for the selector-aware
4288 // assertions on [`TestResponse`]. They exercise the success metric:
4289 // a structural assertion against a notes index survives a cosmetic
4290 // template refactor (indentation, attribute order, wrapping markup)
4291 // that would break the equivalent `assert_body_contains` substring test.
4292 #[cfg(feature = "maud")]
4293 mod html_assertions {
4294 use super::*;
4295 use axum::routing::get;
4296
4297 /// The "original" notes index: a 3-row table where each `<tr>` links
4298 /// to `/notes/{id}`.
4299 async fn notes_index_v1() -> maud::Markup {
4300 maud::html! {
4301 table.notes {
4302 tbody {
4303 @for id in 1..=3u32 {
4304 tr.note-row {
4305 td.title { a href=(format!("/notes/{id}")) { "Note " (id) } }
4306 }
4307 }
4308 }
4309 }
4310 }
4311 }
4312
4313 /// The same index after a cosmetic refactor: attribute order changed,
4314 /// extra wrapping markup and classes, different nesting — but the same
4315 /// structural facts (3 rows, each linking to `/notes/{id}`).
4316 async fn notes_index_v2() -> maud::Markup {
4317 maud::html! {
4318 div.card {
4319 table.notes.striped {
4320 thead { tr { th { "Title" } } }
4321 tbody.rows {
4322 @for id in 1..=3u32 {
4323 tr.note-row.is-clickable data-id=(id) {
4324 td.title {
4325 span.wrap {
4326 a.link href=(format!("/notes/{id}")) data-turbo="true" {
4327 "Note " (id)
4328 }
4329 }
4330 }
4331 }
4332 }
4333 }
4334 }
4335 }
4336 }
4337 }
4338
4339 /// An htmx swap fragment: a bare `<tr>` with no enclosing `<table>`.
4340 async fn note_row_fragment() -> maud::Markup {
4341 maud::html! {
4342 tr.note-row #note-7 {
4343 td.title { a.link href="/notes/7" { "Note 7" } }
4344 }
4345 }
4346 }
4347
4348 fn client(
4349 path: &str,
4350 handler: axum::routing::MethodRouter<crate::state::AppState>,
4351 ) -> TestClient {
4352 let router = axum::Router::<crate::state::AppState>::new().route(path, handler);
4353 TestApp::new().merge(router).build()
4354 }
4355
4356 #[tokio::test]
4357 async fn counts_rows_by_tag_and_class() {
4358 let resp = client("/notes", get(notes_index_v1))
4359 .get("/notes")
4360 .send()
4361 .await;
4362 resp.assert_ok()
4363 .assert_selector("table.notes")
4364 .assert_selector_count("tbody tr", 3)
4365 .assert_selector_count("tr.note-row", 3)
4366 .assert_no_selector("form");
4367 }
4368
4369 #[tokio::test]
4370 async fn reads_text_and_attributes() {
4371 let resp = client("/notes", get(notes_index_v1))
4372 .get("/notes")
4373 .send()
4374 .await;
4375 resp.assert_text("tr.note-row td.title a", "Note 1")
4376 .assert_text_contains("tr.note-row", "Note 1")
4377 .assert_attr("tr.note-row td a", "href", "/notes/1");
4378
4379 // Non-asserting accessors compose for custom assertions.
4380 let links = resp.selector_text("tr.note-row a");
4381 assert_eq!(links, vec!["Note 1", "Note 2", "Note 3"]);
4382 let hrefs = resp.selector_attr("tr.note-row a", "href");
4383 assert_eq!(
4384 hrefs,
4385 vec![
4386 Some("/notes/1".to_string()),
4387 Some("/notes/2".to_string()),
4388 Some("/notes/3".to_string()),
4389 ]
4390 );
4391 assert_eq!(resp.selector_count("tr.note-row"), 3);
4392 }
4393
4394 /// The success metric: identical structural assertions pass against
4395 /// both the original and the refactored template.
4396 #[tokio::test]
4397 async fn survives_cosmetic_refactor() {
4398 for handler in [get(notes_index_v1), get(notes_index_v2)] {
4399 let resp = client("/notes", handler).get("/notes").send().await;
4400 resp.assert_ok()
4401 // Exactly three data rows, each linking to /notes/{id}.
4402 .assert_selector_count("tbody tr.note-row", 3);
4403 let hrefs = resp.selector_attr("tbody tr.note-row a", "href");
4404 assert_eq!(
4405 hrefs,
4406 vec![
4407 Some("/notes/1".to_string()),
4408 Some("/notes/2".to_string()),
4409 Some("/notes/3".to_string()),
4410 ],
4411 "row links must survive the refactor"
4412 );
4413 }
4414 }
4415
4416 /// AC: works for partial/fragment responses (htmx swaps) — a bare
4417 /// `<tr>` with no enclosing table must still be selectable.
4418 #[tokio::test]
4419 async fn works_for_htmx_fragment() {
4420 let resp = client("/rows/7", get(note_row_fragment))
4421 .get("/rows/7")
4422 .send()
4423 .await;
4424 resp.assert_selector("tr.note-row")
4425 .assert_selector("tr#note-7")
4426 .assert_attr("tr#note-7 a", "href", "/notes/7")
4427 .assert_text("tr#note-7 a.link", "Note 7");
4428 }
4429
4430 #[tokio::test]
4431 async fn id_and_attribute_selectors() {
4432 let resp = client("/rows/7", get(note_row_fragment))
4433 .get("/rows/7")
4434 .send()
4435 .await;
4436 resp.assert_selector("#note-7")
4437 .assert_selector("a[href=\"/notes/7\"]")
4438 .assert_selector("a[href^=\"/notes/\"]")
4439 .assert_no_selector("a[href=\"/other\"]");
4440 }
4441
4442 #[tokio::test]
4443 #[should_panic(expected = "expected 5 element(s) matching selector")]
4444 async fn count_mismatch_panics_with_actionable_message() {
4445 let resp = client("/notes", get(notes_index_v1))
4446 .get("/notes")
4447 .send()
4448 .await;
4449 resp.assert_selector_count("tr.note-row", 5);
4450 }
4451
4452 #[tokio::test]
4453 #[should_panic(expected = "no elements matched selector `table.missing`")]
4454 async fn missing_selector_panics() {
4455 let resp = client("/notes", get(notes_index_v1))
4456 .get("/notes")
4457 .send()
4458 .await;
4459 resp.assert_selector("table.missing");
4460 }
4461 }
4462
4463 /// Companion to the override test: an invalid `_method` value rejects
4464 /// with `400 Bad Request` before reaching any handler.
4465 #[tokio::test]
4466 async fn test_app_routes_invalid_method_override_rejected() {
4467 let client = TestApp::new().routes(test_routes()).build();
4468
4469 client
4470 .post("/create")
4471 .form("_method=BREW")
4472 .send()
4473 .await
4474 .assert_status(400);
4475 }
4476
4477 /// The outer `MethodOverrideLayer` stamps a `MethodOverrideRejection`
4478 /// extension instead of short-circuiting, so the inner
4479 /// `method_override_rejection_filter` produces the `400` from inside
4480 /// the per-route layer chain. Verify that framework response
4481 /// middleware (request-ID header, security headers) still wraps that
4482 /// `400` — i.e. malformed requests inherit the same response middleware
4483 /// as ordinary handler responses, rather than bypassing it.
4484 #[tokio::test]
4485 async fn invalid_method_override_response_carries_framework_middleware() {
4486 let client = TestApp::new().routes(test_routes()).build();
4487
4488 let response = client.post("/create").form("_method=BREW").send().await;
4489 response.assert_status(400);
4490
4491 // RequestIdLayer is applied via `Router::layer` in
4492 // `apply_middleware` and stamps a response header on every
4493 // request that flows through the inner router. If the override
4494 // layer short-circuited at the outer wrapper, this header would
4495 // be absent.
4496 assert!(
4497 response.header("x-request-id").is_some(),
4498 "framework request-id header must wrap method-override rejections; \
4499 observed headers: {:?}",
4500 response.headers
4501 );
4502 // SecurityHeadersLayer applies a default set of headers; pick a
4503 // representative one to assert the layer ran on this response.
4504 assert!(
4505 response.header("x-content-type-options").is_some(),
4506 "framework security headers must wrap method-override rejections; \
4507 observed headers: {:?}",
4508 response.headers
4509 );
4510 }
4511
4512 // ── #1262: query-count / N+1 assertions (pure, no Postgres) ─────────
4513 //
4514 // These exercise the assertion *logic* on a directly-constructed
4515 // `TestResponse`, so they run in the always-on CI lane without a database
4516 // — the framework self-test that guarantees the assertions actually fire.
4517
4518 fn resp_with_queries(sqls: &[&str], threshold: usize) -> TestResponse {
4519 TestResponse {
4520 queries: sqls
4521 .iter()
4522 .map(|s| crate::inspector::QueryRecord {
4523 sql: (*s).to_owned(),
4524 params: Vec::new(),
4525 elapsed_ms: 1,
4526 location: String::new(),
4527 })
4528 .collect(),
4529 request_method: "GET".to_owned(),
4530 request_path: "/posts".to_owned(),
4531 n_plus_one_threshold: threshold,
4532 ..Default::default()
4533 }
4534 }
4535
4536 fn panic_message(err: &(dyn std::any::Any + Send)) -> String {
4537 err.downcast_ref::<String>()
4538 .cloned()
4539 .or_else(|| err.downcast_ref::<&str>().map(|s| (*s).to_owned()))
4540 .unwrap_or_default()
4541 }
4542
4543 #[test]
4544 fn query_count_and_queries_reflect_captured_list() {
4545 let resp = resp_with_queries(&["SELECT 1", "SELECT 2"], 5);
4546 assert_eq!(resp.query_count(), 2);
4547 assert_eq!(resp.queries().len(), 2);
4548 assert_eq!(resp.queries()[0].sql, "SELECT 1");
4549 }
4550
4551 /// Regression guard: the `REQUEST_QUERY_CAPTURE` scope must stay active
4552 /// while a lazy/streaming response body is drained, so DB work performed
4553 /// *during* body polling (as `Sse` / `Body::from_stream` handlers do) is
4554 /// still captured. `service.oneshot` returns the response head without
4555 /// polling the stream; `send()` drains the body with `to_bytes` — if that
4556 /// drain happened after the scope closed, these body-time queries would be
4557 /// recorded against an unset task-local and silently dropped, so
4558 /// `query_count()` would under-report (here: read 0 instead of 3).
4559 #[cfg(feature = "db")]
4560 #[tokio::test]
4561 async fn query_capture_stays_active_while_draining_streaming_body() {
4562 use futures::StreamExt as _;
4563
4564 // Each streamed chunk records a DB query when it is polled. The stream is
4565 // lazy: nothing runs until `to_bytes` polls it inside `send()`.
4566 async fn stream_handler() -> axum::response::Response {
4567 let body_stream = futures::stream::iter(0..3).map(|_| {
4568 crate::db::record_request_db_query(
4569 std::time::Duration::from_millis(1),
4570 Some("SELECT 1"),
4571 );
4572 Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static(b"x"))
4573 });
4574 axum::response::Response::new(Body::from_stream(body_stream))
4575 }
4576
4577 let router = axum::Router::new().route("/stream", axum::routing::get(stream_handler));
4578 let resp = RequestBuilder {
4579 router,
4580 method: Method::GET,
4581 uri: "/stream".to_owned(),
4582 headers: Vec::new(),
4583 body: Body::empty(),
4584 cookie_jar: None,
4585 clock: None,
4586 n_plus_one_threshold: 5,
4587 }
4588 .send()
4589 .await;
4590
4591 resp.assert_ok();
4592 assert_eq!(
4593 resp.query_count(),
4594 3,
4595 "body-time DB queries must be captured while the streaming body is \
4596 drained inside the active capture scope"
4597 );
4598 }
4599
4600 #[test]
4601 fn assert_max_queries_passes_at_boundary() {
4602 // len == n is within budget and must not panic.
4603 resp_with_queries(&["SELECT 1", "SELECT 2"], 5).assert_max_queries(2);
4604 }
4605
4606 #[test]
4607 fn assert_max_queries_panics_when_exceeded() {
4608 let resp = resp_with_queries(&["SELECT 1", "SELECT 2", "SELECT 3"], 5);
4609 let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4610 resp.assert_max_queries(2);
4611 }))
4612 .expect_err("assert_max_queries must panic when the query count exceeds the limit");
4613 let msg = panic_message(err.as_ref());
4614 assert!(
4615 msg.contains("GET /posts"),
4616 "message names the request: {msg}"
4617 );
4618 assert!(
4619 msg.contains("issued 3"),
4620 "message reports the actual count: {msg}"
4621 );
4622 assert!(msg.contains("<= 2"), "message reports the limit: {msg}");
4623 }
4624
4625 #[test]
4626 fn assert_no_n_plus_one_passes_for_distinct_queries() {
4627 // Three distinct statements: no normalized template repeats.
4628 resp_with_queries(&["SELECT 1", "SELECT 2", "SELECT 3"], 2).assert_no_n_plus_one();
4629 }
4630
4631 #[test]
4632 fn assert_no_n_plus_one_panics_on_repetition() {
4633 // Same statement modulo whitespace/case, repeated `threshold` times.
4634 let resp = resp_with_queries(
4635 &[
4636 "SELECT * FROM comments WHERE post_id = $1",
4637 "SELECT * FROM comments WHERE post_id = $1",
4638 "select * from comments where post_id = $1",
4639 ],
4640 3,
4641 );
4642 let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4643 resp.assert_no_n_plus_one();
4644 }))
4645 .expect_err("assert_no_n_plus_one must panic on an N+1 pattern");
4646 let msg = panic_message(err.as_ref());
4647 assert!(msg.contains("GET /posts"), "names the request: {msg}");
4648 assert!(
4649 msg.contains("3 times"),
4650 "reports the repetition count: {msg}"
4651 );
4652 assert!(
4653 msg.contains("select * from comments where post_id = $1"),
4654 "reports the normalized SQL template: {msg}"
4655 );
4656 }
4657
4658 #[test]
4659 fn assert_no_n_plus_one_with_threshold_overrides_default() {
4660 // Two identical queries; the configured default threshold (10) does not
4661 // fire, but an explicit override of 2 does.
4662 let resp = resp_with_queries(&["SELECT 1", "SELECT 1"], 10);
4663 resp.assert_no_n_plus_one(); // default threshold 10 -> passes
4664 let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4665 resp.assert_no_n_plus_one_with_threshold(2);
4666 }))
4667 .expect_err("an explicit threshold override must be honoured");
4668 assert!(
4669 panic_message(err.as_ref()).contains("2 times"),
4670 "override fires at the explicit threshold"
4671 );
4672 }
4673
4674 #[test]
4675 fn default_test_response_inherits_detector_threshold() {
4676 // A directly-constructed `TestResponse` must inherit the shared detector
4677 // default (5), not a zero-filled `0` — otherwise `..Default::default()`
4678 // would silently DISABLE N+1 detection.
4679 assert_eq!(
4680 TestResponse::default().n_plus_one_threshold,
4681 crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD,
4682 );
4683 }
4684
4685 #[test]
4686 fn default_constructed_response_catches_n_plus_one() {
4687 // Build a response purely via the documented `{ .., ..Default::default() }`
4688 // pattern (no explicit threshold). With a zero-filled default this passed
4689 // silently (0 == DISABLED); with the detector default (5) it must panic on
4690 // the normalized template repeated to the threshold.
4691 let resp = TestResponse {
4692 queries: [
4693 "SELECT * FROM comments WHERE post_id = $1",
4694 "SELECT * FROM comments WHERE post_id = $1",
4695 "select * from comments where post_id = $1",
4696 "SELECT * FROM comments WHERE post_id = $1",
4697 "Select * From comments Where post_id = $1",
4698 ]
4699 .iter()
4700 .map(|s| crate::inspector::QueryRecord {
4701 sql: (*s).to_owned(),
4702 params: Vec::new(),
4703 elapsed_ms: 1,
4704 location: String::new(),
4705 })
4706 .collect(),
4707 ..Default::default()
4708 };
4709 let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4710 resp.assert_no_n_plus_one();
4711 }))
4712 .expect_err(
4713 "a default-constructed TestResponse must inherit the non-zero detector \
4714 threshold and fire on an N+1 pattern",
4715 );
4716 assert!(
4717 panic_message(err.as_ref()).contains("select * from comments where post_id = $1"),
4718 "reports the normalized SQL template",
4719 );
4720 }
4721}