Skip to main content

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}