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", not(feature = "sqlite")))]
275use diesel_async::AsyncPgConnection;
276// Used by the Postgres transactional establish path (the `.get_result()` on
277// `TransactionalDbInterceptor`), which is itself gated `not(feature = "sqlite")`;
278// every other `RunQueryDsl` method call in this module brings the trait in via a
279// local `use`, so this import is unused under any `sqlite` build.
280#[cfg(all(feature = "db", not(feature = "sqlite")))]
281use diesel_async::RunQueryDsl;
282#[cfg(feature = "db")]
283use diesel_async::pooled_connection::deadpool::Pool;
284
285// ── Mail recording helpers ─────────────────────────────────────
286
287/// Snapshot of an email captured by the built-in test mail recorder.
288///
289/// Available on [`TestClient`] via [`TestClient::sent_mail()`] when the `mail`
290/// feature is enabled.
291///
292/// # Example
293///
294/// ```rust,ignore
295/// use autumn_web::test::TestApp;
296///
297/// let client = TestApp::new().config(cfg).routes(routes![handler]).build();
298/// client.post("/signup").json(&body).send().await.assert_ok();
299///
300/// // ≤ 3 lines to assert an email was sent:
301/// client.assert_email_count(1);
302/// client.assert_email_sent(|m| m.to.iter().any(|a| a == "alice@example.com"));
303/// client.assert_email_sent(|m| m.subject == "Welcome!");
304/// ```
305#[cfg(feature = "mail")]
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct SentMail {
308    /// `From` header value (after mailer defaults are applied).
309    pub from: Option<String>,
310    /// `Reply-To` header value.
311    pub reply_to: Option<String>,
312    /// `To` recipients.
313    pub to: Vec<String>,
314    /// `Subject` header.
315    pub subject: String,
316    /// HTML body, if provided.
317    pub html: Option<String>,
318    /// Plain-text body, if provided.
319    pub text: Option<String>,
320    /// Files attached to this message, in declared order.
321    pub attachments: Vec<crate::mail::MailAttachment>,
322}
323
324#[cfg(feature = "mail")]
325impl From<&crate::mail::Mail> for SentMail {
326    fn from(m: &crate::mail::Mail) -> Self {
327        Self {
328            from: m.from.clone(),
329            reply_to: m.reply_to.clone(),
330            to: m.to.clone(),
331            subject: m.subject.clone(),
332            html: m.html.clone(),
333            text: m.text.clone(),
334            attachments: m.attachments.clone(),
335        }
336    }
337}
338
339/// Built-in per-`TestClient` recording mail interceptor.
340///
341/// Auto-installed by [`TestApp::build`] — no `.with_mail_interceptor()` needed.
342/// Composes with any user-supplied interceptor (the user's interceptor still runs).
343#[cfg(feature = "mail")]
344#[derive(Clone, Default)]
345struct MailRecorder {
346    mails: std::sync::Arc<std::sync::Mutex<Vec<SentMail>>>,
347}
348
349#[cfg(feature = "mail")]
350impl MailRecorder {
351    fn new() -> Self {
352        Self::default()
353    }
354
355    fn get_sent(&self) -> Vec<SentMail> {
356        self.mails.lock().unwrap().clone()
357    }
358}
359
360#[cfg(feature = "mail")]
361impl crate::interceptor::MailInterceptor for MailRecorder {
362    fn intercept<'a>(
363        &'a self,
364        mail: &'a crate::mail::Mail,
365        next: std::pin::Pin<
366            Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
367        >,
368    ) -> std::pin::Pin<
369        Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
370    > {
371        let snapshot = SentMail::from(mail);
372        let mails = std::sync::Arc::clone(&self.mails);
373        Box::pin(async move {
374            let result = next.await;
375            if result.is_ok() {
376                mails.lock().unwrap().push(snapshot);
377            }
378            result
379        })
380    }
381}
382
383/// Chains two [`MailInterceptor`](crate::interceptor::MailInterceptor)s so that
384/// `first` runs before `second`, both before the underlying transport.
385#[cfg(feature = "mail")]
386struct ChainedMailInterceptor {
387    first: std::sync::Arc<dyn crate::interceptor::MailInterceptor>,
388    second: std::sync::Arc<dyn crate::interceptor::MailInterceptor>,
389}
390
391#[cfg(feature = "mail")]
392impl crate::interceptor::MailInterceptor for ChainedMailInterceptor {
393    fn intercept<'a>(
394        &'a self,
395        mail: &'a crate::mail::Mail,
396        next: std::pin::Pin<
397            Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
398        >,
399    ) -> std::pin::Pin<
400        Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
401    > {
402        let second_next = self.second.intercept(mail, next);
403        self.first.intercept(mail, second_next)
404    }
405}
406
407/// A single background-job enqueue captured by the built-in test job recorder.
408///
409/// Available on [`TestClient`] via [`TestClient::enqueued_jobs`]. The recorder
410/// is always on for [`TestApp`]-built clients — no `.with_job_interceptor()`
411/// boilerplate is required. Both the registered job `name` and the fully
412/// serialized `payload` (the exact `serde_json::Value` handed to the backend)
413/// are captured, so assertions can match on name alone or name-and-payload.
414///
415/// # Example
416///
417/// ```rust,ignore
418/// use autumn_web::test::TestApp;
419/// use serde_json::json;
420///
421/// let client = TestApp::new().plugin(MyJobs).routes(routes![signup]).build();
422/// client.post("/signup").json(&body).send().await.assert_ok();
423///
424/// client.assert_job_enqueued_with("send_welcome", json!({ "user_id": 7 }));
425/// ```
426#[derive(Clone, Debug)]
427pub struct RecordedJob {
428    /// The registered name of the enqueued job.
429    pub name: String,
430    /// The JSON payload the job was enqueued with (the real serialized args).
431    pub payload: serde_json::Value,
432}
433
434/// Built-in per-`TestApp` recording job interceptor.
435///
436/// Auto-installed by [`TestApp::build`] — no `.with_job_interceptor()` needed.
437/// Composes with any user-supplied interceptor (the user's interceptor still
438/// runs, after the recorder). Records every enqueue — across `enqueue`,
439/// `enqueue_after_commit`, and `enqueue_in_tx`, which all funnel through the
440/// same enqueue interceptor seam — in the order they were enqueued.
441#[derive(Clone, Default)]
442struct JobRecorder {
443    jobs: std::sync::Arc<std::sync::Mutex<Vec<RecordedJob>>>,
444}
445
446impl JobRecorder {
447    fn new() -> Self {
448        Self::default()
449    }
450
451    fn recorded(&self) -> Vec<RecordedJob> {
452        self.jobs.lock().unwrap().clone()
453    }
454
455    /// Take the captured jobs, leaving the recorder empty — used by
456    /// [`TestClient::perform_enqueued_jobs`] to drain the queue exactly once.
457    fn drain(&self) -> Vec<RecordedJob> {
458        std::mem::take(&mut *self.jobs.lock().unwrap())
459    }
460}
461
462impl crate::interceptor::JobInterceptor for JobRecorder {
463    fn intercept_enqueue<'a>(
464        &'a self,
465        name: &'a str,
466        payload: &'a serde_json::Value,
467        next: std::pin::Pin<
468            Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
469        >,
470    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
471    {
472        let record = RecordedJob {
473            name: name.to_string(),
474            payload: payload.clone(),
475        };
476        let jobs = std::sync::Arc::clone(&self.jobs);
477        Box::pin(async move {
478            // Record the enqueue intent up front, then let delivery proceed so
479            // the app's real backend/worker still sees the job (mirroring how
480            // the mail recorder does not suppress the underlying transport).
481            jobs.lock().unwrap().push(record);
482            next.await
483        })
484    }
485
486    fn intercept_execute<'a>(
487        &'a self,
488        _name: &'a str,
489        _payload: &'a serde_json::Value,
490        next: std::pin::Pin<
491            Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
492        >,
493    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
494    {
495        // The recorder only observes enqueues; execution passes straight through.
496        next
497    }
498}
499
500/// Chains two [`JobInterceptor`](crate::interceptor::JobInterceptor)s so that
501/// `first` runs before `second`, both before the actual enqueue/execute.
502struct ChainedJobInterceptor {
503    first: std::sync::Arc<dyn crate::interceptor::JobInterceptor>,
504    second: std::sync::Arc<dyn crate::interceptor::JobInterceptor>,
505}
506
507impl crate::interceptor::JobInterceptor for ChainedJobInterceptor {
508    fn intercept_enqueue<'a>(
509        &'a self,
510        name: &'a str,
511        payload: &'a serde_json::Value,
512        next: std::pin::Pin<
513            Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
514        >,
515    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
516    {
517        let second_next = self.second.intercept_enqueue(name, payload, next);
518        self.first.intercept_enqueue(name, payload, second_next)
519    }
520
521    fn intercept_execute<'a>(
522        &'a self,
523        name: &'a str,
524        payload: &'a serde_json::Value,
525        next: std::pin::Pin<
526            Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>,
527        >,
528    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
529    {
530        let second_next = self.second.intercept_execute(name, payload, next);
531        self.first.intercept_execute(name, payload, second_next)
532    }
533}
534
535/// Outcome report returned by [`TestClient::perform_enqueued_jobs`].
536///
537/// Holds one `(job name, result)` entry per drained job, in the order the jobs
538/// were enqueued. Per-job handler errors are surfaced here rather than
539/// swallowed: inspect them with [`Self::failures`], or fail the test outright
540/// with [`Self::assert_all_succeeded`]. A captured job whose name has no
541/// registered handler is reported as a failure too — never silently skipped.
542///
543/// # Example
544///
545/// ```rust,ignore
546/// let report = client.perform_enqueued_jobs().await;
547/// report.assert_all_succeeded();
548/// ```
549#[derive(Debug)]
550pub struct PerformedJobs {
551    outcomes: Vec<(String, crate::AutumnResult<()>)>,
552}
553
554impl PerformedJobs {
555    /// Every performed job's `(name, result)`, in the order they were enqueued.
556    pub fn outcomes(&self) -> &[(String, crate::AutumnResult<()>)] {
557        &self.outcomes
558    }
559
560    /// The number of jobs that were drained and performed.
561    #[must_use]
562    pub const fn len(&self) -> usize {
563        self.outcomes.len()
564    }
565
566    /// Whether no jobs were performed (the queue was empty).
567    #[must_use]
568    pub const fn is_empty(&self) -> bool {
569        self.outcomes.is_empty()
570    }
571
572    /// The `(name, error)` pairs for every job whose handler returned `Err`
573    /// (or that had no registered handler).
574    #[must_use]
575    pub fn failures(&self) -> Vec<(&str, &crate::AutumnError)> {
576        self.outcomes
577            .iter()
578            .filter_map(|(name, result)| result.as_ref().err().map(|e| (name.as_str(), e)))
579            .collect()
580    }
581
582    /// Assert every performed job succeeded.
583    ///
584    /// # Panics
585    ///
586    /// Panics, listing each failing job's name and error, if any performed job
587    /// returned an error or had no registered handler.
588    pub fn assert_all_succeeded(&self) -> &Self {
589        let failures = self.failures();
590        assert!(
591            failures.is_empty(),
592            "expected all performed jobs to succeed, but {} failed:\n{}",
593            failures.len(),
594            failures
595                .iter()
596                .map(|(name, err)| format!("  - {name}: {err:?}"))
597                .collect::<Vec<_>>()
598                .join("\n")
599        );
600        self
601    }
602}
603
604/// Render a captured-job list for self-diagnosing assertion failures.
605fn format_recorded_jobs(jobs: &[RecordedJob]) -> String {
606    if jobs.is_empty() {
607        return "  (no jobs were enqueued)".to_string();
608    }
609    jobs.iter()
610        .map(|j| format!("  - {} {}", j.name, j.payload))
611        .collect::<Vec<_>>()
612        .join("\n")
613}
614
615/// A single channel publication captured by the broadcast recorder.
616///
617/// Recorded by [`TestApp::record_broadcasts`] through the channels
618/// interceptor seam. Both raw `publish` text and `publish_html` HTML/OOB
619/// payloads are captured (they funnel through the same `ChannelMessage`).
620#[cfg(feature = "ws")]
621#[derive(Clone, Debug)]
622pub struct RecordedBroadcast {
623    /// The topic the message was published to.
624    pub topic: String,
625    /// The UTF-8 payload of the published `ChannelMessage`.
626    pub payload: String,
627}
628
629#[cfg(feature = "ws")]
630impl RecordedBroadcast {
631    /// The topic the message was published to.
632    #[must_use]
633    pub fn topic(&self) -> &str {
634        &self.topic
635    }
636
637    /// The UTF-8 payload of the published message.
638    #[must_use]
639    pub fn payload(&self) -> &str {
640        &self.payload
641    }
642}
643
644/// Built-in per-`TestClient` recording channels interceptor.
645///
646/// Opt-in via [`TestApp::record_broadcasts`] — no interceptor is installed
647/// unless the builder is called (zero-cost when unused). Records every
648/// publication in order, including publishes to zero subscribers.
649#[cfg(feature = "ws")]
650#[derive(Clone, Default)]
651struct BroadcastRecorder {
652    events: std::sync::Arc<std::sync::Mutex<Vec<RecordedBroadcast>>>,
653}
654
655#[cfg(feature = "ws")]
656impl BroadcastRecorder {
657    fn new() -> Self {
658        Self::default()
659    }
660
661    fn recorded(&self) -> Vec<RecordedBroadcast> {
662        self.events.lock().unwrap().clone()
663    }
664}
665
666#[cfg(feature = "ws")]
667impl crate::interceptor::ChannelsInterceptor for BroadcastRecorder {
668    fn intercept_publish(
669        &self,
670        topic: &str,
671        msg: &crate::channels::ChannelMessage,
672        next: &dyn Fn(
673            &str,
674            &crate::channels::ChannelMessage,
675        ) -> Result<usize, crate::channels::ChannelPublishError>,
676    ) -> Result<usize, crate::channels::ChannelPublishError> {
677        let result = next(topic, msg);
678        // Record the publication even when it reached zero subscribers — the
679        // publish still happened and tests assert on intent, not delivery.
680        self.events.lock().unwrap().push(RecordedBroadcast {
681            topic: topic.into(),
682            payload: msg.as_str().into(),
683        });
684        result
685    }
686}
687
688// ── TestApp ────────────────────────────────────────────────────
689
690/// Builder for constructing a fully-configured Autumn application in tests.
691///
692/// Analogous to Spring Boot's `@SpringBootTest` -- it wires up routes,
693/// middleware, config, and optionally a database pool, then produces a
694/// [`TestClient`] ready to fire requests.
695///
696/// # Examples
697///
698/// ```rust,no_run
699/// use autumn_web::prelude::*;
700/// use autumn_web::test::TestApp;
701///
702/// #[get("/ping")]
703/// async fn ping() -> &'static str { "pong" }
704///
705/// #[tokio::test]
706/// async fn ping_works() {
707///     let client = TestApp::new()
708///         .routes(routes![ping])
709///         .build();
710///
711///     client.get("/ping").send().await.assert_ok();
712/// }
713/// ```
714pub struct TestApp {
715    routes: Vec<Route>,
716    scoped_groups: Vec<crate::app::ScopedGroup>,
717    merge_routers: Vec<axum::Router<crate::state::AppState>>,
718    nest_routers: Vec<(String, axum::Router<crate::state::AppState>)>,
719    /// Routes declared for opaque `nest` mounts, mirroring
720    /// [`AppBuilder::declare_plugin_routes`]. Carried so a `TestApp` runs the
721    /// same duplicate-route preflight production does — without this, a plugin
722    /// whose manifest collides with a host route would mount cleanly in tests
723    /// and panic at boot in production.
724    declared_routes: Vec<crate::route_listing::RouteInfo>,
725    /// Asset bundles installed by plugins, so a second bundle claiming a
726    /// taken namespace is refused here exactly as `AppBuilder` refuses it.
727    plugin_asset_bundles: Vec<&'static crate::assets::PluginAssets>,
728    custom_layers: Vec<crate::app::CustomLayerRegistration>,
729    static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
730    config: AutumnConfig,
731    #[cfg(feature = "openapi")]
732    openapi: Option<crate::openapi::OpenApiConfig>,
733    #[cfg(feature = "mcp")]
734    mcp: Option<crate::mcp::McpRuntime>,
735    #[cfg(feature = "webmcp")]
736    webmcp: Option<crate::webmcp::WebMcpRuntime>,
737    #[cfg(feature = "db")]
738    pool: Option<Pool<crate::db::RuntimeConnection>>,
739    #[cfg(feature = "db")]
740    replica_pool: Option<Pool<crate::db::RuntimeConnection>>,
741    /// A `SQLite` fleet that becomes the app's shard set (ADR 0019).
742    #[cfg(feature = "sqlite")]
743    fleet: Option<crate::db::fleet::DatabaseFleet>,
744    #[cfg(feature = "db")]
745    transactional: bool,
746    #[cfg(feature = "db")]
747    transactional_url: Option<String>,
748    /// Deferred policy / scope registrations applied during
749    /// [`TestApp::build`].
750    policy_registrations: Vec<TestPolicyRegistration>,
751    /// Override for [`AppState::forbidden_response`]. Defaults to
752    /// the value derived from
753    /// [`SecurityConfig::forbidden_response`](crate::security::SecurityConfig::forbidden_response).
754    forbidden_response_override: Option<crate::authorization::ForbiddenResponse>,
755    #[cfg(feature = "mail")]
756    mail_interceptor: Option<std::sync::Arc<dyn crate::interceptor::MailInterceptor>>,
757    #[cfg(feature = "mail")]
758    mail_recorder: MailRecorder,
759    job_interceptor: Option<std::sync::Arc<dyn crate::interceptor::JobInterceptor>>,
760    /// Always-on job recorder capturing every enqueue. Composed ahead of any
761    /// user-supplied [`with_job_interceptor`](Self::with_job_interceptor).
762    job_recorder: JobRecorder,
763    /// Authored fault schedule attached via
764    /// [`with_fault_plan`](Self::with_fault_plan) (issue #1680); `None` means no
765    /// fault interceptors are installed at all.
766    fault_plan: Option<crate::sim::fault::FaultPlan>,
767    #[cfg(feature = "db")]
768    db_interceptor: Option<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
769    #[cfg(feature = "ws")]
770    channels_interceptor: Option<std::sync::Arc<dyn crate::interceptor::ChannelsInterceptor>>,
771    /// Opt-in broadcast recorder, installed only when
772    /// [`record_broadcasts`](Self::record_broadcasts) is called.
773    #[cfg(feature = "ws")]
774    broadcast_recorder: Option<BroadcastRecorder>,
775    #[cfg(feature = "oauth2")]
776    http_interceptor: Option<std::sync::Arc<dyn crate::interceptor::HttpInterceptor>>,
777    /// Shared mock registry installed into `AppState` during [`build`](Self::build)
778    /// so that any [`Client`](crate::http_client::Client) extracted inside a
779    /// handler intercepts matching requests.
780    #[cfg(feature = "http-client")]
781    http_mock_registry: Option<std::sync::Arc<crate::http_client::MockRegistry>>,
782    /// The sim network, installed with the mock registry and before any state
783    /// initializer, so every client built from state sends through it.
784    #[cfg(feature = "http-client")]
785    sim_net: Option<crate::sim::SimNet>,
786    state_initializers: Vec<Box<dyn FnOnce(&AppState) + Send>>,
787    jobs: Vec<crate::job::JobInfo>,
788    tasks: Vec<crate::task::TaskInfo>,
789    listeners: Vec<crate::events::ListenerInfo>,
790    #[cfg(feature = "db")]
791    outbox_handlers: crate::outbox::OutboxHandlers,
792    exception_filters: Vec<std::sync::Arc<dyn crate::middleware::ExceptionFilter>>,
793    #[cfg(feature = "mail")]
794    suppression_store: Option<crate::mail::SuppressionStoreHandle>,
795    #[cfg(feature = "mail")]
796    mail_suppression_store: Option<crate::mail::suppression::SuppressionStoreHandle>,
797    registered_plugins: std::collections::HashSet<String>,
798    extensions: std::collections::HashMap<std::any::TypeId, Box<dyn std::any::Any + Send>>,
799    /// Injected clock; `None` means use [`crate::time::SystemClock`].
800    clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
801    /// Injected entropy source; `None` means use [`crate::entropy::OsEntropy`].
802    entropy: Option<std::sync::Arc<dyn crate::entropy::Entropy>>,
803    /// Retained as `Arc<dyn Any>` so `TestClient::advance_clock` can downcast
804    /// to [`crate::time::TickingClock`] at runtime.
805    clock_as_any: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
806    api_versions: Vec<crate::app::ApiVersion>,
807    agent_skills: Vec<crate::aeo::AgentSkill>,
808    commerce_docs: crate::aeo::RegisteredCommerceDocs,
809    /// Plugin-contributed metrics sources registered via [`AppBuilder::metrics_source`].
810    metrics_sources: Vec<(String, std::sync::Arc<dyn crate::actuator::MetricsSource>)>,
811    /// Plugin-contributed health indicators registered via [`AppBuilder::health_indicator`].
812    health_indicators: Vec<(
813        String,
814        crate::actuator::IndicatorGroup,
815        std::sync::Arc<dyn crate::actuator::HealthIndicator>,
816    )>,
817    /// Inbound mail router registered via [`TestApp::inbound_mail_router`].
818    #[cfg(feature = "inbound-mail")]
819    inbound_mail_router: Option<std::sync::Arc<crate::inbound_mail::InboundMailRouter>>,
820}
821
822type TestPolicyRegistration = Box<dyn FnOnce(&crate::authorization::PolicyRegistry) + Send>;
823
824impl TestApp {
825    /// Create a new test app builder with default configuration.
826    #[must_use]
827    pub fn new() -> Self {
828        let mut config = AutumnConfig::default();
829        config.profile = Some("test".into());
830        // Disable CSRF for tests by default (like Spring Security's test support)
831        config.security.csrf.enabled = false;
832
833        Self {
834            routes: Vec::new(),
835            scoped_groups: Vec::new(),
836            merge_routers: Vec::new(),
837            nest_routers: Vec::new(),
838            declared_routes: Vec::new(),
839            plugin_asset_bundles: Vec::new(),
840            custom_layers: Vec::new(),
841            static_gate_layers: Vec::new(),
842            config,
843            #[cfg(feature = "openapi")]
844            openapi: None,
845            #[cfg(feature = "mcp")]
846            mcp: None,
847            #[cfg(feature = "webmcp")]
848            webmcp: None,
849            #[cfg(feature = "db")]
850            pool: None,
851            #[cfg(feature = "db")]
852            replica_pool: None,
853            #[cfg(feature = "sqlite")]
854            fleet: None,
855            #[cfg(feature = "db")]
856            transactional: false,
857            #[cfg(feature = "db")]
858            transactional_url: None,
859            policy_registrations: Vec::new(),
860            forbidden_response_override: None,
861            #[cfg(feature = "mail")]
862            mail_interceptor: None,
863            #[cfg(feature = "mail")]
864            mail_recorder: MailRecorder::new(),
865            job_interceptor: None,
866            job_recorder: JobRecorder::new(),
867            fault_plan: None,
868            #[cfg(feature = "db")]
869            db_interceptor: None,
870            #[cfg(feature = "ws")]
871            channels_interceptor: None,
872            #[cfg(feature = "ws")]
873            broadcast_recorder: None,
874            #[cfg(feature = "oauth2")]
875            http_interceptor: None,
876            #[cfg(feature = "http-client")]
877            http_mock_registry: None,
878            #[cfg(feature = "http-client")]
879            sim_net: None,
880            state_initializers: Vec::new(),
881            jobs: Vec::new(),
882            tasks: Vec::new(),
883            listeners: Vec::new(),
884            #[cfg(feature = "db")]
885            outbox_handlers: crate::outbox::OutboxHandlers::default(),
886            exception_filters: Vec::new(),
887            #[cfg(feature = "mail")]
888            suppression_store: None,
889            #[cfg(feature = "mail")]
890            mail_suppression_store: None,
891            registered_plugins: std::collections::HashSet::new(),
892            extensions: std::collections::HashMap::new(),
893            clock: None,
894            entropy: None,
895            clock_as_any: None,
896            api_versions: Vec::new(),
897            agent_skills: Vec::new(),
898            commerce_docs: crate::aeo::RegisteredCommerceDocs::default(),
899            metrics_sources: Vec::new(),
900            health_indicators: Vec::new(),
901            #[cfg(feature = "inbound-mail")]
902            inbound_mail_router: None,
903        }
904    }
905
906    /// Register a [`Policy`](crate::authorization::Policy) for
907    /// resource type `R`. Mirrors
908    /// [`AppBuilder::policy`](crate::app::AppBuilder::policy).
909    #[must_use]
910    pub fn policy<R, P>(mut self, policy: P) -> Self
911    where
912        R: Send + Sync + 'static,
913        P: crate::authorization::Policy<R>,
914    {
915        self.policy_registrations.push(Box::new(move |registry| {
916            registry.register_policy::<R, _>(policy);
917        }));
918        self
919    }
920
921    /// Register a [`Scope`](crate::authorization::Scope) for resource
922    /// type `R`. Mirrors
923    /// [`AppBuilder::scope`](crate::app::AppBuilder::scope).
924    #[must_use]
925    pub fn scope<R, S>(mut self, scope: S) -> Self
926    where
927        R: Send + Sync + 'static,
928        S: crate::authorization::Scope<R>,
929    {
930        self.policy_registrations.push(Box::new(move |registry| {
931            registry.register_scope::<R, _>(scope);
932        }));
933        self
934    }
935
936    /// Register an inbound mail router for this test app.
937    ///
938    /// Mirrors [`crate::app::AppBuilder::inbound_mail_router`].
939    #[cfg(feature = "inbound-mail")]
940    #[must_use]
941    pub fn inbound_mail_router(mut self, router: crate::inbound_mail::InboundMailRouter) -> Self {
942        self.inbound_mail_router = Some(std::sync::Arc::new(router));
943        self
944    }
945
946    /// Override the deny-response shape used by `#[authorize]` and
947    /// `#[repository(policy = ...)]` handlers. Useful for
948    /// round-tripping the `403`-vs-`404` decision in tests.
949    #[must_use]
950    pub const fn forbidden_response(
951        mut self,
952        value: crate::authorization::ForbiddenResponse,
953    ) -> Self {
954        self.forbidden_response_override = Some(value);
955        self
956    }
957
958    /// Enable `OpenAPI` spec generation for the test app.
959    ///
960    /// Mirrors [`crate::app::AppBuilder::openapi`] so integration tests
961    /// can exercise the `/openapi.json` and `/swagger-ui` endpoints.
962    ///
963    /// Gated behind the `openapi` Cargo feature.
964    #[cfg(feature = "openapi")]
965    #[must_use]
966    pub fn openapi(mut self, config: crate::openapi::OpenApiConfig) -> Self {
967        self.openapi = Some(config);
968        self
969    }
970
971    /// Mount an MCP endpoint at `path`, mirroring
972    /// [`AppBuilder::mount_mcp`](crate::app::AppBuilder::mount_mcp) so
973    /// integration tests can drive `initialize`/`tools/list`/`tools/call`
974    /// through the in-process pipeline.
975    ///
976    /// Gated behind the `mcp` Cargo feature.
977    #[cfg(feature = "mcp")]
978    #[must_use]
979    pub fn mount_mcp(mut self, path: impl Into<String>) -> Self {
980        let path = path.into();
981        if let Some(rt) = self.mcp.as_mut() {
982            rt.mount_path = path;
983        } else {
984            self.mcp = Some(crate::mcp::McpRuntime::new(path));
985        }
986        self
987    }
988
989    /// Serve the `WebMCP` tool list, as
990    /// [`AppBuilder::mount_webmcp`](crate::app::AppBuilder::mount_webmcp)
991    /// does.
992    #[cfg(feature = "webmcp")]
993    #[must_use]
994    pub fn mount_webmcp(mut self) -> Self {
995        self.webmcp.get_or_insert_with(Default::default);
996        self
997    }
998
999    /// Make each eligible `GET` a `WebMCP` tool, as
1000    /// [`AppBuilder::expose_all_as_webmcp`](crate::app::AppBuilder::expose_all_as_webmcp)
1001    /// does.
1002    #[cfg(feature = "webmcp")]
1003    #[must_use]
1004    pub fn expose_all_as_webmcp(mut self) -> Self {
1005        self.webmcp.get_or_insert_with(Default::default).expose_all = true;
1006        self
1007    }
1008
1009    /// Enable the whole-API MCP hatch, mirroring
1010    /// [`AppBuilder::expose_all_as_mcp`](crate::app::AppBuilder::expose_all_as_mcp).
1011    ///
1012    /// Gated behind the `mcp` Cargo feature.
1013    #[cfg(feature = "mcp")]
1014    #[must_use]
1015    pub fn expose_all_as_mcp(mut self) -> Self {
1016        if let Some(rt) = self.mcp.as_mut() {
1017            rt.expose_all = true;
1018        } else {
1019            let mut rt = crate::mcp::McpRuntime::new("/mcp");
1020            rt.expose_all = true;
1021            self.mcp = Some(rt);
1022        }
1023        self
1024    }
1025
1026    /// Gate the entire MCP endpoint behind a tower `layer`, mirroring
1027    /// [`AppBuilder::secure_mcp`](crate::app::AppBuilder::secure_mcp).
1028    ///
1029    /// Gated behind the `mcp` Cargo feature.
1030    #[cfg(feature = "mcp")]
1031    #[must_use]
1032    pub fn secure_mcp<L>(mut self, layer: L) -> Self
1033    where
1034        L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
1035        L::Service: tower::Service<
1036                axum::http::Request<axum::body::Body>,
1037                Response = axum::http::Response<axum::body::Body>,
1038                Error = std::convert::Infallible,
1039            > + Clone
1040            + Send
1041            + Sync
1042            + 'static,
1043        <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
1044            Send + 'static,
1045    {
1046        let applier: crate::mcp::McpEndpointLayer = Box::new(move |router| router.layer(layer));
1047        if let Some(rt) = self.mcp.as_mut() {
1048            rt.endpoint_layer = Some(applier);
1049        } else {
1050            let mut rt = crate::mcp::McpRuntime::new("/mcp");
1051            rt.endpoint_layer = Some(applier);
1052            self.mcp = Some(rt);
1053        }
1054        self
1055    }
1056
1057    /// Set the limits for MCP task tools, mirroring
1058    /// [`AppBuilder::mcp_tasks`](crate::app::AppBuilder::mcp_tasks).
1059    ///
1060    /// Gated behind the `mcp` Cargo feature.
1061    #[cfg(feature = "mcp")]
1062    #[must_use]
1063    pub fn mcp_tasks(mut self, config: crate::mcp::McpTaskConfig) -> Self {
1064        self.mcp
1065            .get_or_insert_with(|| crate::mcp::McpRuntime::new("/mcp"))
1066            .tasks = config;
1067        self
1068    }
1069
1070    /// Set the MCP server instructions, mirroring
1071    /// [`AppBuilder::mcp_instructions`](crate::app::AppBuilder::mcp_instructions).
1072    ///
1073    /// Gated behind the `mcp` Cargo feature.
1074    #[cfg(feature = "mcp")]
1075    #[must_use]
1076    pub fn mcp_instructions(mut self, text: impl Into<String>) -> Self {
1077        self.mcp
1078            .get_or_insert_with(|| crate::mcp::McpRuntime::new("/mcp"))
1079            .instructions = Some(text.into());
1080        self
1081    }
1082
1083    /// Select the MCP tool catalog, mirroring
1084    /// [`AppBuilder::mcp_catalog`](crate::app::AppBuilder::mcp_catalog).
1085    ///
1086    /// Gated behind the `mcp` Cargo feature.
1087    #[cfg(feature = "mcp")]
1088    #[must_use]
1089    pub fn mcp_catalog(mut self, catalog: crate::mcp::McpCatalog) -> Self {
1090        self.mcp
1091            .get_or_insert_with(|| crate::mcp::McpRuntime::new("/mcp"))
1092            .catalog = catalog;
1093        self
1094    }
1095
1096    /// Merge a router into the internal application state.
1097    ///
1098    /// This is useful when testing modular route definitions without building
1099    /// the full application.
1100    #[must_use]
1101    pub fn merge(mut self, router: axum::Router<crate::state::AppState>) -> Self {
1102        self.merge_routers.push(router);
1103        self
1104    }
1105
1106    /// Mount routes under a scoped prefix with a route-local layer.
1107    #[must_use]
1108    pub fn scoped<L>(mut self, prefix: &str, layer: L, routes: Vec<Route>) -> Self
1109    where
1110        L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
1111        L::Service: tower::Service<
1112                axum::http::Request<axum::body::Body>,
1113                Response = axum::http::Response<axum::body::Body>,
1114                Error = std::convert::Infallible,
1115            > + Clone
1116            + Send
1117            + Sync
1118            + 'static,
1119        <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
1120            Send + 'static,
1121    {
1122        self.scoped_groups.push(crate::app::ScopedGroup {
1123            prefix: prefix.to_owned(),
1124            routes,
1125            source: crate::route_listing::RouteSource::User,
1126            layer_guarded: false,
1127            apply_layer: Box::new(move |router| router.layer(layer)),
1128        });
1129        self
1130    }
1131
1132    /// Nest a router under a specific path prefix for testing.
1133    ///
1134    /// This is useful for testing sub-applications or API versions.
1135    #[must_use]
1136    pub fn nest(mut self, path: &str, router: axum::Router<crate::state::AppState>) -> Self {
1137        self.nest_routers.push((path.to_owned(), router));
1138        self
1139    }
1140
1141    /// Apply a custom [`tower::Layer`] to the entire test application.
1142    ///
1143    /// Mirrors [`crate::app::AppBuilder::layer`] so tests can exercise the
1144    /// exact middleware wiring that `AppBuilder::run()` produces.
1145    #[must_use]
1146    pub fn layer<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
1147        self.custom_layers
1148            .push(crate::app::CustomLayerRegistration {
1149                type_id: std::any::TypeId::of::<L>(),
1150                type_name: std::any::type_name::<L>(),
1151                layer: layer.erase(),
1152            });
1153        self
1154    }
1155
1156    /// Register a pre-static gate layer for this test application.
1157    ///
1158    /// Mirrors [`crate::app::AppBuilder::static_gate`]: the layer runs
1159    /// outermost (outside session and before the static cache lookup) so tests
1160    /// can exercise auth-gating wiring that protects cached SSG/ISG pages.
1161    #[must_use]
1162    pub fn static_gate<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
1163        self.static_gate_layers
1164            .push(crate::app::CustomLayerRegistration {
1165                type_id: std::any::TypeId::of::<L>(),
1166                type_name: std::any::type_name::<L>(),
1167                layer: layer.erase(),
1168            });
1169        self
1170    }
1171
1172    /// Register an [`ErrorReporter`](crate::reporting::ErrorReporter) for this
1173    /// test app.
1174    ///
1175    /// Mirrors [`crate::app::AppBuilder::with_error_reporter`]. Call multiple
1176    /// times to chain reporters; each receives every panic + 5xx event.
1177    #[cfg(feature = "reporting")]
1178    #[must_use]
1179    pub fn with_error_reporter<R: crate::reporting::ErrorReporter>(mut self, reporter: R) -> Self {
1180        let reporter =
1181            std::sync::Arc::new(reporter) as std::sync::Arc<dyn crate::reporting::ErrorReporter>;
1182        self.state_initializers.push(Box::new(move |state| {
1183            let mut reporters = state
1184                .extension::<crate::reporting::RegisteredReporters>()
1185                .map(|registered| registered.0.clone())
1186                .unwrap_or_default();
1187            reporters.push(reporter.clone());
1188            state.insert_extension(crate::reporting::RegisteredReporters(reporters));
1189        }));
1190        self
1191    }
1192
1193    /// Enable HTTP idempotency-key middleware for this test app.
1194    ///
1195    /// Mirrors [`crate::app::AppBuilder::idempotent`]: sets the
1196    /// `config.idempotency.enabled` flag so that the router wires up the layer
1197    /// with the same `MemoryIdempotencyStore` and `MetricsCollector` that
1198    /// production uses.
1199    #[must_use]
1200    pub const fn idempotent(mut self) -> Self {
1201        self.config.idempotency.enabled = Some(true);
1202        self
1203    }
1204
1205    /// Construct a [`TestClient`] directly from an `axum::Router`.
1206    ///
1207    /// Useful for bypassing `TestApp` builder if you just want to write requests
1208    /// against a standard axum Router.  The probe state returned by
1209    /// [`TestClient::probes`] will be in the default ready state; it is not
1210    /// connected to any handler in the supplied router.
1211    ///
1212    /// **Note:** [`TestClient::sent_mail`] will always return an empty list for
1213    /// clients built this way.  The built-in mail recorder is wired in during
1214    /// [`TestApp::build`]; because `from_router` receives an already-constructed
1215    /// `AppState` (with the mailer already installed), the recorder cannot be
1216    /// injected into its interceptor chain.  Use [`TestApp::new().merge(router).build()`](TestApp::merge)
1217    /// to get recording support.
1218    #[must_use]
1219    pub fn from_router(router: axum::Router, state: AppState) -> TestClient {
1220        let auth_session_key = state.auth_session_key().to_owned();
1221        // Resolve the session cookie name from the router's config (installed in
1222        // state extensions by `build()`), falling back to the framework default
1223        // when it isn't present — so `log_out` clears the right cookie even when
1224        // the app configured a custom `session.cookie_name`.
1225        let session_cookie_name = state.extension::<AutumnConfig>().map_or_else(
1226            || crate::session::SessionConfig::default().cookie_name,
1227            |cfg| cfg.session.cookie_name.clone(),
1228        );
1229        TestClient {
1230            router,
1231            probes: crate::probe::ProbeState::ready_for_test(),
1232            state,
1233            _job_runtime: None,
1234            _task_scheduler: None,
1235            clock_as_any: None,
1236            #[cfg(feature = "mail")]
1237            mail_recorder: None,
1238            #[cfg(feature = "ws")]
1239            broadcast_recorder: None,
1240            job_recorder: None,
1241            jobs: Vec::new(),
1242            cookie_jar: std::sync::Arc::new(
1243                std::sync::Mutex::new(std::collections::HashMap::new()),
1244            ),
1245            // `from_router` receives an already-built router, so we have no
1246            // handle to whatever session store (if any) it installed. The jar
1247            // still works — cookies from real requests round-trip — but
1248            // `acting_as` cannot mint a session and panics, mirroring how
1249            // `sent_mail()` degrades for `from_router` clients.
1250            session_store: None,
1251            session_cookie_name,
1252            auth_session_key,
1253            session_signing_keys: None,
1254            fault_ledger: None,
1255            observed_server_errors: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
1256        }
1257    }
1258
1259    /// Register a collection of routes to be built into the `TestApp`.
1260    #[must_use]
1261    pub fn routes(mut self, routes: Vec<Route>) -> Self {
1262        self.routes.extend(routes);
1263        self
1264    }
1265
1266    /// Register a callback to configure/initialize the application state before building the router.
1267    #[must_use]
1268    pub fn state_initializer<F>(mut self, f: F) -> Self
1269    where
1270        F: FnOnce(&AppState) + Send + 'static,
1271    {
1272        self.state_initializers.push(Box::new(f));
1273        self
1274    }
1275
1276    /// Install a designated live-state block, so handlers that read it through
1277    /// [`AppState::live_state`](crate::AppState::live_state) can be tested.
1278    ///
1279    /// Mirrors [`crate::app::AppBuilder::with_live_state`]. A test app never
1280    /// adopts a snapshot from a predecessor — there is no upgrade in flight —
1281    /// so `initial` is always the value handlers see.
1282    #[must_use]
1283    pub fn with_live_state<T>(mut self, initial: T) -> Self
1284    where
1285        T: crate::upgrade::LiveState,
1286    {
1287        self.state_initializers.push(Box::new(move |state| {
1288            assert!(
1289                state
1290                    .extension::<crate::upgrade::LiveStateRegistry>()
1291                    .is_none(),
1292                "an app may designate only one block of live state; the real builder \
1293                 refuses a second one at startup, so a test app does too"
1294            );
1295            let handle = crate::upgrade::LiveStateHandle::new(initial);
1296            state.insert_extension(crate::upgrade::LiveStateRegistry::new(&handle));
1297            state.insert_extension(handle);
1298        }));
1299        self
1300    }
1301
1302    /// Register a [`FlagStore`](crate::feature_flags::FlagStore) backend so
1303    /// the [`Flags`](crate::feature_flags::Flags) extractor works in test handlers.
1304    ///
1305    /// Mirrors [`crate::app::AppBuilder::with_flag_store`].
1306    #[must_use]
1307    pub fn with_flag_store<S>(self, store: S) -> Self
1308    where
1309        S: crate::feature_flags::FlagStore,
1310    {
1311        use std::sync::Arc;
1312        let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>);
1313        self.with_flag_service(service)
1314    }
1315
1316    /// Mirrors [`crate::app::AppBuilder::with_flag_service`]. The build
1317    /// preloads the store before the first request.
1318    #[must_use]
1319    pub fn with_flag_service(mut self, service: crate::feature_flags::FeatureFlagService) -> Self {
1320        self.state_initializers.push(Box::new(move |state| {
1321            crate::feature_flags::preload_blocking(&service, crate::feature_flags::PRELOAD_WAIT);
1322            state.insert_extension(service);
1323        }));
1324        self
1325    }
1326
1327    /// Mirrors [`crate::app::AppBuilder::with_notification_store`].
1328    #[must_use]
1329    pub fn with_notification_store<S>(mut self, store: S) -> Self
1330    where
1331        S: crate::notifications::NotificationStore,
1332    {
1333        let service = crate::notifications::Notifications::new(store);
1334        self.state_initializers.push(Box::new(move |state| {
1335            state.insert_extension(service);
1336        }));
1337        self
1338    }
1339
1340    /// Mirrors
1341    /// [`crate::app::AppBuilder::with_push_subscription_store`].
1342    #[must_use]
1343    pub fn with_push_subscription_store<S>(mut self, store: S) -> Self
1344    where
1345        S: crate::push::PushSubscriptionStore,
1346    {
1347        self.state_initializers.push(Box::new(move |state| {
1348            state.insert_extension(crate::push::WebPush::from_state_with_store(state, store));
1349        }));
1350        self
1351    }
1352
1353    /// Register an explicit [`WebPush`](crate::push::WebPush) service,
1354    /// overriding key, store and transport at once.
1355    ///
1356    /// The usual reason is a
1357    /// [`RecordingPushTransport`](crate::push::RecordingPushTransport), so a
1358    /// test can assert exactly what would have gone to the push service.
1359    #[must_use]
1360    pub fn with_web_push(mut self, push: crate::push::WebPush) -> Self {
1361        self.state_initializers.push(Box::new(move |state| {
1362            state.insert_extension(push);
1363        }));
1364        self
1365    }
1366
1367    /// Apply a plugin directly to the test app.
1368    #[must_use]
1369    pub fn plugin<P: crate::plugin::Plugin>(mut self, plugin: P) -> Self {
1370        let name = plugin.name().into_owned();
1371        if self.registered_plugins.contains(&name) {
1372            tracing::warn!(plugin = %name, "Duplicate plugin registration in TestApp; skipping");
1373            return self;
1374        }
1375
1376        let mut app_builder = crate::app();
1377        app_builder
1378            .registered_plugins
1379            .clone_from(&self.registered_plugins);
1380        app_builder
1381            .plugin_asset_bundles
1382            .clone_from(&self.plugin_asset_bundles);
1383        app_builder.extensions = self.extensions;
1384        app_builder.state_initializers = std::mem::take(&mut self.state_initializers);
1385
1386        app_builder = app_builder.plugin(plugin);
1387
1388        self.registered_plugins = app_builder.registered_plugins;
1389        self.extensions = app_builder.extensions;
1390        self.state_initializers = app_builder.state_initializers;
1391
1392        // Merge properties from the plugin's app_builder into self:
1393        self.routes.extend(app_builder.routes);
1394        self.scoped_groups.extend(app_builder.scoped_groups);
1395        self.merge_routers.extend(app_builder.merge_routers);
1396        self.nest_routers.extend(app_builder.nest_routers);
1397        self.declared_routes.extend(app_builder.declared_routes);
1398        self.plugin_asset_bundles = app_builder.plugin_asset_bundles;
1399        self.custom_layers.extend(app_builder.custom_layers);
1400        self.static_gate_layers
1401            .extend(app_builder.static_gate_layers);
1402        self.jobs.extend(app_builder.jobs);
1403        self.tasks.extend(app_builder.tasks);
1404        self.listeners.extend(app_builder.listeners);
1405        #[cfg(feature = "db")]
1406        self.outbox_handlers.extend(app_builder.outbox_handlers);
1407        self.exception_filters.extend(app_builder.exception_filters);
1408        self.metrics_sources.extend(app_builder.metrics_sources);
1409        self.health_indicators.extend(app_builder.health_indicators);
1410        // Carry plugin-registered inbound mail router into the test app so
1411        // webhook plugins behave identically under TestApp.
1412        #[cfg(feature = "inbound-mail")]
1413        if let Some(router) = app_builder.inbound_mail_router {
1414            self.inbound_mail_router = Some(router);
1415        }
1416
1417        // Carry a plugin-registered suppression store (List-Unsubscribe storage)
1418        // into the test app so unsubscribe POSTs and send-time suppression behave
1419        // under TestApp exactly as they do under AppBuilder::run.
1420        #[cfg(feature = "mail")]
1421        if let Some(handle) = app_builder.suppression_store {
1422            self.suppression_store = Some(handle);
1423        }
1424
1425        // Carry a plugin-registered bounce/complaint suppression store (issue
1426        // #1247) into the test app so send-time suppression is consulted under
1427        // TestApp exactly as under AppBuilder::run — otherwise a plugin/app that
1428        // wired a PgSuppressionStore would silently test against the in-memory
1429        // default and hide production failures (e.g. a missing table).
1430        #[cfg(feature = "mail")]
1431        if let Some(handle) = app_builder.mail_suppression_store {
1432            self.mail_suppression_store = Some(handle);
1433        }
1434
1435        // Carry a plugin's `mount_unsubscribe_endpoint()` opt-in: production copies
1436        // this builder flag into config.mail before router assembly, so a plugin
1437        // that mounts the default unsubscribe endpoint must mount it under TestApp
1438        // too (otherwise /_autumn/unsubscribe 404s in tests but works in prod).
1439        #[cfg(feature = "mail")]
1440        if app_builder.mount_unsubscribe_endpoint {
1441            self.config.mail.mount_unsubscribe_endpoint = true;
1442        }
1443
1444        // Carry plugin-registered error reporters into the test app so
1445        // reporting-enabled plugins exercise the same behavior under `TestApp`
1446        // that they get from `AppBuilder::run`.
1447        #[cfg(feature = "reporting")]
1448        {
1449            let reporters = std::mem::take(&mut app_builder.error_reporters);
1450            if !reporters.is_empty() {
1451                self.state_initializers.push(Box::new(move |state| {
1452                    let mut existing = state
1453                        .extension::<crate::reporting::RegisteredReporters>()
1454                        .map(|registered| registered.0.clone())
1455                        .unwrap_or_default();
1456                    existing.extend(reporters.iter().cloned());
1457                    state.insert_extension(crate::reporting::RegisteredReporters(existing));
1458                }));
1459            }
1460        }
1461
1462        for hook in app_builder.startup_hooks {
1463            self.state_initializers.push(Box::new(move |state| {
1464                let state_owned = state.clone();
1465                if let Ok(handle) = tokio::runtime::Handle::try_current() {
1466                    let thread_handle =
1467                        std::thread::spawn(move || handle.block_on(hook(state_owned)));
1468                    thread_handle
1469                        .join()
1470                        .expect("Plugin startup hook thread panicked")
1471                        .expect("Plugin startup hook failed");
1472                } else {
1473                    let thread_handle = std::thread::spawn(move || {
1474                        let rt = tokio::runtime::Builder::new_multi_thread()
1475                            .enable_all()
1476                            .build()
1477                            .expect("failed to build tokio runtime for test plugin startup hook");
1478                        rt.block_on(hook(state_owned))
1479                    });
1480                    thread_handle
1481                        .join()
1482                        .expect("Plugin startup hook thread panicked")
1483                        .expect("Plugin startup hook failed");
1484                }
1485            }));
1486        }
1487        self
1488    }
1489
1490    #[cfg(feature = "mail")]
1491    #[must_use]
1492    pub fn with_mail_interceptor(
1493        mut self,
1494        interceptor: impl crate::interceptor::MailInterceptor,
1495    ) -> Self {
1496        self.mail_interceptor = Some(std::sync::Arc::new(interceptor));
1497        self
1498    }
1499
1500    /// Register a [`SuppressionStore`](crate::mail::SuppressionStore) so
1501    /// List-Unsubscribe sends skip suppressed recipients and the unsubscribe
1502    /// endpoint records opt-outs. Mirrors
1503    /// [`AppBuilder::with_suppression_store`](crate::app::AppBuilder::with_suppression_store).
1504    #[cfg(feature = "mail")]
1505    #[must_use]
1506    pub fn with_suppression_store(
1507        mut self,
1508        store: impl crate::mail::SuppressionStore + 'static,
1509    ) -> Self {
1510        self.suppression_store = Some(crate::mail::SuppressionStoreHandle::new(store));
1511        self
1512    }
1513
1514    /// Register a bounce/complaint
1515    /// [`SuppressionStore`](crate::mail::suppression::SuppressionStore) so
1516    /// [`Mailer::send`](crate::mail::Mailer::send) skips hard-bounced/complained
1517    /// addresses under `TestApp` exactly as it does under `AppBuilder::run`.
1518    /// Mirrors
1519    /// [`AppBuilder::with_mail_suppression_store`](crate::app::AppBuilder::with_mail_suppression_store).
1520    #[cfg(feature = "mail")]
1521    #[must_use]
1522    pub fn with_mail_suppression_store(
1523        mut self,
1524        store: impl crate::mail::suppression::SuppressionStore + 'static,
1525    ) -> Self {
1526        self.mail_suppression_store =
1527            Some(crate::mail::suppression::SuppressionStoreHandle::new(store));
1528        self
1529    }
1530
1531    /// Mount the framework's default one-click unsubscribe endpoint (opt-in).
1532    /// Mirrors
1533    /// [`AppBuilder::mount_unsubscribe_endpoint`](crate::app::AppBuilder::mount_unsubscribe_endpoint).
1534    #[cfg(feature = "mail")]
1535    #[must_use]
1536    pub const fn mount_unsubscribe_endpoint(mut self) -> Self {
1537        self.config.mail.mount_unsubscribe_endpoint = true;
1538        self
1539    }
1540
1541    #[must_use]
1542    pub fn with_job_interceptor(
1543        mut self,
1544        interceptor: impl crate::interceptor::JobInterceptor,
1545    ) -> Self {
1546        self.job_interceptor = Some(std::sync::Arc::new(interceptor));
1547        self
1548    }
1549
1550    /// Attach an authored, seed-deterministic fault schedule
1551    /// ([`FaultPlan`](crate::sim::FaultPlan), issue #1680).
1552    ///
1553    /// The plan's faults are injected through the existing
1554    /// [`interceptor`](crate::interceptor) seams, so no application code
1555    /// changes: the ordinal-th database checkout or job execution fails, exactly
1556    /// as a real transient failure would, and everything the run did is recorded
1557    /// into a serializable [`FaultOutcome`](crate::sim::FaultOutcome) reachable
1558    /// through [`TestClient::fault_outcome`] (or
1559    /// [`TestClient::fault_ledger`]).
1560    ///
1561    /// It **composes with**, and never replaces, the interceptors already in
1562    /// play: the always-on enqueue recorder still records, a
1563    /// [`with_job_interceptor`](Self::with_job_interceptor) still runs (and
1564    /// observes the injected error like a real handler failure), transactional
1565    /// database isolation is preserved, and [`Sim::chaos`](crate::sim::Sim::chaos)
1566    /// keeps working alongside. The fault decision is innermost of each chain.
1567    ///
1568    /// Attaching a plan registers an error reporter of its own, which — exactly
1569    /// like `with_error_reporter` — means the built-in `LogReporter` fallback is
1570    /// no longer installed for that app.
1571    ///
1572    /// ```rust,ignore
1573    /// use autumn_web::sim::FaultPlan;
1574    ///
1575    /// let client = TestApp::new()
1576    ///     .plugin(ChargeCardJobs) // the plugin registering `charge_card`
1577    ///     .with_fault_plan(FaultPlan::from_seed(0x5EED).fail_job("charge_card", 1))
1578    ///     .build();
1579    /// ```
1580    ///
1581    /// # Determinism
1582    ///
1583    /// Attaching a plan also defaults the app's entropy source to
1584    /// `SeededEntropy::shared(plan.seed())` when the test supplied none, so
1585    /// request ids and job-retry jitter replay from the same seed. Run the
1586    /// scenario under [`#[sim_test]`](crate::sim_test) (a paused,
1587    /// single-threaded runtime with a virtual clock) for the ordinals to be
1588    /// reproducible; see the [`fault`](crate::sim::fault) module docs.
1589    ///
1590    /// # Panics
1591    ///
1592    /// [`build`](Self::build) panics if the config would make the fault schedule
1593    /// non-reproducible: more than one job worker (`jobs.workers`), error
1594    /// reporting disabled / sampled below `1.0` (the sampler draws OS
1595    /// randomness, so a sampled-out 5xx would be missing from the outcome at
1596    /// random), or failure capture enabled (reporting awaits the capsule's
1597    /// blocking persistence before any reporter runs, so an observed 5xx could
1598    /// still be in flight when the outcome is read).
1599    #[must_use]
1600    pub fn with_fault_plan(mut self, plan: crate::sim::fault::FaultPlan) -> Self {
1601        self.fault_plan = Some(plan);
1602        self
1603    }
1604
1605    /// Register background jobs with the test app.
1606    ///
1607    /// Collect them with `jobs![..]`, exactly as in `AppBuilder::jobs`. They
1608    /// run under the in-process test job runtime that [`build`](Self::build)
1609    /// starts.
1610    #[must_use]
1611    pub fn jobs(mut self, jobs: Vec<crate::job::JobInfo>) -> Self {
1612        self.jobs.extend(jobs);
1613        self
1614    }
1615
1616    /// Register `#[scheduled]` tasks with the test app.
1617    ///
1618    /// Collect them with `tasks![..]`, exactly as in `AppBuilder::tasks`.
1619    /// [`build`](Self::build) starts them on the in-process scheduler, and
1620    /// dropping the [`TestClient`] stops them. Their timers are tokio timers
1621    /// and they read the injected clock, so under a `#[sim_test]` a tick fires
1622    /// when [`crate::sim::Sim::advance`] crosses its deadline.
1623    #[must_use]
1624    pub fn tasks(mut self, tasks: Vec<crate::task::TaskInfo>) -> Self {
1625        self.tasks.extend(tasks);
1626        self
1627    }
1628
1629    /// Install `entropy` unless the test already injected a source with
1630    /// [`with_entropy`](Self::with_entropy). [`crate::sim::Sim::build`] uses
1631    /// this to seed the app from the simulation seed by default.
1632    #[must_use]
1633    pub(crate) fn with_default_entropy(
1634        mut self,
1635        entropy: std::sync::Arc<dyn crate::entropy::Entropy>,
1636    ) -> Self {
1637        if self.entropy.is_none() {
1638            self.entropy = Some(entropy);
1639        }
1640        self
1641    }
1642
1643    /// Register event listeners with the test app.
1644    ///
1645    /// Collect them with `listeners![..]`, exactly as in `AppBuilder::listeners`.
1646    /// Durable listeners run under the in-process test job runtime; sync
1647    /// listeners run in-request. Published events are always recorded, so
1648    /// [`TestClient::assert_event_published`] works without standing up jobs.
1649    #[must_use]
1650    pub fn listeners(mut self, listeners: Vec<crate::events::ListenerInfo>) -> Self {
1651        self.listeners.extend(listeners);
1652        self
1653    }
1654
1655    /// Enable the outbox relay with `config` (it sets `enabled = true`).
1656    ///
1657    /// `build` cannot create the tables. Call
1658    /// [`crate::outbox::ensure_schema`] in the test first.
1659    #[cfg(feature = "db")]
1660    #[must_use]
1661    pub const fn with_outbox(mut self, config: crate::config::OutboxConfig) -> Self {
1662        self.config.outbox = config;
1663        self.config.outbox.enabled = true;
1664        self
1665    }
1666
1667    /// Set the outbox handler of `topic`, as `AppBuilder::outbox_handler`.
1668    ///
1669    /// The test app runs no relay worker. Drain the outbox with
1670    /// [`crate::outbox::drain`] or [`crate::sim::Sim::run_to_idle`]. Set
1671    /// `outbox.enabled = true` in the config, and create the tables with
1672    /// [`crate::outbox::ensure_schema`].
1673    ///
1674    /// # Panics
1675    ///
1676    /// Panics when `topic` is empty or starts with `autumn.`.
1677    #[cfg(feature = "db")]
1678    #[must_use]
1679    pub fn outbox_handler<F, Fut>(mut self, topic: impl Into<String>, handler: F) -> Self
1680    where
1681        F: Fn(AppState, crate::outbox::OutboxMessage) -> Fut + Send + Sync + 'static,
1682        Fut: std::future::Future<Output = crate::AutumnResult<()>> + Send + 'static,
1683    {
1684        self.outbox_handlers.insert(topic, handler);
1685        self
1686    }
1687
1688    #[cfg(feature = "db")]
1689    #[must_use]
1690    pub fn with_db_interceptor(
1691        mut self,
1692        interceptor: impl crate::interceptor::DbConnectionInterceptor,
1693    ) -> Self {
1694        self.db_interceptor = Some(std::sync::Arc::new(interceptor));
1695        self
1696    }
1697
1698    #[cfg(feature = "ws")]
1699    #[must_use]
1700    pub fn with_channels_interceptor(
1701        mut self,
1702        interceptor: impl crate::interceptor::ChannelsInterceptor,
1703    ) -> Self {
1704        self.channels_interceptor = Some(std::sync::Arc::new(interceptor));
1705        self
1706    }
1707
1708    /// Opt in to recording every channel broadcast published while requests
1709    /// run, enabling [`TestClient::broadcasts`],
1710    /// [`TestClient::broadcasts_on`], and the `assert_broadcast*` helpers.
1711    ///
1712    /// No interceptor is installed — and channel publishing is untouched —
1713    /// unless this is called. Composes with a user-supplied
1714    /// [`with_channels_interceptor`](Self::with_channels_interceptor): the
1715    /// recorder runs first, then the user's interceptor.
1716    #[cfg(feature = "ws")]
1717    #[must_use]
1718    pub fn record_broadcasts(mut self) -> Self {
1719        self.broadcast_recorder = Some(BroadcastRecorder::new());
1720        self
1721    }
1722
1723    #[cfg(feature = "oauth2")]
1724    #[must_use]
1725    pub fn with_http_interceptor(
1726        mut self,
1727        interceptor: impl crate::interceptor::HttpInterceptor,
1728    ) -> Self {
1729        self.http_interceptor = Some(std::sync::Arc::new(interceptor));
1730        self
1731    }
1732
1733    /// Trust `host` as a request host, so a sim replica serves calls that
1734    /// other replicas send to it by name (issue #3067).
1735    #[cfg(feature = "http-client")]
1736    pub(crate) fn trust_host(mut self, host: &str) -> Self {
1737        self.config
1738            .security
1739            .trusted_hosts
1740            .hosts
1741            .push(host.to_owned());
1742        self
1743    }
1744
1745    /// Override the default test configuration.
1746    #[must_use]
1747    pub fn config(mut self, config: AutumnConfig) -> Self {
1748        self.config = config;
1749        self
1750    }
1751
1752    /// Set the active profile (default is `"test"`).
1753    #[must_use]
1754    pub fn profile(mut self, profile: &str) -> Self {
1755        self.config.profile = Some(profile.to_owned());
1756        self
1757    }
1758
1759    /// Inject a custom clock into the test app.
1760    ///
1761    /// All handlers that take a [`crate::time::Clock`] extractor will see time
1762    /// as reported by `clock`. Use [`crate::time::FixedClock`] to pin time to
1763    /// a known instant, or [`crate::time::TickingClock`] when you need to step
1764    /// the clock forward between requests via
1765    /// [`TestClient::advance_clock`].
1766    ///
1767    /// ```rust,no_run
1768    /// use autumn_web::test::TestApp;
1769    /// use autumn_web::time::{FixedClock, TickingClock};
1770    /// use chrono::{TimeZone, Utc};
1771    ///
1772    /// // Pin to a fixed instant:
1773    /// let _client = TestApp::new()
1774    ///     .with_clock(FixedClock::at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()))
1775    ///     .build();
1776    ///
1777    /// // Step forward in time:
1778    /// let clock = TickingClock::starting_at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap());
1779    /// let client = TestApp::new()
1780    ///     .with_clock(clock.clone())
1781    ///     .build();
1782    /// client.advance_clock(std::time::Duration::from_secs(3600));
1783    /// ```
1784    #[must_use]
1785    pub fn with_clock<C>(mut self, clock: C) -> Self
1786    where
1787        C: crate::time::ClockSource + 'static,
1788    {
1789        let arc: std::sync::Arc<C> = std::sync::Arc::new(clock);
1790        // Retain as dyn Any so TestClient::advance_clock can downcast to TickingClock.
1791        self.clock_as_any = Some(arc.clone() as std::sync::Arc<dyn std::any::Any + Send + Sync>);
1792        self.clock = Some(arc as std::sync::Arc<dyn crate::time::ClockSource>);
1793        self
1794    }
1795
1796    /// Inject a custom entropy source into the test app.
1797    ///
1798    /// All handlers that take a [`crate::entropy::Rng`] extractor — and every
1799    /// framework-minted identifier (request ids, session ids, idempotency lock
1800    /// owners, job ids) — draw from `entropy`. Pass a
1801    /// [`crate::entropy::SeededEntropy`] to make the whole app's identifier
1802    /// stream byte-for-byte reproducible under a fixed seed. Mirrors
1803    /// [`Self::with_clock`].
1804    ///
1805    /// ```rust,no_run
1806    /// use autumn_web::entropy::SeededEntropy;
1807    /// use autumn_web::test::TestApp;
1808    ///
1809    /// let _client = TestApp::new()
1810    ///     .with_entropy(SeededEntropy::new(0x5eed))
1811    ///     .build();
1812    /// ```
1813    #[must_use]
1814    pub fn with_entropy<E>(mut self, entropy: E) -> Self
1815    where
1816        E: crate::entropy::Entropy + 'static,
1817    {
1818        self.entropy = Some(std::sync::Arc::new(entropy));
1819        self
1820    }
1821
1822    /// Register a single API version for testing.
1823    #[must_use]
1824    pub fn api_version(mut self, version: crate::app::ApiVersion) -> Self {
1825        self.api_versions.push(version);
1826        self
1827    }
1828
1829    /// Publish an agent skill, as [`AppBuilder::agent_skill`](crate::app::AppBuilder::agent_skill).
1830    #[must_use]
1831    pub fn agent_skill(mut self, skill: crate::aeo::AgentSkill) -> Self {
1832        self.agent_skills.push(skill);
1833        self
1834    }
1835
1836    /// Serve a UCP profile, as [`AppBuilder::ucp_profile`](crate::app::AppBuilder::ucp_profile).
1837    #[must_use]
1838    pub fn ucp_profile(mut self, profile: crate::aeo::commerce::UcpProfile) -> Self {
1839        self.commerce_docs.ucp = Some(profile);
1840        self
1841    }
1842
1843    /// Serve an ACP document, as [`AppBuilder::acp_discovery`](crate::app::AppBuilder::acp_discovery).
1844    #[must_use]
1845    pub fn acp_discovery(mut self, document: crate::aeo::commerce::AcpDiscovery) -> Self {
1846        self.commerce_docs.acp = Some(document);
1847        self
1848    }
1849
1850    /// Register multiple API versions for testing.
1851    #[must_use]
1852    pub fn api_versions(
1853        mut self,
1854        versions: impl IntoIterator<Item = crate::app::ApiVersion>,
1855    ) -> Self {
1856        self.api_versions.extend(versions);
1857        self
1858    }
1859
1860    /// Attach a database connection pool to the test app.
1861    #[cfg(feature = "db")]
1862    #[must_use]
1863    pub fn with_db(mut self, pool: Pool<crate::db::RuntimeConnection>) -> Self {
1864        self.pool = Some(pool);
1865        self
1866    }
1867
1868    /// Enable transactional test isolation using the database URL configured
1869    /// in the application's configuration.
1870    #[cfg(feature = "db")]
1871    #[must_use]
1872    pub const fn transactional(mut self) -> Self {
1873        self.transactional = true;
1874        self
1875    }
1876
1877    /// Enable transactional test isolation with an explicit database URL.
1878    #[cfg(feature = "db")]
1879    #[must_use]
1880    pub fn with_transactional_db(mut self, url: impl Into<String>) -> Self {
1881        self.transactional = true;
1882        self.transactional_url = Some(url.into());
1883        self
1884    }
1885
1886    /// Configure the application's horizontal shards programmatically, as if
1887    /// they were declared via `[[database.shards]]` in `autumn.toml`.
1888    ///
1889    /// This is the escape hatch for tests that spin up shard databases at
1890    /// runtime (e.g. one Postgres container per shard) and need to point the
1891    /// app at them without writing a config file. Combine with
1892    /// [`transactional`](Self::transactional) to get rolled-back shard writes.
1893    ///
1894    /// ```rust,no_run
1895    /// use autumn_web::test::TestApp;
1896    /// use autumn_web::config::ShardConfig;
1897    ///
1898    /// # fn example(shard0: String, shard1: String) {
1899    /// let client = TestApp::new()
1900    ///     .with_transactional_db("postgres://localhost/control")
1901    ///     .with_shards(vec![
1902    ///         ShardConfig { name: "shard0".into(), primary_url: shard0, ..Default::default() },
1903    ///         ShardConfig { name: "shard1".into(), primary_url: shard1, ..Default::default() },
1904    ///     ])
1905    ///     .build();
1906    /// # let _ = client;
1907    /// # }
1908    /// ```
1909    #[cfg(feature = "db")]
1910    #[must_use]
1911    pub fn with_shards(mut self, shards: Vec<crate::config::ShardConfig>) -> Self {
1912        self.config.database.shards = shards;
1913        self
1914    }
1915
1916    /// Route tenant data through a `SQLite` fleet (ADR 0019): the fleet
1917    /// becomes the app's shard set, so [`ShardedDb`](crate::sharding::ShardedDb),
1918    /// [`Shards`](crate::sharding::Shards) and `#[repository(sharded)]` open
1919    /// each tenant's (or slot's) own database. Needs a control pool
1920    /// ([`with_db`](Self::with_db)), which seeds cross-shard repositories.
1921    #[cfg(feature = "sqlite")]
1922    #[must_use]
1923    pub fn with_fleet(mut self, fleet: crate::db::fleet::DatabaseFleet) -> Self {
1924        self.fleet = Some(fleet);
1925        self
1926    }
1927
1928    /// Register a canned HTTP response for outbound requests made via the
1929    /// [`Client`](crate::http_client::Client) extractor during this test.
1930    ///
1931    /// `alias` identifies the named service (must match the alias passed to
1932    /// [`Client::named`](crate::http_client::Client::named) in the handler, or
1933    /// the key used in `[http.client.base_urls]`).
1934    ///
1935    /// Returns a [`MockSetupBuilder`](crate::http_client::MockSetupBuilder) on
1936    /// which you chain the HTTP method and path before calling
1937    /// [`respond_with`](crate::http_client::MockSetupBuilder::respond_with) to
1938    /// register the entry and get a
1939    /// [`MockHandle`](crate::http_client::MockHandle) for later assertions.
1940    ///
1941    /// # Examples
1942    ///
1943    /// ```rust,no_run
1944    /// use autumn_web::test::TestApp;
1945    /// use serde_json::json;
1946    ///
1947    /// # async fn example() {
1948    /// let mut app = TestApp::new();
1949    /// let mock = app
1950    ///     .http_mock("stripe")
1951    ///     .post("/v1/charges")
1952    ///     .respond_with(200, json!({"id": "ch_123", "amount": 1000}));
1953    ///
1954    /// let client = app.build();
1955    /// // … fire requests …
1956    /// mock.expect_called(1);
1957    /// # }
1958    /// ```
1959    #[cfg(feature = "http-client")]
1960    pub fn http_mock(&mut self, alias: &str) -> crate::http_client::MockSetupBuilder {
1961        let registry = self
1962            .http_mock_registry
1963            .get_or_insert_with(|| std::sync::Arc::new(crate::http_client::MockRegistry::new()))
1964            .clone();
1965
1966        crate::http_client::MockSetupBuilder {
1967            registry,
1968            alias: alias.to_owned(),
1969            method: None,
1970            path: None,
1971        }
1972    }
1973
1974    /// Route outbound HTTP through `net`. Set by [`Sim::net`](crate::sim::Sim::net).
1975    #[cfg(feature = "http-client")]
1976    pub(crate) fn with_sim_net(mut self, net: crate::sim::SimNet) -> Self {
1977        self.sim_net = Some(net);
1978        self
1979    }
1980
1981    /// Build the application and return a [`TestClient`] ready for requests.
1982    ///
1983    /// This constructs the full Axum router with all middleware applied,
1984    /// identical to what `AppBuilder::run()` produces -- without binding
1985    /// a TCP listener.
1986    ///
1987    /// The process-level global cache is cleared unconditionally so that
1988    /// `#[cached]` functions inside this test app always use their
1989    /// per-function Moka stores and do not accidentally inherit a Redis or
1990    /// other shared backend installed by a previous test.
1991    #[must_use]
1992    #[cfg_attr(not(feature = "inbound-mail"), allow(unused_mut))]
1993    pub fn build(mut self) -> TestClient {
1994        // Reset the global cache to prevent cross-test contamination. Briefly
1995        // held so this can't land mid-flight inside another same-process
1996        // test's own global-cache critical section (issue #2218) — see
1997        // `GLOBAL_CACHE_TEST_LOCK`'s doc comment.
1998        {
1999            let _guard = crate::cache::GLOBAL_CACHE_TEST_LOCK
2000                .lock()
2001                .unwrap_or_else(std::sync::PoisonError::into_inner);
2002            crate::cache::clear_global_cache();
2003        }
2004        // Reset the global event bus so a prior test's listeners/recorder do not
2005        // leak into this one (it is re-installed below).
2006        crate::events::clear_global_event_bus();
2007
2008        // An attached fault plan (issue #1680) only replays byte-for-byte when
2009        // the surrounding config cannot reorder, drop, or delay what it records,
2010        // so the knobs that would are checked up front rather than silently
2011        // producing a flaky scenario.
2012        if let Some(plan) = self.fault_plan.as_ref() {
2013            assert_eq!(
2014                self.config.jobs.workers, 1,
2015                "a fault plan needs `jobs.workers = 1`: concurrent workers can swap \
2016                 which execution is the Nth, so the ordinals would not replay"
2017            );
2018            #[cfg(feature = "reporting")]
2019            assert!(
2020                self.config.reporting.enabled && self.config.reporting.sample_rate >= 1.0,
2021                "a fault plan needs `reporting.enabled = true` and \
2022                 `reporting.sample_rate = 1.0`: the sampler draws OS randomness, so a \
2023                 sampled-out 5xx would drop out of `FaultOutcome::server_errors` at random"
2024            );
2025            // Codex review (round 2): with capture on, `ReporterChain::dispatch`
2026            // awaits the capsule's blocking persistence (a directory scan, a
2027            // write and a `sync_all` on the blocking pool) BEFORE any reporter
2028            // runs, so `fault_outcome()`'s cooperative settle could snapshot
2029            // while that write is still in flight on slow storage and miss a
2030            // 5xx the client already observed. Capsules are production
2031            // evidence with no place in a fault scenario, so refuse the
2032            // combination rather than make the settle depend on disk speed.
2033            #[cfg(feature = "reporting")]
2034            assert!(
2035                !self.config.failure_capture.enabled,
2036                "a fault plan needs `failure_capture.enabled = false`: reporting awaits \
2037                 the capsule's blocking persistence before any reporter runs, so a 5xx \
2038                 could be missing from `FaultOutcome::server_errors` when the outcome is \
2039                 read"
2040            );
2041            // Replay the app's identifier stream (request ids, job-retry jitter)
2042            // from the plan's seed unless the test injected its own source.
2043            if self.entropy.is_none() {
2044                self.entropy = Some(crate::entropy::SeededEntropy::shared(plan.seed()));
2045            }
2046        }
2047
2048        // Postgres transactional test isolation (`begin_test_transaction` +
2049        // SAVEPOINT rollback on a `max_size(1)` control pool) is Postgres-only;
2050        // SQLite has no equivalent, so under the `sqlite` feature the harness
2051        // uses the configured pool directly (no per-test rollback isolation).
2052        #[cfg(all(feature = "db", feature = "sqlite"))]
2053        let (pool, replica_pool, db_interceptor) = {
2054            let _ = self.transactional;
2055            // SQLite has no equivalent of the Postgres transactional-rollback
2056            // isolation (`begin_test_transaction` + SAVEPOINT on a `max_size(1)`
2057            // control pool), so a SQLite test DB gets a real pool but NOT
2058            // per-test transactional isolation. Even so, `with_transactional_db`
2059            // records an explicit SQLite database URL, and dropping it here would
2060            // leave a `TestApp` built that way with no pool at all -- every route
2061            // using the `Db` extractor would then return 503. So when no pool was
2062            // attached via `with_db` but an explicit URL was given, build a plain
2063            // (non-transactional) SQLite pool from it, reusing the runtime
2064            // `create_pool` path so the pool matches production behavior.
2065            let pool = if let Some(pool) = self.pool {
2066                Some(pool)
2067            } else if let Some(url) = self.transactional_url.as_deref() {
2068                let mut db_config = self.config.database.clone();
2069                db_config.primary_url = Some(url.to_owned());
2070                Some(
2071                    crate::db::create_pool(&db_config)
2072                        .expect("failed to build SQLite test pool from with_transactional_db URL")
2073                        .expect(
2074                            "with_transactional_db URL did not yield a SQLite pool (empty URL?)",
2075                        ),
2076                )
2077            } else {
2078                None
2079            };
2080            (pool, self.replica_pool, self.db_interceptor)
2081        };
2082        #[cfg(all(feature = "db", not(feature = "sqlite")))]
2083        let (pool, replica_pool, db_interceptor) = if self.transactional {
2084            let url = self.transactional_url.as_deref()
2085                .or_else(|| self.config.database.effective_primary_url())
2086                .expect("Transactional isolation enabled but database URL is not configured. Use `with_transactional_db(url)` or configure database.primary_url/database.url");
2087
2088            let connect_timeout_secs = self.config.database.connect_timeout_secs;
2089            let timeout = std::time::Duration::from_secs(connect_timeout_secs);
2090
2091            let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::<
2092                diesel_async::AsyncPgConnection,
2093            >::new(url);
2094            let pool = Pool::builder(manager)
2095                .max_size(1)
2096                .wait_timeout(Some(timeout))
2097                .create_timeout(Some(timeout))
2098                .runtime(deadpool::Runtime::Tokio1)
2099                .post_create(deadpool::managed::Hook::async_fn(
2100                    |conn: &mut diesel_async::AsyncPgConnection, _metrics| {
2101                        Box::pin(async move {
2102                            use diesel_async::AsyncConnection;
2103                            use diesel_async::RunQueryDsl;
2104
2105                            conn.begin_test_transaction().await.map_err(|e| {
2106                                deadpool::managed::HookError::Backend(
2107                                    diesel_async::pooled_connection::PoolError::QueryError(e),
2108                                )
2109                            })?;
2110
2111                            diesel::sql_query("SET autumn.test_transaction_started = 'true'")
2112                                .execute(conn)
2113                                .await
2114                                .map_err(|e| {
2115                                    deadpool::managed::HookError::Backend(
2116                                        diesel_async::pooled_connection::PoolError::QueryError(e),
2117                                    )
2118                                })?;
2119
2120                            Ok(())
2121                        })
2122                    },
2123                ))
2124                .build()
2125                .expect("failed to build transactional pool of size 1");
2126
2127            let trans_interceptor = std::sync::Arc::new(TransactionalDbInterceptor);
2128            let interceptor = if let Some(user_interceptor) = self.db_interceptor {
2129                std::sync::Arc::new(ComposedDbInterceptor {
2130                    first: user_interceptor,
2131                    second: trans_interceptor,
2132                })
2133                    as std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>
2134            } else {
2135                trans_interceptor as std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>
2136            };
2137
2138            (Some(pool), None, Some(interceptor))
2139        } else {
2140            (self.pool, self.replica_pool, self.db_interceptor)
2141        };
2142
2143        // Mirror production router selection (see `setup_database`): when the
2144        // test config enables directory routing, build a `DirectoryShardRouter`
2145        // over the control pool so tests that pin tenants in
2146        // `_autumn_shard_directory` route the same way production would.
2147        #[cfg(feature = "db")]
2148        let shard_router: std::sync::Arc<dyn crate::sharding::ShardRouter> =
2149            match (self.config.database.directory_shard_router, &pool) {
2150                (true, Some(control_pool)) => {
2151                    let timeout_ms = self.config.database.statement_timeout.map_or(0, |d| {
2152                        u64::try_from(d.as_millis())
2153                            .unwrap_or(i32::MAX as u64)
2154                            .min(i32::MAX as u64)
2155                    });
2156                    std::sync::Arc::new(
2157                        crate::sharding::DirectoryShardRouter::new(control_pool.clone())
2158                            .with_statement_timeout_ms(timeout_ms),
2159                    )
2160                }
2161                // Production `setup_database` errors here (the directory router
2162                // needs a control DB), so fail the test app the same way rather
2163                // than silently routing by hash and passing a test the deployed
2164                // app would fail.
2165                (true, None) => panic!(
2166                    "directory_shard_router is enabled but TestApp has no control database pool; \
2167                     configure a control pool (with_db) or disable directory routing"
2168                ),
2169                (false, _) => std::sync::Arc::new(crate::sharding::HashShardRouter),
2170            };
2171
2172        #[cfg(feature = "sqlite")]
2173        let fleet_shards = self.fleet.take().map(|fleet| {
2174            crate::sharding::ShardSet::from_fleet(
2175                fleet,
2176                pool.clone()
2177                    .expect("TestApp::with_fleet needs a control pool: call with_db"),
2178            )
2179        });
2180
2181        let probes = crate::probe::ProbeState::ready_for_test();
2182        #[cfg(feature = "ws")]
2183        let test_channels = crate::channels::Channels::new(32);
2184        // Shared with the collaboration hub, for the reason `app.rs` gives.
2185        #[cfg(feature = "presence")]
2186        let test_presence = crate::presence::Presence::new(test_channels.clone());
2187        // Resolve the injected clock BEFORE the state literal so `started_at`
2188        // is stamped on the same timeline the app will read time from. A sim
2189        // installs a virtual clock here, and uptime has to start at that
2190        // clock's origin rather than at real process time.
2191        let clock: std::sync::Arc<dyn crate::time::ClockSource> = self
2192            .clock
2193            .unwrap_or_else(|| std::sync::Arc::new(crate::time::SystemClock));
2194        let started_at = clock.monotonic();
2195
2196        // The fault ledger is created here, per build, from the RESOLVED clock:
2197        // a sim installs its virtual clock immediately before `build`, and a
2198        // `Sim::kill`/`restart` rebuilds, so counting restarts with the app.
2199        let fault_ledger = self.fault_plan.as_ref().map(|plan| {
2200            crate::sim::fault::FaultLedger::new(plan, std::sync::Arc::clone(&clock), started_at)
2201        });
2202
2203        #[cfg_attr(not(feature = "ws"), allow(unused_mut))]
2204        let mut state = AppState {
2205            extensions: std::sync::Arc::new(std::sync::RwLock::new(
2206                std::collections::HashMap::new(),
2207            )),
2208            #[cfg(feature = "db")]
2209            pool,
2210            #[cfg(feature = "db")]
2211            replica_pool,
2212            // Build the shard set from the test config so handlers using
2213            // the sharding extractors behave as they would in production.
2214            // Pools are lazy, so this needs no running databases.
2215            //
2216            // Under transactional isolation each shard primary pool is built
2217            // with `max_size(1)` and a `begin_test_transaction` hook (mirroring
2218            // the control pool above) so writes routed to a shard are rolled
2219            // back at the end of the test — the same isolation the control pool
2220            // gets. Replicas are skipped; all shard reads run on the primary.
2221            #[cfg(all(feature = "db", not(feature = "sqlite")))]
2222            shards: if self.transactional {
2223                crate::sharding::create_shard_set_transactional(
2224                    &self.config.database,
2225                    shard_router.clone(),
2226                )
2227                .expect("transactional test shard pools should build from config")
2228            } else {
2229                crate::sharding::create_shard_set(&self.config.database, shard_router.clone())
2230                    .expect("test shard pools should build from config")
2231            },
2232            // The transactional shard-set builder is Postgres-only (per-shard
2233            // `begin_test_transaction` isolation); under the `sqlite` feature the
2234            // harness always uses the plain builder (no shard rollback isolation).
2235            #[cfg(all(feature = "db", feature = "sqlite"))]
2236            shards: match fleet_shards {
2237                Some(shards) => Some(shards),
2238                None => {
2239                    crate::sharding::create_shard_set(&self.config.database, shard_router.clone())
2240                        .expect("test shard pools should build from config")
2241                }
2242            },
2243            // The test harness attaches pools directly (`with_pool`), without
2244            // a topology to carry a capture gap; a DB test that needs the gap
2245            // noted asserts through the production seam instead.
2246            #[cfg(all(feature = "db", feature = "reporting"))]
2247            db_capture_gap: None,
2248            profile: self.config.profile.as_deref().map(std::sync::Arc::from),
2249            role: self.config.role,
2250            started_at,
2251            health_detailed: self.config.health.detailed,
2252            probes: probes.clone(),
2253            metrics: crate::middleware::MetricsCollector::new(),
2254            log_levels: crate::actuator::LogLevels::new(&self.config.log.level),
2255            task_registry: crate::actuator::TaskRegistry::new(),
2256            // Built from the resolved clock, not `JobRegistry::new()`: the queue
2257            // gauges compare ready-at marks the job runtime stamps from this
2258            // same clock. This literal bypasses `AppState::with_clock`, so
2259            // leaving it on the default real clock is what made a sim's delayed
2260            // job read as ready the instant it was enqueued.
2261            job_registry: crate::actuator::JobRegistry::new()
2262                .with_clock(std::sync::Arc::clone(&clock)),
2263            config_props: crate::actuator::ConfigProperties::default(),
2264            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
2265            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
2266            #[cfg(all(feature = "collab", feature = "presence"))]
2267            collab: crate::collab::CollabHub::new(test_channels.clone(), test_presence.clone()),
2268            #[cfg(feature = "presence")]
2269            presence: test_presence,
2270            #[cfg(feature = "ws")]
2271            channels: test_channels,
2272
2273            policy_registry: crate::authorization::PolicyRegistry::default(),
2274            forbidden_response: self
2275                .forbidden_response_override
2276                .unwrap_or(self.config.security.forbidden_response),
2277            auth_session_key: std::sync::Arc::from(self.config.auth.session_key.as_str()),
2278            shared_cache: None,
2279            clock,
2280            entropy: self
2281                .entropy
2282                .unwrap_or_else(|| std::sync::Arc::new(crate::entropy::OsEntropy)),
2283            app_id: crate::state::AppState::next_app_id(),
2284        };
2285
2286        // Mirror `App::run`'s failure-capsule clock wiring (#1598): the layer
2287        // itself is installed by the shared router builder, but the recording
2288        // clock replaces the state's clock, which the router never owns.
2289        #[cfg(feature = "reporting")]
2290        if self.config.failure_capture.enabled {
2291            let recording =
2292                std::sync::Arc::new(crate::capsule::RecordingClock::new(state.clock_arc()))
2293                    as std::sync::Arc<dyn crate::time::ClockSource>;
2294            state = state.with_clock(recording);
2295        }
2296        // Same for the entropy source (#1634): a handler that mints a session
2297        // id, a token or a job id must mint the *recorded* one on replay, or
2298        // the identifier in the capsule's SQL binds will not be the one the
2299        // replayed code produced.
2300        #[cfg(feature = "reporting")]
2301        if self.config.failure_capture.enabled {
2302            let recording =
2303                std::sync::Arc::new(crate::capsule::RecordingEntropy::new(state.entropy_arc()))
2304                    as std::sync::Arc<dyn crate::entropy::Entropy>;
2305            state = state.with_entropy(recording);
2306        }
2307
2308        for register in self.policy_registrations {
2309            register(state.policy_registry());
2310        }
2311        state.insert_extension(crate::app::RegisteredApiVersions(self.api_versions));
2312        state.insert_extension(crate::aeo::RegisteredAgentSkills(self.agent_skills));
2313        state.insert_extension(self.commerce_docs);
2314        crate::app::install_webhook_registry(&state, &self.config);
2315
2316        // Install AutumnConfig so DbState::statement_timeout / slow_query_threshold
2317        // and HTTP Client resilience can read the test-supplied config.
2318        state.insert_extension(self.config.clone());
2319        // A sim kill stops the app's background tasks through this (#3067).
2320        state.insert_extension(crate::sim::AppTasks::default());
2321
2322        #[cfg(feature = "mail")]
2323        let mail_recorder_for_client = {
2324            let recorder_for_client = self.mail_recorder.clone();
2325            let recorder = std::sync::Arc::new(self.mail_recorder);
2326            let effective: std::sync::Arc<dyn crate::interceptor::MailInterceptor> =
2327                if let Some(user) = self.mail_interceptor {
2328                    std::sync::Arc::new(ChainedMailInterceptor {
2329                        first: recorder,
2330                        second: user,
2331                    })
2332                } else {
2333                    recorder
2334                };
2335            state.insert_extension(effective);
2336            recorder_for_client
2337        };
2338        // Always install the job recorder so `enqueued_jobs`/`assert_job_*` and
2339        // `perform_enqueued_jobs` work with no opt-in. The recorder runs first
2340        // and composes with any user-supplied `with_job_interceptor` (which
2341        // still runs, after the recorder). A single `Arc<dyn JobInterceptor>`
2342        // extension is what the job runtime reads, so we chain rather than
2343        // install two.
2344        let job_recorder_for_client = {
2345            let recorder_for_client = self.job_recorder.clone();
2346            let recorder: std::sync::Arc<dyn crate::interceptor::JobInterceptor> =
2347                std::sync::Arc::new(self.job_recorder);
2348            // Chain order is recorder → user → fault plan, so an attached
2349            // `FaultPlan` sits INNERMOST: a user interceptor observes the
2350            // injected error exactly as it would a real handler failure, and
2351            // the recorder still sees every enqueue.
2352            let mut inner = self.job_interceptor;
2353            if let Some(ledger) = fault_ledger.as_ref() {
2354                let fault = ledger.job_interceptor();
2355                inner = Some(match inner {
2356                    Some(user) => std::sync::Arc::new(ChainedJobInterceptor {
2357                        first: user,
2358                        second: fault,
2359                    }),
2360                    None => fault,
2361                });
2362            }
2363            let effective: std::sync::Arc<dyn crate::interceptor::JobInterceptor> =
2364                if let Some(inner) = inner {
2365                    std::sync::Arc::new(ChainedJobInterceptor {
2366                        first: recorder,
2367                        second: inner,
2368                    })
2369                } else {
2370                    recorder
2371                };
2372            state.insert_extension(effective);
2373            recorder_for_client
2374        };
2375        #[cfg(feature = "db")]
2376        {
2377            // The single `Arc<dyn DbConnectionInterceptor>` extension the
2378            // checkout path reads. An attached `FaultPlan` WRAPS whatever was
2379            // already composed (the user's interceptor, transactional test
2380            // isolation, or both) and runs its decision innermost, forwarding
2381            // `is_transactional_test` so rollback isolation survives. Written
2382            // without `ComposedDbInterceptor`, which does not exist under the
2383            // `sqlite` feature.
2384            let db_interceptor = match fault_ledger.as_ref() {
2385                Some(ledger) => Some(ledger.db_interceptor(db_interceptor)),
2386                None => db_interceptor,
2387            };
2388            if let Some(interceptor) = db_interceptor {
2389                state.insert_extension(interceptor);
2390            }
2391        }
2392        #[cfg(feature = "ws")]
2393        let broadcast_recorder_for_client = {
2394            let mut interceptors: Vec<std::sync::Arc<dyn crate::interceptor::ChannelsInterceptor>> =
2395                Vec::new();
2396
2397            // Recorder runs first so it observes every publish before any
2398            // user-supplied interceptor can short-circuit the chain.
2399            let recorder_for_client = self.broadcast_recorder.clone();
2400            if let Some(recorder) = self.broadcast_recorder {
2401                interceptors.push(std::sync::Arc::new(recorder));
2402            }
2403            if let Some(interceptor) = self.channels_interceptor {
2404                // Preserve the existing `insert_extension` behavior so the
2405                // user's interceptor is discoverable from state.
2406                state.insert_extension(interceptor.clone());
2407                interceptors.push(interceptor);
2408            }
2409
2410            // AC6: install nothing (and leave production `Channels` untouched)
2411            // unless at least one interceptor was requested.
2412            if !interceptors.is_empty() {
2413                state.channels = crate::channels::Channels::with_shared_backend(
2414                    std::sync::Arc::new(crate::channels::InterceptedChannelsBackend::new(
2415                        state.channels.backend().clone(),
2416                        interceptors,
2417                    )),
2418                );
2419                #[cfg(feature = "presence")]
2420                {
2421                    state.presence = crate::presence::Presence::new(state.channels.clone());
2422                    // Same reason as `app.rs`: the hub captured the old pair.
2423                    #[cfg(feature = "collab")]
2424                    {
2425                        state.collab = crate::collab::CollabHub::new(
2426                            state.channels.clone(),
2427                            state.presence.clone(),
2428                        );
2429                    }
2430                }
2431            }
2432            recorder_for_client
2433        };
2434        #[cfg(feature = "oauth2")]
2435        if let Some(interceptor) = self.http_interceptor {
2436            state.insert_extension(interceptor);
2437        }
2438
2439        // Before the mailer: with `outbox.enabled`, `deliver_later` goes
2440        // through the outbox queue the relay installs.
2441        #[cfg(feature = "db")]
2442        crate::outbox::install(
2443            &state,
2444            &self.config.outbox,
2445            std::mem::take(&mut self.outbox_handlers),
2446        );
2447
2448        #[cfg(feature = "mail")]
2449        {
2450            if let Some(handle) = self.suppression_store.clone() {
2451                state.insert_extension(handle);
2452            }
2453            // Mirror AppBuilder::run: register the bounce/complaint suppression
2454            // handle before install_mailer so the test mailer actually consults
2455            // it (install_mailer reads it back via the extension).
2456            if let Some(handle) = self.mail_suppression_store.clone() {
2457                state.insert_extension(handle);
2458            }
2459            crate::mail::install_mailer(&state, &self.config.mail, false)
2460                .expect("Failed to configure test mailer");
2461        }
2462
2463        // Install HTTP client config so the Client extractor can read it.
2464        #[cfg(feature = "http-client")]
2465        state.insert_extension(self.config.http.clone());
2466
2467        // Register the shared reqwest::Client so Client::from_state reuses the
2468        // connection pool in tests, mirroring the production build_state path.
2469        #[cfg(feature = "http-client")]
2470        state.insert_extension(crate::http_client::SharedReqwestClient {
2471            client: crate::http_client::Client::build_inner(&self.config.http.client),
2472            timeout_secs: self.config.http.client.timeout_secs,
2473        });
2474        #[cfg(feature = "http-client")]
2475        crate::http_client::install_shared_throttle(&state, &self.config.http.client);
2476
2477        // Install mock registry when http_mock() was called.
2478        #[cfg(feature = "http-client")]
2479        if let Some(registry) = self.http_mock_registry {
2480            state.insert_extension(crate::http_client::HttpMockRegistryExt(registry));
2481        }
2482        #[cfg(feature = "http-client")]
2483        if let Some(net) = self.sim_net.take() {
2484            state.insert_extension(net);
2485        }
2486
2487        // Register metrics sources before state initializers — mirrors production
2488        // AppBuilder::run ordering so initializers can observe the registry.
2489        for (name, source) in self.metrics_sources {
2490            if let Err(e) = state.metrics_source_registry.register(name, source) {
2491                tracing::warn!("{e}");
2492            }
2493        }
2494        for (name, group, indicator) in self.health_indicators {
2495            if let Err(e) = state
2496                .health_indicator_registry
2497                .register(name, group, indicator)
2498            {
2499                tracing::warn!("{e}");
2500            }
2501        }
2502        // Mirror production `build_state`: the `[health]` cache TTL, ping
2503        // time limit and database readiness gate.
2504        state.apply_health_config(&self.config.health);
2505        // Mirror production `AppBuilder` wiring: surface each configured shard's
2506        // replica readiness as a `db:shard:<name>` indicator so `/ready`
2507        // refreshes shard replica health (gating `fail_readiness` shards and
2508        // marking healthy replicas ready for `ShardedDb` read routing).
2509        #[cfg(feature = "db")]
2510        if let Some(set) = state.shards() {
2511            crate::sharding::register_shard_health_indicators(
2512                set,
2513                &state.health_indicator_registry,
2514                self.config.health.ping_timeout(),
2515            );
2516        }
2517
2518        for initializer in self.state_initializers {
2519            initializer(&state);
2520        }
2521        crate::cost::install(&state, &self.config);
2522
2523        // Register the fault plan's 5xx projector alongside the app's own
2524        // reporters (which keep receiving every event). It goes FIRST in the
2525        // chain: `ReporterChain::report_all` awaits the reporters one after
2526        // another, so a user reporter whose future stays pending (one waiting
2527        // on a timer under a paused sim, say) would otherwise starve the
2528        // projector and the observed 5xx would never reach
2529        // `FaultOutcome::server_errors`. The projector records as its future is
2530        // built and resolves immediately, so leading the chain delays nobody.
2531        // Must land before the router is built, which is where
2532        // `ReportingLayer` reads the chain.
2533        #[cfg(feature = "reporting")]
2534        if let Some(ledger) = fault_ledger.as_ref() {
2535            let mut reporters = state
2536                .extension::<crate::reporting::RegisteredReporters>()
2537                .map(|registered| registered.0.clone())
2538                .unwrap_or_default();
2539            reporters.insert(0, ledger.reporter());
2540            state.insert_extension(crate::reporting::RegisteredReporters(reporters));
2541        }
2542
2543        // Wire the event bus: always install a recorder so tests can assert on
2544        // published events without a job runner, register the listener registry
2545        // for the `Events` extractor, and fold durable listeners into the jobs
2546        // started below so they dispatch through the in-process test runtime.
2547        state.insert_extension(crate::events::EventRecorder::default());
2548        let event_recorder = state
2549            .extension::<crate::events::EventRecorder>()
2550            .expect("event recorder just installed");
2551        let event_registry =
2552            crate::events::EventRegistry::from_listeners(std::mem::take(&mut self.listeners));
2553        self.jobs.extend(event_registry.durable_job_infos());
2554        state.insert_extension(event_registry.clone());
2555        crate::events::init_global_event_bus(&event_registry, &state, Some(event_recorder));
2556
2557        for job in &self.jobs {
2558            state.job_registry.register(&job.name);
2559        }
2560
2561        // Mirror production `AppBuilder` wiring: one `redis:<subsystem>`
2562        // PING indicator per Redis-backed subsystem (#3059). Here, the job set
2563        // is final (durable listeners are jobs too). `TestApp` always uses
2564        // in-process channels, and with no jobs no job runtime starts.
2565        #[cfg(feature = "redis")]
2566        crate::redis_health::register_redis_health_indicators(
2567            &self.config,
2568            &state.health_indicator_registry,
2569            &crate::redis_health::app_pingers(&state),
2570            if self.jobs.is_empty() {
2571                &["channels", "jobs"]
2572            } else {
2573                &["channels"]
2574            },
2575        );
2576
2577        let job_runtime = if self.jobs.is_empty() {
2578            None
2579        } else {
2580            let shutdown = tokio_util::sync::CancellationToken::new();
2581            crate::job::start_runtime(
2582                self.jobs.clone(),
2583                &state,
2584                &shutdown,
2585                &self.config.jobs,
2586                true,
2587            )
2588            .expect("Failed to start job runtime in test");
2589            Some(TestJobRuntime { shutdown })
2590        };
2591
2592        // Start `#[scheduled]` tasks on the in-process scheduler. Their loops
2593        // sleep on tokio timers and read the injected clock, so under a
2594        // `#[sim_test]` they tick in virtual time.
2595        let task_scheduler = if self.tasks.is_empty() {
2596            None
2597        } else {
2598            let shutdown = tokio_util::sync::CancellationToken::new();
2599            crate::app::start_task_scheduler_with_config(
2600                std::mem::take(&mut self.tasks),
2601                &state,
2602                &shutdown,
2603                &self.config.scheduler,
2604            )
2605            .expect("Failed to start scheduled tasks in test");
2606            Some(TestTaskScheduler { shutdown })
2607        };
2608
2609        // Retain the registered job metadata so `perform_enqueued_jobs` can look
2610        // up each captured job's handler by name and dispatch it directly.
2611        let jobs_for_client = self.jobs.clone();
2612
2613        #[cfg_attr(not(feature = "inbound-mail"), allow(unused_mut))]
2614        let mut merge_routers = self.merge_routers;
2615        #[cfg(feature = "inbound-mail")]
2616        if let Some(ref im_router) = self.inbound_mail_router {
2617            let mut registered_inbound: std::collections::HashSet<String> =
2618                std::collections::HashSet::new();
2619            for (path, axum_router) in crate::inbound_mail::build_routes(im_router) {
2620                if self
2621                    .routes
2622                    .iter()
2623                    .any(|r| r.method == Method::POST && r.path == path)
2624                    || self.scoped_groups.iter().any(|g| {
2625                        g.routes.iter().any(|r| {
2626                            r.method == Method::POST
2627                                && crate::router::join_nested_path(&g.prefix, r.path)
2628                                    == path.as_str()
2629                        })
2630                    })
2631                    || self.nest_routers.iter().any(|(nest_path, _)| {
2632                        let p = nest_path.as_str();
2633                        path.as_str() == p
2634                            || path.starts_with(p)
2635                                && (p.ends_with('/') || path.as_bytes().get(p.len()) == Some(&b'/'))
2636                    })
2637                {
2638                    tracing::warn!(
2639                        path = %path,
2640                        "inbound_mail: skipping webhook route — a POST handler is \
2641                         already registered at this path by the application"
2642                    );
2643                    continue;
2644                }
2645                if !registered_inbound.insert(path.clone()) {
2646                    tracing::warn!(
2647                        path = %path,
2648                        "inbound_mail: skipping duplicate inbound webhook path"
2649                    );
2650                    continue;
2651                }
2652                self.config.security.csrf.exempt_paths.push(path.clone());
2653                self.config.security.captcha_exempt_paths.push(path);
2654                merge_routers.push(axum_router);
2655            }
2656        }
2657
2658        // Explicitly build the session store the router's `SessionLayer` will
2659        // use, so the client keeps a handle for `acting_as` to mint sessions
2660        // (#1359). For the default in-memory backend we install a `MemoryStore`
2661        // and pass it as the custom store; for other backends we leave it to
2662        // config-driven selection (`None`) and the client's `session_store`
2663        // handle stays `None`, so `acting_as` panics with a clear message.
2664        let session_backed_by_memory = matches!(
2665            self.config.session.backend,
2666            crate::session::SessionBackend::Memory
2667        );
2668        let test_session_store: Option<std::sync::Arc<dyn crate::session::BoxedSessionStore>> =
2669            if session_backed_by_memory {
2670                Some(std::sync::Arc::new(crate::session::MemoryStore::new()))
2671            } else {
2672                None
2673            };
2674        let session_cookie_name = self.config.session.cookie_name.clone();
2675        let auth_session_key = self.config.auth.session_key.clone();
2676        // Mirror the router's session-cookie signing decision (router.rs): only
2677        // thread signing keys when a secret is configured or in production.
2678        let session_signing_keys = {
2679            let is_production =
2680                matches!(self.config.profile.as_deref(), Some("prod" | "production"));
2681            if self.config.security.signing_secret.secret.is_some() || is_production {
2682                Some(std::sync::Arc::new(
2683                    crate::security::config::resolve_signing_keys(
2684                        &self.config.security.signing_secret,
2685                    ),
2686                ))
2687            } else {
2688                None
2689            }
2690        };
2691
2692        let router = crate::router::try_build_router_inner(
2693            self.routes,
2694            &self.config,
2695            state.clone(),
2696            crate::router::RouterContext {
2697                exception_filters: self.exception_filters,
2698                scoped_groups: self.scoped_groups,
2699                merge_routers,
2700                nest_routers: self.nest_routers,
2701                declared_routes: self.declared_routes,
2702                custom_layers: self.custom_layers,
2703                static_gate_layers: self.static_gate_layers,
2704                #[cfg(feature = "maud")]
2705                error_page_renderer: None,
2706                session_store: test_session_store.clone(),
2707                #[cfg(feature = "openapi")]
2708                openapi: self.openapi,
2709                #[cfg(feature = "mcp")]
2710                mcp: self.mcp,
2711                #[cfg(feature = "webmcp")]
2712                webmcp: self.webmcp,
2713            },
2714        )
2715        .expect("failed to build test router");
2716        // Mirror production's two outermost fallbacks, which `apply_startup_barrier`
2717        // applies outside the session and exception-filter layers:
2718        //
2719        //  * access-log fallback (#999) — emits only for responses the primary
2720        //    in-stack layer never saw (e.g. session-store outage 503s), so tests
2721        //    observe the same access-log behavior an operator would;
2722        //  * Server-Timing fallback (#1348) — appends a `total` only for responses
2723        //    the primary never saw (short-circuits and the late-merged `/mcp`
2724        //    envelope). Without it a `tools/call` would carry no outer `total` in
2725        //    tests, unlike production.
2726        //
2727        // Composed into ONE `Router::layer` call, exactly as production does, so a
2728        // test router has the same nesting depth as the real one (issue #2193).
2729        // Tuple order is OUTERMOST FIRST: Server-Timing wraps the access log,
2730        // matching production order.
2731        let server_timing_fallback = crate::config::server_timing_enabled(&self.config)
2732            .then(|| crate::middleware::ServerTimingLayer::fallback(true));
2733        let access_log_fallback = self.config.log.access_log.then(|| {
2734            crate::middleware::AccessLogLayer::fallback(self.config.log.access_log_exclude.clone())
2735        });
2736        // Guarded, because `Router::layer` re-boxes every route even when the
2737        // tuple contributes no service: with both fallbacks off this would
2738        // otherwise add a nesting level production does not have.
2739        let router = if server_timing_fallback.is_some() || access_log_fallback.is_some() {
2740            router.layer((
2741                tower::util::option_layer(server_timing_fallback),
2742                tower::util::option_layer(access_log_fallback),
2743            ))
2744        } else {
2745            router
2746        };
2747        TestClient {
2748            router,
2749            probes,
2750            state,
2751            _job_runtime: job_runtime,
2752            _task_scheduler: task_scheduler,
2753            clock_as_any: self.clock_as_any,
2754            #[cfg(feature = "mail")]
2755            mail_recorder: Some(mail_recorder_for_client),
2756            #[cfg(feature = "ws")]
2757            broadcast_recorder: broadcast_recorder_for_client,
2758            job_recorder: Some(job_recorder_for_client),
2759            jobs: jobs_for_client,
2760            cookie_jar: std::sync::Arc::new(
2761                std::sync::Mutex::new(std::collections::HashMap::new()),
2762            ),
2763            session_store: test_session_store,
2764            session_cookie_name,
2765            auth_session_key,
2766            session_signing_keys,
2767            fault_ledger,
2768            observed_server_errors: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
2769        }
2770    }
2771}
2772
2773impl Default for TestApp {
2774    fn default() -> Self {
2775        Self::new()
2776    }
2777}
2778
2779// ── TestClient ─────────────────────────────────────────────────
2780
2781/// Fluent HTTP client for integration tests.
2782///
2783/// Analogous to Spring Boot's `MockMvc` or Django's `Client`.
2784/// Fires requests through the full Axum middleware pipeline using
2785/// `tower::ServiceExt::oneshot()` -- no TCP listener required.
2786///
2787/// Created by [`TestApp::build()`].
2788///
2789/// # Examples
2790///
2791/// ```rust,ignore
2792/// let client = TestApp::new().routes(routes![handler]).build();
2793///
2794/// // GET request
2795/// client.get("/path").send().await.assert_ok();
2796///
2797/// // POST with JSON body
2798/// client.post("/items")
2799///     .json(&serde_json::json!({"name": "foo"}))
2800///     .send().await
2801///     .assert_status(201);
2802///
2803/// // PUT with header
2804/// client.put("/items/1")
2805///     .header("authorization", "Bearer token")
2806///     .json(&serde_json::json!({"name": "bar"}))
2807///     .send().await
2808///     .assert_ok();
2809/// ```
2810pub struct TestClient {
2811    router: axum::Router,
2812    probes: crate::probe::ProbeState,
2813    pub(crate) state: AppState,
2814    _job_runtime: Option<TestJobRuntime>,
2815    /// Stops the `#[scheduled]` task loops [`TestApp::build`] started.
2816    _task_scheduler: Option<TestTaskScheduler>,
2817    /// Retained so `advance_clock` can downcast to [`crate::time::TickingClock`].
2818    clock_as_any: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
2819    /// `None` when built via [`TestApp::from_router`], which bypasses recorder
2820    /// wiring. `Some` for all clients produced by [`TestApp::build`].
2821    #[cfg(feature = "mail")]
2822    mail_recorder: Option<MailRecorder>,
2823    /// `Some` only when [`TestApp::record_broadcasts`] opted in; otherwise
2824    /// `None` (also for clients built via [`TestApp::from_router`]).
2825    #[cfg(feature = "ws")]
2826    broadcast_recorder: Option<BroadcastRecorder>,
2827    /// Built-in job recorder. `None` for clients built via
2828    /// [`TestApp::from_router`], which bypasses recorder wiring; `Some` for all
2829    /// clients produced by [`TestApp::build`].
2830    job_recorder: Option<JobRecorder>,
2831    /// Registered job metadata, retained so [`TestClient::perform_enqueued_jobs`]
2832    /// can dispatch each captured job through its handler. Empty for
2833    /// [`TestApp::from_router`] clients.
2834    jobs: Vec<crate::job::JobInfo>,
2835    /// Per-client cookie jar (`name → value + optional expiry`). Every
2836    /// response's `Set-Cookie` is folded in here, its `Max-Age`/`Expires`
2837    /// recorded, and it is replayed on subsequent requests until it expires
2838    /// against the client's clock, so a real
2839    /// `POST /login` → `GET /dashboard` flow works with no manual header
2840    /// threading. Shared with each [`RequestBuilder`] via a cloned `Arc`.
2841    cookie_jar: CookieJar,
2842    /// Handle to the session store the router's `SessionLayer` reads, so
2843    /// [`TestClient::acting_as`] can mint an authenticated session directly.
2844    /// `None` for clients built via [`TestApp::from_router`] or configured
2845    /// with a non-memory session backend; `acting_as` panics for those.
2846    session_store: Option<std::sync::Arc<dyn crate::session::BoxedSessionStore>>,
2847    /// Name of the session cookie (`session.cookie_name`, default
2848    /// `"autumn.sid"`); the cookie `acting_as` seeds and `log_out` clears.
2849    session_cookie_name: String,
2850    /// Session key the auth stack reads for identity (`auth.session_key`,
2851    /// default `"user_id"`); the key `acting_as` writes.
2852    auth_session_key: String,
2853    /// Session cookie signing keys when `security.signing_secret` is set (or
2854    /// in production), mirroring how the router signs session cookies. When
2855    /// present, `acting_as` signs the seeded cookie so the `SessionLayer`
2856    /// accepts it.
2857    session_signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
2858    /// The runtime ledger for an attached [`crate::sim::FaultPlan`] (issue
2859    /// #1680); `None` when no plan was attached (and for
2860    /// [`TestApp::from_router`] clients).
2861    fault_ledger: Option<crate::sim::fault::FaultLedger>,
2862    /// How many 5xx responses this client has seen on its own
2863    /// [`RequestBuilder::send`] calls. [`TestClient::fault_outcome`] settles the
2864    /// detached reporter tasks against this count, so an outcome is read only
2865    /// once the 5xx the test actually observed have reached the ledger. Only
2866    /// incremented while a ledger exists.
2867    observed_server_errors: std::sync::Arc<std::sync::atomic::AtomicU64>,
2868}
2869
2870/// A cookie stored in the jar: its value plus an optional absolute expiry.
2871///
2872/// `expires_at: None` is a session cookie that never client-expires; `Some(t)`
2873/// records the instant (from `Max-Age`/`Expires`) past which the cookie must no
2874/// longer be replayed, evaluated against the client's (possibly virtual) clock.
2875#[derive(Clone)]
2876struct StoredCookie {
2877    value: String,
2878    expires_at: Option<chrono::DateTime<chrono::Utc>>,
2879}
2880
2881/// Shared per-client cookie store: cookie name → stored cookie (value + expiry).
2882type CookieJar = std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, StoredCookie>>>;
2883
2884struct TestJobRuntime {
2885    shutdown: tokio_util::sync::CancellationToken,
2886}
2887
2888/// Cancels the scheduled-task loops of one [`TestClient`] when it drops.
2889struct TestTaskScheduler {
2890    shutdown: tokio_util::sync::CancellationToken,
2891}
2892
2893impl Drop for TestTaskScheduler {
2894    fn drop(&mut self) {
2895        self.shutdown.cancel();
2896    }
2897}
2898
2899impl Drop for TestJobRuntime {
2900    fn drop(&mut self) {
2901        self.shutdown.cancel();
2902        crate::job::clear_global_job_client();
2903    }
2904}
2905
2906impl TestClient {
2907    /// The app router, for a sim replica that serves other replicas (#3067).
2908    #[cfg(feature = "http-client")]
2909    pub(crate) fn router(&self) -> axum::Router {
2910        self.router.clone()
2911    }
2912
2913    /// Returns a reference to the [`AppState`] wired into this test app's router.
2914    #[must_use]
2915    pub const fn state(&self) -> &AppState {
2916        &self.state
2917    }
2918
2919    /// Every recorded publication of event type `E`, deserialized.
2920    ///
2921    /// Events are recorded synchronously at publish time, so this works whether
2922    /// or not the listeners (sync or durable) have run.
2923    #[must_use]
2924    pub fn published_events<E: crate::events::Event>(&self) -> Vec<E> {
2925        self.state
2926            .extension::<crate::events::EventRecorder>()
2927            .map(|recorder| recorder.published::<E>())
2928            .unwrap_or_default()
2929    }
2930
2931    /// Assert that at least one event of type `E` was published during the test.
2932    ///
2933    /// # Panics
2934    ///
2935    /// Panics if no event of type `E` was recorded.
2936    pub fn assert_event_published<E: crate::events::Event>(&self) {
2937        let count = self
2938            .state
2939            .extension::<crate::events::EventRecorder>()
2940            .map_or(0, |recorder| recorder.count::<E>());
2941        assert!(
2942            count > 0,
2943            "expected event `{}` to have been published, but none were recorded",
2944            E::NAME,
2945        );
2946    }
2947
2948    /// Step the test clock forward by `duration`.
2949    ///
2950    /// Only effective when the app was configured with a
2951    /// [`crate::time::TickingClock`] via [`TestApp::with_clock`]. Calling this
2952    /// with a [`crate::time::FixedClock`] or without any custom clock is a
2953    /// safe no-op — time stays where it is.
2954    ///
2955    /// This method only affects the wall-clock time reported by the
2956    /// [`crate::time::Clock`] extractor. Tokio's runtime timer (used by
2957    /// `tokio::time::sleep`, `tokio::time::Instant`, etc.) is not affected.
2958    ///
2959    /// ```rust,no_run
2960    /// use autumn_web::test::TestApp;
2961    /// use autumn_web::time::TickingClock;
2962    /// use chrono::{TimeZone, Utc};
2963    /// use std::time::Duration;
2964    ///
2965    /// # #[tokio::main]
2966    /// # async fn main() {
2967    /// let clock = TickingClock::starting_at(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap());
2968    /// let client = TestApp::new().with_clock(clock).build();
2969    ///
2970    /// client.advance_clock(Duration::from_secs(86400)); // advance 1 day
2971    /// # }
2972    /// ```
2973    pub fn advance_clock(&self, duration: std::time::Duration) {
2974        if let Some(any) = &self.clock_as_any {
2975            let cloned = std::sync::Arc::clone(any);
2976            if let Ok(ticking) = cloned.downcast::<crate::time::TickingClock>() {
2977                ticking.advance(duration);
2978            }
2979            // FixedClock or other types: advance_clock is a no-op.
2980        }
2981        // No clock installed: also a no-op.
2982    }
2983
2984    /// Unwrap the underlying [`axum::Router`] out of the [`TestClient`].
2985    pub fn into_router(self) -> axum::Router {
2986        self.router
2987    }
2988
2989    /// Return the [`crate::probe::ProbeState`] wired into this test app's router.
2990    ///
2991    /// Use this to drive readiness/liveness transitions in integration tests
2992    /// and verify the HTTP probe endpoints reflect state changes.
2993    pub const fn probes(&self) -> &crate::probe::ProbeState {
2994        &self.probes
2995    }
2996
2997    /// Returns all emails sent during this test, in the order they were sent.
2998    ///
2999    /// The built-in recorder is installed automatically — no
3000    /// `.with_mail_interceptor(…)` call is required.
3001    ///
3002    /// # Example
3003    ///
3004    /// ```rust,ignore
3005    /// client.post("/signup").json(&body).send().await.assert_ok();
3006    /// let mail = &client.sent_mail()[0];
3007    /// assert_eq!(mail.subject, "Welcome!");
3008    /// ```
3009    #[cfg(feature = "mail")]
3010    #[must_use]
3011    pub fn sent_mail(&self) -> Vec<SentMail> {
3012        self.mail_recorder
3013            .as_ref()
3014            .expect("sent_mail() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead")
3015            .get_sent()
3016    }
3017
3018    /// Asserts that exactly `n` emails were sent, panicking with a list of
3019    /// what was actually sent on failure.
3020    ///
3021    /// Returns `&self` for chaining.
3022    ///
3023    /// # Panics
3024    ///
3025    /// Panics when the count does not match.
3026    #[cfg(feature = "mail")]
3027    pub fn assert_email_count(&self, n: usize) -> &Self {
3028        let sent = self.sent_mail();
3029        assert_eq!(
3030            sent.len(),
3031            n,
3032            "expected {n} email(s) to have been sent, got {};\nactually sent: {sent:#?}",
3033            sent.len(),
3034        );
3035        self
3036    }
3037
3038    /// Asserts that no emails were sent.
3039    ///
3040    /// Returns `&self` for chaining.
3041    ///
3042    /// # Panics
3043    ///
3044    /// Panics when any emails were sent.
3045    #[cfg(feature = "mail")]
3046    pub fn assert_no_email_sent(&self) -> &Self {
3047        self.assert_email_count(0)
3048    }
3049
3050    /// Asserts that at least one sent email satisfies `predicate`, panicking
3051    /// with a list of what was actually sent on failure.
3052    ///
3053    /// Returns `&self` for chaining.
3054    ///
3055    /// # Panics
3056    ///
3057    /// Panics when no sent email matches.
3058    ///
3059    /// # Example
3060    ///
3061    /// ```rust,ignore
3062    /// client
3063    ///     .assert_email_sent(|m| m.to.iter().any(|a| a == "alice@example.com"))
3064    ///     .assert_email_sent(|m| m.subject == "Welcome!");
3065    /// ```
3066    #[cfg(feature = "mail")]
3067    pub fn assert_email_sent(&self, predicate: impl Fn(&SentMail) -> bool) -> &Self {
3068        let sent = self.sent_mail();
3069        assert!(
3070            sent.iter().any(predicate),
3071            "no sent email matched the predicate;\nactually sent: {sent:#?}",
3072        );
3073        self
3074    }
3075
3076    // ── Broadcast recorder accessors & assertions (issue #1043) ──────────
3077
3078    /// Every recorded channel publication, in publish order.
3079    ///
3080    /// Requires opting in with [`TestApp::record_broadcasts`]. Captures both
3081    /// raw `publish` text and `publish_html` HTML/OOB payloads.
3082    ///
3083    /// # Panics
3084    ///
3085    /// Panics if [`TestApp::record_broadcasts`] was not called.
3086    #[cfg(feature = "ws")]
3087    #[must_use]
3088    pub fn broadcasts(&self) -> Vec<RecordedBroadcast> {
3089        self.broadcast_recorder
3090            .as_ref()
3091            .expect(
3092                "broadcasts() requires opting in via TestApp::record_broadcasts() before build()",
3093            )
3094            .recorded()
3095    }
3096
3097    /// Recorded publications on `topic`, in publish order.
3098    ///
3099    /// # Panics
3100    ///
3101    /// Panics if [`TestApp::record_broadcasts`] was not called.
3102    #[cfg(feature = "ws")]
3103    #[must_use]
3104    pub fn broadcasts_on(&self, topic: &str) -> Vec<RecordedBroadcast> {
3105        self.broadcasts()
3106            .into_iter()
3107            .filter(|b| b.topic == topic)
3108            .collect()
3109    }
3110
3111    /// Builds a self-diagnosing failure message listing what was actually
3112    /// published to `topic` and, grouped, to every other topic.
3113    #[cfg(feature = "ws")]
3114    fn broadcast_failure_message(&self, topic: &str, headline: &str) -> String {
3115        use std::collections::BTreeMap;
3116        use std::fmt::Write as _;
3117        let all = self.broadcasts();
3118        let on_topic: Vec<&str> = all
3119            .iter()
3120            .filter(|b| b.topic == topic)
3121            .map(|b| b.payload.as_str())
3122            .collect();
3123
3124        let mut others: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3125        for b in &all {
3126            if b.topic != topic {
3127                others
3128                    .entry(b.topic.as_str())
3129                    .or_default()
3130                    .push(b.payload.as_str());
3131            }
3132        }
3133
3134        let mut msg = format!("{headline}\n");
3135        let _ = writeln!(
3136            msg,
3137            "published to {topic:?} ({} total): {on_topic:#?}",
3138            on_topic.len(),
3139        );
3140        if others.is_empty() {
3141            msg.push_str("no publications on any other topic");
3142        } else {
3143            let _ = write!(msg, "other topics published: {others:#?}");
3144        }
3145        msg
3146    }
3147
3148    /// Asserts that at least one publication on `topic` satisfies `predicate`.
3149    ///
3150    /// Returns `&Self` for chaining.
3151    ///
3152    /// # Panics
3153    ///
3154    /// Panics when no matching publication is found, dumping what *was*
3155    /// published to `topic` and nearby topics.
3156    #[cfg(feature = "ws")]
3157    pub fn assert_broadcast(
3158        &self,
3159        topic: &str,
3160        predicate: impl Fn(&RecordedBroadcast) -> bool,
3161    ) -> &Self {
3162        let matched = self.broadcasts_on(topic).iter().any(predicate);
3163        assert!(
3164            matched,
3165            "{}",
3166            self.broadcast_failure_message(
3167                topic,
3168                &format!("no broadcast on {topic:?} matched the predicate;"),
3169            )
3170        );
3171        self
3172    }
3173
3174    /// Asserts that exactly `n` publications were made to `topic`.
3175    ///
3176    /// Returns `&Self` for chaining.
3177    ///
3178    /// # Panics
3179    ///
3180    /// Panics when the count does not match, dumping what *was* published to
3181    /// `topic` and nearby topics.
3182    #[cfg(feature = "ws")]
3183    pub fn assert_broadcast_count(&self, topic: &str, n: usize) -> &Self {
3184        let count = self.broadcasts_on(topic).len();
3185        assert!(
3186            count == n,
3187            "{}",
3188            self.broadcast_failure_message(
3189                topic,
3190                &format!("expected {n} broadcast(s) on {topic:?}, got {count};"),
3191            )
3192        );
3193        self
3194    }
3195
3196    /// Asserts that nothing was published to `topic`.
3197    ///
3198    /// Returns `&Self` for chaining.
3199    ///
3200    /// # Panics
3201    ///
3202    /// Panics when any publication was made to `topic`, dumping what *was*
3203    /// published to `topic` and nearby topics.
3204    #[cfg(feature = "ws")]
3205    pub fn assert_no_broadcasts(&self, topic: &str) -> &Self {
3206        self.assert_broadcast_count(topic, 0)
3207    }
3208
3209    // ── Background-job recorder ────────────────────────────────────
3210
3211    /// Every background-job enqueue captured by the built-in recorder, in the
3212    /// order they were enqueued (across `enqueue`, `enqueue_after_commit`, and
3213    /// `enqueue_in_tx`).
3214    ///
3215    /// The recorder is always on for [`TestApp::build`] clients — no opt-in.
3216    ///
3217    /// # Panics
3218    ///
3219    /// Panics if called on a [`TestClient`] built via [`TestApp::from_router`],
3220    /// which bypasses recorder wiring.
3221    #[must_use]
3222    pub fn enqueued_jobs(&self) -> Vec<RecordedJob> {
3223        self.job_recorder
3224            .as_ref()
3225            .expect(
3226                "enqueued_jobs() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead",
3227            )
3228            .recorded()
3229    }
3230
3231    /// Assert at least one job with the given registered `name` was enqueued.
3232    ///
3233    /// # Panics
3234    ///
3235    /// Panics, listing every job that *was* enqueued, if no enqueue with that
3236    /// name was captured.
3237    pub fn assert_job_enqueued(&self, name: &str) -> &Self {
3238        let jobs = self.enqueued_jobs();
3239        assert!(
3240            jobs.iter().any(|j| j.name == name),
3241            "expected a job named '{name}' to have been enqueued, but it was not.\nEnqueued jobs:\n{}",
3242            format_recorded_jobs(&jobs)
3243        );
3244        self
3245    }
3246
3247    /// Assert at least one job was enqueued with **both** the given registered
3248    /// `name` and an exactly-equal JSON `payload`.
3249    ///
3250    /// # Panics
3251    ///
3252    /// Panics, listing every job that *was* enqueued, if no enqueue matched
3253    /// both the name and payload.
3254    // Takes the payload by value for call-site ergonomics — `json!({..})`
3255    // reads cleanly without a leading `&`, mirroring the acceptance criteria.
3256    #[allow(clippy::needless_pass_by_value)]
3257    pub fn assert_job_enqueued_with(&self, name: &str, payload: serde_json::Value) -> &Self {
3258        let jobs = self.enqueued_jobs();
3259        // Strip the opt-in schema-version envelope (issue #1205) so payload
3260        // assertions stay on clean args even for `#[job(version = N)]` jobs
3261        // whose stored payload is wrapped as `{__autumn_schema_version, args}`.
3262        assert!(
3263            jobs.iter().any(|j| j.name == name
3264                && *crate::payload_version::split_version(&j.payload).1 == payload),
3265            "expected a job named '{name}' enqueued with payload {payload}, but no match was found.\nEnqueued jobs:\n{}",
3266            format_recorded_jobs(&jobs)
3267        );
3268        self
3269    }
3270
3271    /// Assert no jobs were enqueued at all.
3272    ///
3273    /// # Panics
3274    ///
3275    /// Panics, listing every captured enqueue, if any job was enqueued.
3276    pub fn assert_no_jobs_enqueued(&self) -> &Self {
3277        let jobs = self.enqueued_jobs();
3278        assert!(
3279            jobs.is_empty(),
3280            "expected no jobs to have been enqueued, but {} were:\n{}",
3281            jobs.len(),
3282            format_recorded_jobs(&jobs)
3283        );
3284        self
3285    }
3286
3287    /// Drain every captured job and dispatch it through its registered handler,
3288    /// awaiting each in enqueue order, so a test can assert the resulting side
3289    /// effects synchronously.
3290    ///
3291    /// Each captured payload is handed to the same handler the runtime would
3292    /// invoke, so the real deserialization path runs: a payload that cannot be
3293    /// deserialized into the job's args surfaces as a per-job failure (not a
3294    /// silent miss). The queue is emptied — a second call performs nothing
3295    /// until more jobs are enqueued.
3296    ///
3297    /// Returns a [`PerformedJobs`] report carrying each job's `(name, result)`;
3298    /// per-job handler errors (and captured jobs with no registered handler)
3299    /// are surfaced there rather than swallowed. See
3300    /// [`PerformedJobs::assert_all_succeeded`].
3301    ///
3302    /// # Note
3303    ///
3304    /// [`TestApp::build`] starts the in-process job worker by default, and that
3305    /// worker *also* drains and runs the same enqueued jobs. Calling this method
3306    /// therefore executes a job's side effect an **additional** time, on top of
3307    /// the worker's own run. It is primarily for asserting that a job runs to
3308    /// completion — surfacing handler/deserialization errors synchronously — not
3309    /// for counting side effects. Any assertion on a side effect's *count* must
3310    /// account for the worker's run as well (as the job-recorder integration
3311    /// tests do: they settle the worker's run first, then attribute the next
3312    /// increment to this call).
3313    ///
3314    /// The helper invokes each job's registered handler directly and does *not*
3315    /// run it through a user-installed
3316    /// [`JobInterceptor::intercept_execute`](crate::interceptor::JobInterceptor::intercept_execute),
3317    /// so
3318    /// execution-interceptor effects (context injection, metrics, error
3319    /// injection) are exercised by the in-process worker path, not by this
3320    /// helper.
3321    ///
3322    /// # Panics
3323    ///
3324    /// Panics if called on a [`TestClient`] built via [`TestApp::from_router`].
3325    pub async fn perform_enqueued_jobs(&self) -> PerformedJobs {
3326        let recorder = self.job_recorder.as_ref().expect(
3327            "perform_enqueued_jobs() is not available on a TestClient built via from_router(); use TestApp::new().merge(router).build() instead",
3328        );
3329        let drained = recorder.drain();
3330        let mut outcomes = Vec::with_capacity(drained.len());
3331        for job in drained {
3332            let handler = self
3333                .jobs
3334                .iter()
3335                .find(|info| info.name == job.name)
3336                .map(|info| info.handler);
3337            let result = match handler {
3338                Some(handler) => (handler)(self.state.clone(), job.payload).await,
3339                None => Err(crate::AutumnError::internal_server_error(
3340                    std::io::Error::other(format!(
3341                        "no registered handler for enqueued job '{}'; register it via AppBuilder::jobs()",
3342                        job.name
3343                    )),
3344                )),
3345            };
3346            outcomes.push((job.name, result));
3347        }
3348        PerformedJobs { outcomes }
3349    }
3350
3351    /// The app's configured N+1 detection threshold
3352    /// (`dev.inspector_n_plus_one_threshold`), threaded into every
3353    /// [`RequestBuilder`] so the resulting [`TestResponse`] can default
3354    /// [`TestResponse::assert_no_n_plus_one`] to it.
3355    ///
3356    /// Reads through [`AppState::config_arc`]: this runs on every
3357    /// `TestClient` request, so a [`AppState::config`] deep clone here would
3358    /// tax the whole suite — and the committed `request_pipeline` benchmark
3359    /// (issue #2198).
3360    fn n_plus_one_threshold(&self) -> usize {
3361        self.state.config_arc().dev.inspector_n_plus_one_threshold
3362    }
3363
3364    /// The shared 5xx counter handed to each [`RequestBuilder`], or `None` when
3365    /// no [`crate::sim::FaultPlan`] is attached (so an ordinary test app never
3366    /// touches an atomic per request).
3367    ///
3368    /// Also `None` without the `reporting` feature: nothing can ever populate
3369    /// `FaultOutcome::server_errors` then, so counting observed 5xx would only
3370    /// make [`fault_outcome`](Self::fault_outcome) spin out its whole settle
3371    /// budget against a list that stays empty by contract.
3372    #[cfg_attr(
3373        not(feature = "reporting"),
3374        allow(
3375            clippy::unused_self,
3376            clippy::missing_const_for_fn,
3377            reason = "without `reporting` the answer is a constant `None`, but the \
3378                      signature has to stay one method so every request builder \
3379                      keeps a single call site"
3380        )
3381    )]
3382    fn fault_error_counter(&self) -> Option<std::sync::Arc<std::sync::atomic::AtomicU64>> {
3383        #[cfg(feature = "reporting")]
3384        {
3385            self.fault_ledger
3386                .as_ref()
3387                .map(|_| std::sync::Arc::clone(&self.observed_server_errors))
3388        }
3389        #[cfg(not(feature = "reporting"))]
3390        {
3391            None
3392        }
3393    }
3394
3395    /// The runtime ledger for the [`crate::sim::FaultPlan`] attached with
3396    /// [`TestApp::with_fault_plan`], or `None` when none was attached.
3397    ///
3398    /// The handle is cheap to clone and shares the underlying ledger, so it can
3399    /// be read mid-run. For a scenario that drove HTTP requests, prefer
3400    /// [`fault_outcome`](Self::fault_outcome), which settles the detached
3401    /// reporter tasks first.
3402    #[must_use]
3403    pub fn fault_ledger(&self) -> Option<crate::sim::fault::FaultLedger> {
3404        self.fault_ledger.clone()
3405    }
3406
3407    /// Settle the reporting lane, then snapshot the
3408    /// [`FaultOutcome`](crate::sim::FaultOutcome) for this run.
3409    ///
3410    /// Autumn's error-reporting layer dispatches on a **detached** task, so a
3411    /// 5xx this client already saw on the wire may not have reached the ledger
3412    /// yet. This yields cooperatively (never sleeping and never advancing the
3413    /// virtual clock, which would corrupt a sim's timeline) until the ledger has
3414    /// recorded at least as many server errors as this client observed, up to a
3415    /// bounded number of yields — then snapshots regardless, so a 5xx the
3416    /// reporting layer never sees (one raised outside `ReportingLayer`, e.g. by
3417    /// the session layer) can only cost a bounded spin, not a hang.
3418    ///
3419    /// The bound is sufficient because [`TestApp::build`] refuses the two
3420    /// configurations under which the dispatch could still be pending after
3421    /// it: a sampled reporter (`reporting.sample_rate < 1.0`) and failure
3422    /// capture (`failure_capture.enabled`), whose blocking capsule persistence
3423    /// reporting awaits before any reporter runs. With both refused the
3424    /// dispatch task reaches the ledger's projector on its first poll.
3425    ///
3426    /// # Panics
3427    ///
3428    /// Panics if no [`crate::sim::FaultPlan`] was attached with
3429    /// [`TestApp::with_fault_plan`].
3430    pub async fn fault_outcome(&self) -> crate::sim::fault::FaultOutcome {
3431        /// Cooperative yields spent waiting for the detached reporter tasks.
3432        const MAX_SETTLE_YIELDS: usize = 10_000;
3433
3434        let ledger = self.fault_ledger.as_ref().expect(
3435            "no fault plan attached to this TestApp; call `TestApp::with_fault_plan(..)` before `build()`",
3436        );
3437        // Fully-qualified: a `diesel_async::RunQueryDsl` glob import in this
3438        // module also offers a `.load(..)` method by that name.
3439        let observed = usize::try_from(std::sync::atomic::AtomicU64::load(
3440            &self.observed_server_errors,
3441            std::sync::atomic::Ordering::SeqCst,
3442        ))
3443        .unwrap_or(usize::MAX);
3444        for _ in 0..MAX_SETTLE_YIELDS {
3445            if ledger.server_errors_len() >= observed {
3446                break;
3447            }
3448            tokio::task::yield_now().await;
3449        }
3450        ledger.outcome()
3451    }
3452
3453    /// Start building a GET request.
3454    #[must_use]
3455    pub fn get(&self, uri: &str) -> RequestBuilder {
3456        RequestBuilder::new(
3457            self.router.clone(),
3458            Method::GET,
3459            uri,
3460            self.cookie_jar.clone(),
3461            Some(self.state.clock.clone()),
3462            self.n_plus_one_threshold(),
3463            self.fault_error_counter(),
3464        )
3465    }
3466
3467    /// Start building a HEAD request.
3468    #[must_use]
3469    pub fn head(&self, uri: &str) -> RequestBuilder {
3470        RequestBuilder::new(
3471            self.router.clone(),
3472            Method::HEAD,
3473            uri,
3474            self.cookie_jar.clone(),
3475            Some(self.state.clock.clone()),
3476            self.n_plus_one_threshold(),
3477            self.fault_error_counter(),
3478        )
3479    }
3480
3481    /// Start building a POST request.
3482    #[must_use]
3483    pub fn post(&self, uri: &str) -> RequestBuilder {
3484        RequestBuilder::new(
3485            self.router.clone(),
3486            Method::POST,
3487            uri,
3488            self.cookie_jar.clone(),
3489            Some(self.state.clock.clone()),
3490            self.n_plus_one_threshold(),
3491            self.fault_error_counter(),
3492        )
3493    }
3494
3495    /// Start building a PUT request.
3496    #[must_use]
3497    pub fn put(&self, uri: &str) -> RequestBuilder {
3498        RequestBuilder::new(
3499            self.router.clone(),
3500            Method::PUT,
3501            uri,
3502            self.cookie_jar.clone(),
3503            Some(self.state.clock.clone()),
3504            self.n_plus_one_threshold(),
3505            self.fault_error_counter(),
3506        )
3507    }
3508
3509    /// Start building a DELETE request.
3510    #[must_use]
3511    pub fn delete(&self, uri: &str) -> RequestBuilder {
3512        RequestBuilder::new(
3513            self.router.clone(),
3514            Method::DELETE,
3515            uri,
3516            self.cookie_jar.clone(),
3517            Some(self.state.clock.clone()),
3518            self.n_plus_one_threshold(),
3519            self.fault_error_counter(),
3520        )
3521    }
3522
3523    /// Start building a PATCH request.
3524    #[must_use]
3525    pub fn patch(&self, uri: &str) -> RequestBuilder {
3526        RequestBuilder::new(
3527            self.router.clone(),
3528            Method::PATCH,
3529            uri,
3530            self.cookie_jar.clone(),
3531            Some(self.state.clock.clone()),
3532            self.n_plus_one_threshold(),
3533            self.fault_error_counter(),
3534        )
3535    }
3536
3537    /// Start building an OPTIONS request (e.g. a CORS preflight).
3538    #[must_use]
3539    pub fn options(&self, uri: &str) -> RequestBuilder {
3540        RequestBuilder::new(
3541            self.router.clone(),
3542            Method::OPTIONS,
3543            uri,
3544            self.cookie_jar.clone(),
3545            Some(self.state.clock.clone()),
3546            self.n_plus_one_threshold(),
3547            self.fault_error_counter(),
3548        )
3549    }
3550
3551    // ── Authentication helpers (#1359) ─────────────────────────
3552
3553    /// Establish an authenticated session for `user_id` *without* calling the
3554    /// login endpoint, then return `&Self` for chaining.
3555    ///
3556    /// Mints a fresh session containing the app's configured
3557    /// `auth.session_key` (default `"user_id"`) set to `user_id`, saves it to
3558    /// the session store the router reads, and seeds the cookie jar with the
3559    /// session cookie. A subsequent request to a `#[secured]` / [`Auth`](crate::auth::Auth)-gated
3560    /// route then extracts the same identity a real login would produce.
3561    ///
3562    /// This sets **identity only** — authorization still runs. A user acted-as
3563    /// here who lacks a required role or scope is still denied.
3564    ///
3565    /// Analogous to Laravel's `actingAs`, Rails' `sign_in`, Django's
3566    /// `force_login`, and Phoenix's `log_in_user`.
3567    ///
3568    /// # Panics
3569    ///
3570    /// Panics if the client has no handle to a session store — i.e. it was
3571    /// built via [`TestApp::from_router`], or configured with a non-memory
3572    /// session backend. Use [`TestApp::build`] with the default (memory)
3573    /// session backend for `acting_as` support.
3574    ///
3575    /// # Examples
3576    ///
3577    /// ```rust,ignore
3578    /// let client = TestApp::new().routes(routes![dashboard]).build();
3579    /// client.acting_as(42).await;
3580    /// client.get("/dashboard").send().await.assert_ok();
3581    /// ```
3582    pub async fn acting_as(&self, user_id: impl std::fmt::Display) -> &Self {
3583        let store = self.session_store.as_ref().unwrap_or_else(|| {
3584            panic!(
3585                "acting_as requires a session store handle, which is only available on clients \
3586                 built via `TestApp::build()` with the default in-memory session backend. \
3587                 Clients from `TestApp::from_router` or configured with a non-memory backend \
3588                 cannot mint sessions this way."
3589            )
3590        });
3591
3592        let session_id = uuid::Uuid::new_v4().to_string();
3593        let mut data = std::collections::HashMap::new();
3594        data.insert(self.auth_session_key.clone(), user_id.to_string());
3595        store
3596            .boxed_save(&session_id, data)
3597            .await
3598            .expect("failed to save acting_as session to the test session store");
3599
3600        // Match the router's cookie encoding: sign the id when signing keys
3601        // are active, otherwise store the raw id.
3602        let cookie_value = self.session_signing_keys.as_ref().map_or_else(
3603            || session_id.clone(),
3604            |keys| format!("{session_id}.{}", keys.sign(session_id.as_bytes())),
3605        );
3606        self.cookie_jar
3607            .lock()
3608            .expect("cookie jar mutex poisoned")
3609            .insert(
3610                self.session_cookie_name.clone(),
3611                StoredCookie {
3612                    value: cookie_value,
3613                    expires_at: None,
3614                },
3615            );
3616
3617        self
3618    }
3619
3620    /// Alias for [`acting_as`](Self::acting_as).
3621    ///
3622    /// Provided for readers coming from frameworks whose helper is spelled
3623    /// `login_as` / `sign_in`.
3624    ///
3625    /// # Panics
3626    ///
3627    /// See [`acting_as`](Self::acting_as).
3628    pub async fn login_as(&self, user_id: impl std::fmt::Display) -> &Self {
3629        self.acting_as(user_id).await
3630    }
3631
3632    /// Clear the session cookie from the jar, reverting the client to an
3633    /// unauthenticated state, then return `&Self` for chaining.
3634    ///
3635    /// After `log_out`, a request to a secured route returns its
3636    /// unauthenticated status (401 / redirect) again. The corresponding
3637    /// server-side session (if any) is left to expire naturally.
3638    ///
3639    /// Analogous to Laravel's `Auth::logout`, Rails' `sign_out`, and Django's
3640    /// `logout`.
3641    pub fn log_out(&self) -> &Self {
3642        self.cookie_jar
3643            .lock()
3644            .expect("cookie jar mutex poisoned")
3645            .remove(&self.session_cookie_name);
3646        self
3647    }
3648}
3649
3650/// Fold a single `Set-Cookie` header value into the cookie jar.
3651///
3652/// Stores `name=value` along with any absolute expiry parsed from the header's
3653/// `Max-Age`/`Expires` attributes (so a live cookie stops being replayed once
3654/// the clock passes it), or removes the cookie when the header marks it for
3655/// immediate deletion (`Max-Age=0`, a non-positive `Max-Age`, or an `Expires`
3656/// in the past — the encodings the session layer and CSRF layer use to clear
3657/// cookies).
3658///
3659/// When both `Max-Age` and `Expires` are present, `Max-Age` wins (per
3660/// RFC 6265). A live cookie with no expiry attributes is stored as a session
3661/// cookie (`expires_at: None`) that never client-expires.
3662///
3663/// `now` is the reference instant for evaluating `Max-Age`/`Expires`; callers
3664/// pass the framework's (possibly virtual) clock so a test that pins or
3665/// advances time sees deterministic expiry.
3666fn apply_set_cookie(
3667    jar: &mut std::collections::HashMap<String, StoredCookie>,
3668    header: &str,
3669    now: chrono::DateTime<chrono::Utc>,
3670) {
3671    let mut parts = header.split(';');
3672    let Some(pair) = parts.next() else {
3673        return;
3674    };
3675    let Some((name, value)) = pair.split_once('=') else {
3676        return;
3677    };
3678    let name = name.trim();
3679    let value = value.trim();
3680    if name.is_empty() {
3681        return;
3682    }
3683
3684    // The jar reliably recognizes Autumn's own cookie-clear encodings: an empty
3685    // value (handled below) and a non-positive `Max-Age`, which the session and
3686    // CSRF layers use to delete cookies. Third-party `Expires`-based deletions
3687    // are only best-effort — an RFC 2822 timestamp in the past is honored, but
3688    // other date encodings a foreign server might send are not fully parsed.
3689    let mut deletes = false;
3690    // Absolute expiry parsed from a positive `Max-Age`/future `Expires`. When
3691    // both are present, `Max-Age` wins (RFC 6265), so track them separately and
3692    // resolve at the end.
3693    let mut max_age_expiry: Option<chrono::DateTime<chrono::Utc>> = None;
3694    let mut expires_expiry: Option<chrono::DateTime<chrono::Utc>> = None;
3695    let mut saw_max_age = false;
3696    for attr in parts {
3697        let attr = attr.trim();
3698        // Both `Max-Age=` and `Expires=` are 8-byte ASCII prefixes; match them
3699        // case-insensitively (`EXPIRES=` etc. are equally valid per RFC 6265).
3700        let prefix = attr.get(..8);
3701        if prefix.is_some_and(|p| p.eq_ignore_ascii_case("Max-Age=")) {
3702            if let Ok(secs) = attr[8..].trim().parse::<i64>() {
3703                saw_max_age = true;
3704                if secs <= 0 {
3705                    deletes = true;
3706                } else {
3707                    max_age_expiry = now.checked_add_signed(chrono::Duration::seconds(secs));
3708                }
3709            }
3710        } else if prefix.is_some_and(|p| p.eq_ignore_ascii_case("Expires="))
3711            && let Ok(when) = chrono::DateTime::parse_from_rfc2822(attr[8..].trim())
3712        {
3713            let when = when.with_timezone(&chrono::Utc);
3714            if when <= now {
3715                deletes = true;
3716            } else {
3717                expires_expiry = Some(when);
3718            }
3719        }
3720    }
3721
3722    if deletes || value.is_empty() {
3723        jar.remove(name);
3724    } else {
3725        // `Max-Age` takes precedence over `Expires` when both are present.
3726        let expires_at = if saw_max_age {
3727            max_age_expiry
3728        } else {
3729            expires_expiry
3730        };
3731        jar.insert(
3732            name.to_owned(),
3733            StoredCookie {
3734                value: value.to_owned(),
3735                expires_at,
3736            },
3737        );
3738    }
3739}
3740
3741// ── RequestBuilder ─────────────────────────────────────────────
3742
3743/// Fluent builder for composing an HTTP request in tests.
3744///
3745/// Created by [`TestClient::get()`], [`TestClient::post()`], etc.
3746/// Call [`.send()`](Self::send) to fire the request and get a
3747/// [`TestResponse`].
3748pub struct RequestBuilder {
3749    router: axum::Router,
3750    method: Method,
3751    uri: String,
3752    headers: Vec<(String, String)>,
3753    body: Body,
3754    /// Shared with the originating [`TestClient`]: cookies are read from here
3755    /// to compose the request `Cookie` header, and `Set-Cookie` from the
3756    /// response is folded back in. `None` when the builder was constructed
3757    /// without a client (not reachable through the public API today).
3758    cookie_jar: Option<CookieJar>,
3759    /// The originating client's clock, used to evaluate `Expires` when folding
3760    /// `Set-Cookie` back into the jar. `None` falls back to [`crate::time::ambient_now`].
3761    clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
3762    /// Default N+1 detection threshold (`dev.inspector_n_plus_one_threshold`),
3763    /// propagated to the resulting [`TestResponse`] so
3764    /// [`TestResponse::assert_no_n_plus_one`] can honour the app's config.
3765    n_plus_one_threshold: usize,
3766    /// Shared with the originating [`TestClient`] when a
3767    /// [`crate::sim::FaultPlan`] is attached: every 5xx this request produces is
3768    /// counted here, and [`TestClient::fault_outcome`] settles the detached
3769    /// reporter tasks against that count. `None` when no plan is attached, so a
3770    /// plain test app pays nothing.
3771    observed_server_errors: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
3772}
3773
3774impl RequestBuilder {
3775    fn new(
3776        router: axum::Router,
3777        method: Method,
3778        uri: &str,
3779        cookie_jar: CookieJar,
3780        clock: Option<std::sync::Arc<dyn crate::time::ClockSource>>,
3781        n_plus_one_threshold: usize,
3782        observed_server_errors: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
3783    ) -> Self {
3784        Self {
3785            router,
3786            method,
3787            uri: uri.to_owned(),
3788            headers: Vec::new(),
3789            body: Body::empty(),
3790            cookie_jar: Some(cookie_jar),
3791            clock,
3792            n_plus_one_threshold,
3793            observed_server_errors,
3794        }
3795    }
3796
3797    /// Add a header to the request.
3798    #[must_use]
3799    pub fn header(mut self, name: &str, value: &str) -> Self {
3800        self.headers.push((name.to_owned(), value.to_owned()));
3801        self
3802    }
3803
3804    /// Set the request body to a JSON-serialized value.
3805    ///
3806    /// Automatically sets `Content-Type: application/json`.
3807    #[must_use]
3808    pub fn json(mut self, value: &serde_json::Value) -> Self {
3809        self.headers
3810            .push(("content-type".to_owned(), "application/json".to_owned()));
3811        self.body = Body::from(serde_json::to_vec(value).expect("failed to serialize JSON body"));
3812        self
3813    }
3814
3815    /// Set the request body to URL-encoded form data.
3816    ///
3817    /// Automatically sets `Content-Type: application/x-www-form-urlencoded`
3818    /// and `Sec-Fetch-Site: same-origin` to mirror what a real browser
3819    /// would send for a same-origin `<form method="post">` — which is
3820    /// what the method-override middleware requires to honour
3821    /// `_method=PUT|PATCH|DELETE` overrides.
3822    #[must_use]
3823    pub fn form(mut self, body: &str) -> Self {
3824        self.headers.push((
3825            "content-type".to_owned(),
3826            "application/x-www-form-urlencoded".to_owned(),
3827        ));
3828        self.headers
3829            .push(("sec-fetch-site".to_owned(), "same-origin".to_owned()));
3830        self.body = Body::from(body.to_owned());
3831        self
3832    }
3833
3834    /// Set a raw string body.
3835    #[must_use]
3836    pub fn body(mut self, body: impl Into<Body>) -> Self {
3837        self.body = body.into();
3838        self
3839    }
3840
3841    /// Fire the request through the full middleware pipeline and return
3842    /// a [`TestResponse`].
3843    pub async fn send(self) -> TestResponse {
3844        // Captured for failure messages and the N+1 default threshold on the
3845        // resulting `TestResponse`.
3846        let request_method = self.method.to_string();
3847        let request_path = self.uri.clone();
3848        let n_plus_one_threshold = self.n_plus_one_threshold;
3849        // Cloned up front: `self` is partially moved below (the router and body
3850        // are consumed building the request).
3851        let observed_server_errors = self.observed_server_errors.clone();
3852
3853        let mut builder = Request::builder().method(self.method).uri(&self.uri);
3854
3855        // Replay the cookie jar: compose a `Cookie` header from stored cookies
3856        // unless the caller already set one explicitly (an explicit header
3857        // wins, so tests can still exercise raw cookie behavior).
3858        let caller_set_cookie = self
3859            .headers
3860            .iter()
3861            .any(|(name, _)| name.eq_ignore_ascii_case("cookie"));
3862        if !caller_set_cookie && let Some(jar) = &self.cookie_jar {
3863            // Evaluate expiry against the same clock the jar folds `Set-Cookie`
3864            // with, so a virtual-clock test sees cookies stop replaying once it
3865            // advances past their `Max-Age`/`Expires`. Prune expired entries in
3866            // passing so they don't linger.
3867            let now = self
3868                .clock
3869                .as_ref()
3870                .map_or_else(crate::time::ambient_now, |c| c.now());
3871            let cookie_header = {
3872                let mut jar = jar.lock().expect("cookie jar mutex poisoned");
3873                jar.retain(|_, cookie| cookie.expires_at.is_none_or(|t| t > now));
3874                jar.iter()
3875                    .map(|(name, cookie)| format!("{name}={}", cookie.value))
3876                    .collect::<Vec<_>>()
3877                    .join("; ")
3878            };
3879            if !cookie_header.is_empty() {
3880                builder = builder.header(http::header::COOKIE, cookie_header);
3881            }
3882        }
3883
3884        for (name, value) in &self.headers {
3885            builder = builder.header(name.as_str(), value.as_str());
3886        }
3887
3888        let request = builder.body(self.body).expect("failed to build request");
3889
3890        // Wrap the router with MethodOverrideLayer the same way the production
3891        // serve site does, so a POST with a `_method=DELETE` form field reaches
3892        // the declared DELETE handler in tests too. The layer is a no-op for
3893        // non-POST methods and non-form bodies, so it's safe to apply
3894        // unconditionally.
3895        let service =
3896            tower::Layer::layer(&crate::middleware::MethodOverrideLayer::new(), self.router);
3897
3898        // Drive the request under a per-request `REQUEST_QUERY_CAPTURE` scope so
3899        // the connection-level `RequestQueryTimer` (installed at `Db::checkout`
3900        // whenever this capture lane is active) records every SQL statement the
3901        // handler issues into the capture sink — no manual `DbInterceptor`
3902        // wiring required. This lane is independent of the `Server-Timing`
3903        // timing accumulator (`REQUEST_DB_TIMINGS`), so query capture is
3904        // unaffected by however `ServerTimingLayer` scopes (and nests) its
3905        // per-scope DB metrics. `oneshot` runs on this same task, so the
3906        // task-local is visible to the checkout. When the `db` feature is off
3907        // there is no DB, so the captured query list is simply empty.
3908        //
3909        // The response body is drained (`to_bytes`) *inside* the scope so that
3910        // handlers returning a lazy or streaming body (`Sse`, `Body::from_stream`,
3911        // …) which perform DB work when the stream is polled still record those
3912        // body-time checkouts into the capture sink. The sink is read only after
3913        // the body is fully collected, so nothing is missed.
3914        #[cfg(feature = "db")]
3915        let (status, headers, body_bytes, queries) = {
3916            let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3917            let (status, headers, body_bytes) = crate::db::REQUEST_QUERY_CAPTURE
3918                .scope(std::sync::Arc::clone(&capture), async move {
3919                    let response = service.oneshot(request).await.expect("request failed");
3920                    let status = response.status();
3921                    let headers: Vec<(String, String)> = response
3922                        .headers()
3923                        .iter()
3924                        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_owned()))
3925                        .collect();
3926                    let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
3927                        .await
3928                        .expect("failed to read response body");
3929                    (status, headers, body_bytes)
3930                })
3931                .await;
3932            let queries = capture.lock().map(|v| v.clone()).unwrap_or_default();
3933            (status, headers, body_bytes, queries)
3934        };
3935        #[cfg(not(feature = "db"))]
3936        let (status, headers, body_bytes, queries): (
3937            _,
3938            _,
3939            _,
3940            Vec<crate::inspector::QueryRecord>,
3941        ) = {
3942            let response = service.oneshot(request).await.expect("request failed");
3943            let status = response.status();
3944            let headers: Vec<(String, String)> = response
3945                .headers()
3946                .iter()
3947                .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_owned()))
3948                .collect();
3949            let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
3950                .await
3951                .expect("failed to read response body");
3952            (status, headers, body_bytes, Vec::new())
3953        };
3954
3955        // Fold every `Set-Cookie` from the response back into the jar so the
3956        // next request from the same client replays it. Cookies whose
3957        // attributes mark them for deletion (`Max-Age=0` or a past `Expires`)
3958        // are removed instead of stored.
3959        if let Some(jar) = &self.cookie_jar {
3960            let now = self
3961                .clock
3962                .as_ref()
3963                .map_or_else(crate::time::ambient_now, |c| c.now());
3964            let mut jar = jar.lock().expect("cookie jar mutex poisoned");
3965            for (name, value) in &headers {
3966                if name.eq_ignore_ascii_case("set-cookie") {
3967                    apply_set_cookie(&mut jar, value, now);
3968                }
3969            }
3970        }
3971
3972        // Count the 5xx an attached fault plan will want to see reflected in
3973        // `FaultOutcome::server_errors`; `fault_outcome()` settles against it.
3974        if status.is_server_error()
3975            && let Some(counter) = observed_server_errors.as_ref()
3976        {
3977            counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3978        }
3979
3980        TestResponse {
3981            status,
3982            headers,
3983            body: body_bytes.to_vec(),
3984            queries,
3985            request_method,
3986            request_path,
3987            n_plus_one_threshold,
3988        }
3989    }
3990}
3991
3992// ── TestResponse ───────────────────────────────────────────────
3993
3994/// HTTP response from a test request with fluent assertion helpers.
3995///
3996/// All assertion methods return `&Self` for chaining:
3997///
3998/// ```rust,ignore
3999/// client.get("/users/1").send().await
4000///     .assert_ok()
4001///     .assert_header("content-type", "application/json")
4002///     .assert_body_contains("Alice");
4003/// ```
4004///
4005/// The `status`, `headers`, and `body` fields are public so you can construct a
4006/// `TestResponse` directly in unit tests that don't need a full HTTP
4007/// round-trip. Fill the remaining (query-capture) fields with
4008/// `..Default::default()`:
4009///
4010/// ```rust
4011/// use autumn_web::test::TestResponse;
4012/// use axum::http::StatusCode;
4013///
4014/// let resp = TestResponse {
4015///     status: StatusCode::OK,
4016///     headers: vec![
4017///         ("content-type".into(), "application/json".into()),
4018///         ("x-request-id".into(), "abc-123".into()),
4019///     ],
4020///     body: br#"{"name":"Alice"}"#.to_vec(),
4021///     ..Default::default()
4022/// };
4023///
4024/// resp.assert_ok()
4025///     .assert_header_contains("content-type", "json")
4026///     .assert_body_contains("Alice");
4027///
4028/// assert_eq!(resp.header("x-request-id"), Some("abc-123"));
4029/// ```
4030///
4031/// # Query-count and N+1 assertions
4032///
4033/// When the response was produced by [`RequestBuilder::send`] against a
4034/// database-backed app, every SQL statement the handler issued is captured
4035/// automatically (no manual interceptor wiring). Assert on it with
4036/// [`TestResponse::query_count`], [`TestResponse::assert_max_queries`], and
4037/// [`TestResponse::assert_no_n_plus_one`]:
4038///
4039/// ```rust,no_run
4040/// # async fn ex(client: autumn_web::test::TestClient) {
4041/// client.get("/posts").send().await
4042///     .assert_ok()
4043///     .assert_max_queries(3)   // fails, naming GET /posts, if > 3 queries ran
4044///     .assert_no_n_plus_one(); // fails if a query repeats >= the dev threshold
4045/// # }
4046/// ```
4047pub struct TestResponse {
4048    /// HTTP status code.
4049    pub status: StatusCode,
4050    /// Response headers as `(name, value)` pairs.
4051    pub headers: Vec<(String, String)>,
4052    /// Raw response body bytes.
4053    pub body: Vec<u8>,
4054    /// SQL queries captured while handling the request, in execution order.
4055    ///
4056    /// Populated automatically by [`RequestBuilder::send`] for
4057    /// database-backed apps; empty for directly-constructed responses or when
4058    /// the `db` feature is disabled. Prefer the [`TestResponse::queries`]
4059    /// accessor for reading.
4060    pub queries: Vec<crate::inspector::QueryRecord>,
4061    /// HTTP method of the originating request, for assertion failure messages.
4062    pub request_method: String,
4063    /// Path of the originating request, for assertion failure messages.
4064    pub request_path: String,
4065    /// Default N+1 threshold (`dev.inspector_n_plus_one_threshold`) used by
4066    /// [`TestResponse::assert_no_n_plus_one`].
4067    pub n_plus_one_threshold: usize,
4068}
4069
4070impl Default for TestResponse {
4071    fn default() -> Self {
4072        Self {
4073            status: StatusCode::OK,
4074            headers: Vec::new(),
4075            body: Vec::new(),
4076            queries: Vec::new(),
4077            request_method: String::new(),
4078            request_path: String::new(),
4079            // Inherit the detector's default (5) — not a zero-filled `0`, which
4080            // `inspector::detect_n_plus_one` treats as DISABLED — so the
4081            // documented `TestResponse { .. ..Default::default() }` construction
4082            // still catches N+1 patterns.
4083            n_plus_one_threshold: crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD,
4084        }
4085    }
4086}
4087
4088impl TestResponse {
4089    /// Get the response body as a UTF-8 string.
4090    ///
4091    /// # Panics
4092    ///
4093    /// Panics if the body is not valid UTF-8.
4094    #[must_use]
4095    pub fn text(&self) -> String {
4096        String::from_utf8(self.body.clone()).unwrap_or_else(|e| {
4097            panic!(
4098                "response body is not valid UTF-8: {e}\nRaw bytes: {:?}",
4099                self.body
4100            )
4101        })
4102    }
4103
4104    /// Deserialize the response body as JSON.
4105    ///
4106    /// # Panics
4107    ///
4108    /// Panics if the body is not valid JSON or cannot be deserialized
4109    /// into `T`.
4110    #[must_use]
4111    pub fn json<T: serde::de::DeserializeOwned>(&self) -> T {
4112        serde_json::from_slice(&self.body).unwrap_or_else(|e| {
4113            panic!(
4114                "failed to parse response body as JSON: {e}\nBody: {}",
4115                String::from_utf8_lossy(&self.body)
4116            )
4117        })
4118    }
4119
4120    /// Get the value of a response header.
4121    #[must_use]
4122    pub fn header(&self, name: &str) -> Option<&str> {
4123        let name_lower = name.to_lowercase();
4124        self.headers
4125            .iter()
4126            .find(|(k, _)| k.to_lowercase() == name_lower)
4127            .map(|(_, v)| v.as_str())
4128    }
4129
4130    // ── Assertion helpers ──────────────────────────────────────
4131
4132    /// Assert the response status is 200 OK.
4133    #[track_caller]
4134    pub fn assert_ok(&self) -> &Self {
4135        assert_eq!(
4136            self.status,
4137            StatusCode::OK,
4138            "expected 200 OK, got {}.\nBody: {}",
4139            self.status,
4140            String::from_utf8_lossy(&self.body)
4141        );
4142        self
4143    }
4144
4145    /// Assert the response status matches the given code.
4146    #[track_caller]
4147    pub fn assert_status(&self, expected: u16) -> &Self {
4148        assert_eq!(
4149            self.status.as_u16(),
4150            expected,
4151            "expected status {expected}, got {}.\nBody: {}",
4152            self.status,
4153            String::from_utf8_lossy(&self.body)
4154        );
4155        self
4156    }
4157
4158    /// Assert the response status indicates a successful request (2xx).
4159    #[track_caller]
4160    pub fn assert_success(&self) -> &Self {
4161        assert!(
4162            self.status.is_success(),
4163            "expected 2xx success, got {}.\nBody: {}",
4164            self.status,
4165            String::from_utf8_lossy(&self.body)
4166        );
4167        self
4168    }
4169
4170    /// Assert a response header exists and equals the expected value.
4171    #[track_caller]
4172    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
4173        let value = self.header(name).unwrap_or_else(|| {
4174            panic!(
4175                "expected header `{name}` to be present.\nAvailable headers: {:?}",
4176                self.headers
4177            )
4178        });
4179        assert_eq!(
4180            value, expected,
4181            "header `{name}`: expected `{expected}`, got `{value}`"
4182        );
4183        self
4184    }
4185
4186    /// Assert a response header exists and contains the expected substring.
4187    #[track_caller]
4188    pub fn assert_header_contains(&self, name: &str, substring: &str) -> &Self {
4189        let value = self.header(name).unwrap_or_else(|| {
4190            panic!(
4191                "expected header `{name}` to be present.\nAvailable headers: {:?}",
4192                self.headers
4193            )
4194        });
4195        assert!(
4196            value.contains(substring),
4197            "header `{name}`: expected `{value}` to contain `{substring}`"
4198        );
4199        self
4200    }
4201
4202    /// Assert the response body contains the given substring.
4203    #[track_caller]
4204    pub fn assert_body_contains(&self, substring: &str) -> &Self {
4205        let body = self.text();
4206        assert!(
4207            body.contains(substring),
4208            "expected body to contain `{substring}`.\nBody: {body}"
4209        );
4210        self
4211    }
4212
4213    /// Assert a rendered PDF response's extracted text contains the given
4214    /// substring — e.g. `resp.assert_pdf_contains("Total: $42.00")`.
4215    ///
4216    /// Extracts text via [`crate::pdf::extract_text`], which reads back
4217    /// exactly what [`Pdf`](crate::pdf::Pdf) (or any well-formed PDF) wrote,
4218    /// so this works against the in-process test client with no headless
4219    /// browser involved.
4220    ///
4221    /// # Panics
4222    ///
4223    /// Panics if the body isn't a parseable PDF, or doesn't contain
4224    /// `substring`.
4225    #[cfg(feature = "pdf")]
4226    #[track_caller]
4227    pub fn assert_pdf_contains(&self, substring: &str) -> &Self {
4228        let text = crate::pdf::extract_text(&self.body).unwrap_or_else(|e| {
4229            panic!(
4230                "response body is not a parseable PDF: {e}\n{} bytes",
4231                self.body.len()
4232            )
4233        });
4234        assert!(
4235            text.contains(substring),
4236            "expected PDF text to contain `{substring}`.\nExtracted text: {text}"
4237        );
4238        self
4239    }
4240
4241    /// Assert the response body exactly equals the given string.
4242    #[track_caller]
4243    pub fn assert_body_eq(&self, expected: &str) -> &Self {
4244        let body = self.text();
4245        assert_eq!(body, expected, "body mismatch.\nActual Body: {body}");
4246        self
4247    }
4248
4249    /// Assert the response body deserializes to JSON matching the predicate.
4250    #[track_caller]
4251    pub fn assert_json<T, F>(&self, predicate: F) -> &Self
4252    where
4253        T: serde::de::DeserializeOwned,
4254        F: FnOnce(&T),
4255    {
4256        let value: T = self.json();
4257        predicate(&value);
4258        self
4259    }
4260
4261    /// Assert the response body is empty.
4262    #[track_caller]
4263    pub fn assert_body_empty(&self) -> &Self {
4264        assert!(
4265            self.body.is_empty(),
4266            "expected empty body, got {} bytes: {}",
4267            self.body.len(),
4268            String::from_utf8_lossy(&self.body)
4269        );
4270        self
4271    }
4272
4273    // ── CSS-selector HTML assertions ────────────────────────────
4274    //
4275    // Autumn renders server-side HTML (Maud + htmx), so tests want to assert on
4276    // page *structure* — "the table has exactly 3 rows", "there is a `<form>`
4277    // posting to `/notes`" — rather than brittle substrings. These helpers parse
4278    // the body with a real HTML parser and match against a CSS-selector subset
4279    // (tag, `.class`, `#id`, `[attr=…]`, plus descendant/child combinators), so
4280    // assertions survive cosmetic template changes (whitespace, attribute order,
4281    // wrapping markup) that would break [`assert_body_contains`].
4282    //
4283    // They work for full documents and for partial/fragment responses (htmx
4284    // swaps) alike, and compose with the other matchers — every method returns
4285    // `&Self` for chaining.
4286    //
4287    // ```rust,ignore
4288    // client.get("/notes").send().await
4289    //     .assert_ok()
4290    //     .assert_selector_count("tbody tr.note-row", 3)   // exactly 3 rows
4291    //     .assert_attr("tr.note-row:first-child a", "href", "/notes/1")
4292    //     .assert_text("h1", "Notes");
4293    // ```
4294
4295    /// Parse the response body as HTML once for a selector assertion.
4296    fn parse_html(&self) -> Vec<crate::test_html::Node> {
4297        crate::test_html::parse(&self.text())
4298    }
4299
4300    /// Compile a CSS selector, panicking with an actionable message on a
4301    /// malformed selector.
4302    #[track_caller]
4303    fn compile_selector(css: &str) -> crate::test_html::SelectorList {
4304        crate::test_html::SelectorList::parse(css)
4305            .unwrap_or_else(|e| panic!("invalid CSS selector `{css}`: {e}"))
4306    }
4307
4308    /// A truncated, indented outline of the parsed HTML for failure messages.
4309    fn html_outline(nodes: &[crate::test_html::Node]) -> String {
4310        crate::test_html::outline(nodes, 1200)
4311    }
4312
4313    /// Return the normalized text content of every element matching `css`, in
4314    /// document order. Non-asserting accessor for custom assertions.
4315    ///
4316    /// Whitespace within each element's text is collapsed and trimmed so values
4317    /// are stable across indentation and line-wrapping changes.
4318    #[must_use]
4319    #[track_caller]
4320    pub fn selector_text(&self, css: &str) -> Vec<String> {
4321        let selector = Self::compile_selector(css);
4322        let nodes = self.parse_html();
4323        selector
4324            .matches(&nodes)
4325            .iter()
4326            .map(|el| crate::test_html::normalize_ws(&el.text()))
4327            .collect()
4328    }
4329
4330    /// Return the value of attribute `attr` for every element matching `css`,
4331    /// in document order (`None` for matches lacking the attribute).
4332    /// Non-asserting accessor for custom assertions.
4333    #[must_use]
4334    #[track_caller]
4335    pub fn selector_attr(&self, css: &str, attr: &str) -> Vec<Option<String>> {
4336        let selector = Self::compile_selector(css);
4337        let nodes = self.parse_html();
4338        selector
4339            .matches(&nodes)
4340            .iter()
4341            .map(|el| el.attr(attr).map(str::to_string))
4342            .collect()
4343    }
4344
4345    /// Return the number of elements matching `css`. Non-asserting accessor.
4346    #[must_use]
4347    #[track_caller]
4348    pub fn selector_count(&self, css: &str) -> usize {
4349        let selector = Self::compile_selector(css);
4350        let nodes = self.parse_html();
4351        selector.matches(&nodes).len()
4352    }
4353
4354    /// Assert at least one element matches the CSS selector.
4355    #[track_caller]
4356    pub fn assert_selector(&self, css: &str) -> &Self {
4357        let selector = Self::compile_selector(css);
4358        let nodes = self.parse_html();
4359        let count = selector.matches(&nodes).len();
4360        assert!(
4361            count > 0,
4362            "no elements matched selector `{css}`.\nParsed HTML:\n{}",
4363            Self::html_outline(&nodes)
4364        );
4365        self
4366    }
4367
4368    /// Assert that *no* element matches the CSS selector.
4369    #[track_caller]
4370    pub fn assert_no_selector(&self, css: &str) -> &Self {
4371        let selector = Self::compile_selector(css);
4372        let nodes = self.parse_html();
4373        let count = selector.matches(&nodes).len();
4374        assert!(
4375            count == 0,
4376            "expected no elements matching selector `{css}`, but found {count}.\nParsed HTML:\n{}",
4377            Self::html_outline(&nodes)
4378        );
4379        self
4380    }
4381
4382    /// Assert exactly `expected` elements match the CSS selector.
4383    #[track_caller]
4384    pub fn assert_selector_count(&self, css: &str, expected: usize) -> &Self {
4385        let selector = Self::compile_selector(css);
4386        let nodes = self.parse_html();
4387        let actual = selector.matches(&nodes).len();
4388        assert!(
4389            actual == expected,
4390            "expected {expected} element(s) matching selector `{css}`, found {actual}.\n\
4391             Parsed HTML:\n{}",
4392            Self::html_outline(&nodes)
4393        );
4394        self
4395    }
4396
4397    /// Assert the first element matching `css` has text content equal to
4398    /// `expected` (whitespace-normalized on both sides).
4399    #[track_caller]
4400    pub fn assert_text(&self, css: &str, expected: &str) -> &Self {
4401        let selector = Self::compile_selector(css);
4402        let nodes = self.parse_html();
4403        let matched = selector.matches(&nodes);
4404        let Some(first) = matched.into_iter().next() else {
4405            panic!(
4406                "no elements matched selector `{css}`.\nParsed HTML:\n{}",
4407                Self::html_outline(&nodes)
4408            );
4409        };
4410        let actual = crate::test_html::normalize_ws(&first.text());
4411        let expected_norm = crate::test_html::normalize_ws(expected);
4412        assert!(
4413            actual == expected_norm,
4414            "text mismatch for selector `{css}`:\n  expected: {expected_norm:?}\n  \
4415             actual:   {actual:?}\nParsed HTML:\n{}",
4416            Self::html_outline(&nodes)
4417        );
4418        self
4419    }
4420
4421    /// Assert the first element matching `css` has text content containing
4422    /// `substring` (whitespace-normalized on both sides).
4423    #[track_caller]
4424    pub fn assert_text_contains(&self, css: &str, substring: &str) -> &Self {
4425        let selector = Self::compile_selector(css);
4426        let nodes = self.parse_html();
4427        let matched = selector.matches(&nodes);
4428        let Some(first) = matched.into_iter().next() else {
4429            panic!(
4430                "no elements matched selector `{css}`.\nParsed HTML:\n{}",
4431                Self::html_outline(&nodes)
4432            );
4433        };
4434        let actual = crate::test_html::normalize_ws(&first.text());
4435        let needle = crate::test_html::normalize_ws(substring);
4436        assert!(
4437            actual.contains(&needle),
4438            "text for selector `{css}` did not contain {needle:?}.\n  actual: {actual:?}\n\
4439             Parsed HTML:\n{}",
4440            Self::html_outline(&nodes)
4441        );
4442        self
4443    }
4444
4445    /// Assert the first element matching `css` has attribute `attr` equal to
4446    /// `expected`.
4447    #[track_caller]
4448    pub fn assert_attr(&self, css: &str, attr: &str, expected: &str) -> &Self {
4449        let selector = Self::compile_selector(css);
4450        let nodes = self.parse_html();
4451        let matched = selector.matches(&nodes);
4452        let Some(first) = matched.into_iter().next() else {
4453            panic!(
4454                "no elements matched selector `{css}`.\nParsed HTML:\n{}",
4455                Self::html_outline(&nodes)
4456            );
4457        };
4458        match first.attr(attr) {
4459            Some(actual) => assert!(
4460                actual == expected,
4461                "attribute `{attr}` mismatch for selector `{css}`:\n  expected: {expected:?}\n  \
4462                 actual:   {actual:?}\nParsed HTML:\n{}",
4463                Self::html_outline(&nodes)
4464            ),
4465            None => panic!(
4466                "element matching selector `{css}` has no `{attr}` attribute.\n\
4467                 Parsed HTML:\n{}",
4468                Self::html_outline(&nodes)
4469            ),
4470        }
4471        self
4472    }
4473
4474    // ── Database query assertions (#1262) ──────────────────────
4475
4476    /// Number of SQL queries the request issued.
4477    ///
4478    /// Captured automatically by [`RequestBuilder::send`] for database-backed
4479    /// apps; `0` for directly-constructed responses or when the `db` feature
4480    /// is disabled.
4481    #[must_use]
4482    pub const fn query_count(&self) -> usize {
4483        self.queries.len()
4484    }
4485
4486    /// The SQL queries the request issued, in execution order.
4487    ///
4488    /// Lets a test assert on specific normalized SQL. Empty for
4489    /// directly-constructed responses or when the `db` feature is disabled.
4490    #[must_use]
4491    pub fn queries(&self) -> &[crate::inspector::QueryRecord] {
4492        &self.queries
4493    }
4494
4495    /// A per-query listing for assertion failure messages: one line per query
4496    /// (`#N  <elapsed>ms  <sql>`), followed by repetition counts per
4497    /// normalized statement so the offending pattern is obvious.
4498    fn query_report(&self) -> String {
4499        use std::collections::BTreeMap;
4500        use std::fmt::Write as _;
4501        let mut out = String::new();
4502        for (i, q) in self.queries.iter().enumerate() {
4503            let _ = write!(
4504                out,
4505                "\n  #{n:<3} {ms:>4}ms  {sql}",
4506                n = i + 1,
4507                ms = q.elapsed_ms,
4508                sql = q.sql,
4509            );
4510        }
4511        // Counts per normalized statement (stable, sorted for determinism).
4512        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4513        for q in &self.queries {
4514            *counts
4515                .entry(q.sql.split_whitespace().collect::<Vec<_>>().join(" "))
4516                .or_insert(0) += 1;
4517        }
4518        if counts.len() != self.queries.len() {
4519            out.push_str("\n  ── counts per statement ──");
4520            for (sql, count) in &counts {
4521                let _ = write!(out, "\n  {count}x  {sql}");
4522            }
4523        }
4524        out
4525    }
4526
4527    /// Assert the request issued at most `n` SQL queries.
4528    ///
4529    /// Passes when `query_count() <= n`. Panics otherwise with a message
4530    /// naming the request (method + path), the expected and actual counts, and
4531    /// the full query list.
4532    #[track_caller]
4533    pub fn assert_max_queries(&self, n: usize) -> &Self {
4534        let actual = self.queries.len();
4535        assert!(
4536            actual <= n,
4537            "assert_max_queries failed for {method} {path}: expected <= {n} queries, issued {actual}.{report}",
4538            method = self.request_method,
4539            path = self.request_path,
4540            report = self.query_report(),
4541        );
4542        self
4543    }
4544
4545    /// Assert the request contains no N+1 query pattern, using the app's
4546    /// configured `dev.inspector_n_plus_one_threshold` (default 5).
4547    ///
4548    /// Reuses [`crate::inspector::detect_n_plus_one`]. Panics, naming the
4549    /// request and the offending normalized query + repetition count, when a
4550    /// single normalized statement was issued at least `threshold` times.
4551    ///
4552    /// Use [`TestResponse::assert_no_n_plus_one_with_threshold`] to override
4553    /// the threshold explicitly.
4554    #[track_caller]
4555    pub fn assert_no_n_plus_one(&self) -> &Self {
4556        self.assert_no_n_plus_one_with_threshold(self.n_plus_one_threshold)
4557    }
4558
4559    /// Like [`TestResponse::assert_no_n_plus_one`] but with an explicit
4560    /// repetition `threshold` instead of the configured default.
4561    #[track_caller]
4562    pub fn assert_no_n_plus_one_with_threshold(&self, threshold: usize) -> &Self {
4563        match crate::inspector::detect_n_plus_one(&self.queries, threshold) {
4564            Some(w) => panic!(
4565                "assert_no_n_plus_one failed for {method} {path}: query repeated {count} times \
4566                 (threshold {threshold}):\n  {sql}{report}",
4567                method = self.request_method,
4568                path = self.request_path,
4569                count = w.count,
4570                sql = w.sql_template,
4571                report = self.query_report(),
4572            ),
4573            None => self,
4574        }
4575    }
4576}
4577
4578// Constructed only by the Postgres transactional test-isolation establish path,
4579// which is cfg'd out under the `sqlite` feature — so gate these out too.
4580#[cfg(all(feature = "db", not(feature = "sqlite")))]
4581struct TransactionalDbInterceptor;
4582
4583#[cfg(all(feature = "db", not(feature = "sqlite")))]
4584impl crate::interceptor::DbConnectionInterceptor for TransactionalDbInterceptor {
4585    fn intercept_checkout<'a>(
4586        &'a self,
4587        _ctx: crate::interceptor::DbCheckoutContext,
4588        next: std::pin::Pin<
4589            Box<
4590                dyn std::future::Future<
4591                        Output = Result<crate::db::PooledConnection, crate::AutumnError>,
4592                    > + Send
4593                    + 'a,
4594            >,
4595        >,
4596    ) -> std::pin::Pin<
4597        Box<
4598            dyn std::future::Future<
4599                    Output = Result<crate::db::PooledConnection, crate::AutumnError>,
4600                > + Send
4601                + 'a,
4602        >,
4603    > {
4604        Box::pin(async move {
4605            let mut conn = next.await?;
4606
4607            // Check if transaction has already been started on this connection
4608            let guc_result = diesel::select(diesel::dsl::sql::<
4609                diesel::sql_types::Nullable<diesel::sql_types::Text>,
4610            >(
4611                "current_setting('autumn.test_transaction_started', true)",
4612            ))
4613            .get_result::<Option<String>>(&mut *conn)
4614            .await;
4615
4616            match guc_result {
4617                Ok(Some(ref s)) if s == "true" => {
4618                    // Already started and healthy
4619                }
4620                Ok(_) => {
4621                    use diesel_async::AsyncConnection;
4622                    use diesel_async::RunQueryDsl;
4623
4624                    conn.begin_test_transaction().await.map_err(|e| {
4625                        crate::AutumnError::internal_server_error_msg(format!(
4626                            "failed to start test transaction: {e}"
4627                        ))
4628                    })?;
4629
4630                    diesel::sql_query("SET autumn.test_transaction_started = 'true'")
4631                        .execute(&mut *conn)
4632                        .await
4633                        .map_err(|e| {
4634                            crate::AutumnError::internal_server_error_msg(format!(
4635                                "failed to set transaction session GUC: {e}"
4636                            ))
4637                        })?;
4638                }
4639                Err(_) => {
4640                    // The GUC query failed. This happens when the connection is in a failed/aborted transaction block.
4641                    // Since the transaction is already active (but aborted), do not retry begin_test_transaction!
4642                }
4643            }
4644            Ok(conn)
4645        })
4646    }
4647
4648    fn is_transactional_test(&self) -> bool {
4649        true
4650    }
4651}
4652
4653// See `TransactionalDbInterceptor`: only the Postgres transactional establish
4654// path composes interceptors, so this is dead under the `sqlite` feature.
4655#[cfg(all(feature = "db", not(feature = "sqlite")))]
4656struct ComposedDbInterceptor {
4657    first: std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>,
4658    second: std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>,
4659}
4660
4661#[cfg(all(feature = "db", not(feature = "sqlite")))]
4662impl crate::interceptor::DbConnectionInterceptor for ComposedDbInterceptor {
4663    fn intercept_checkout<'a>(
4664        &'a self,
4665        ctx: crate::interceptor::DbCheckoutContext,
4666        next: std::pin::Pin<
4667            Box<
4668                dyn std::future::Future<
4669                        Output = Result<crate::db::PooledConnection, crate::AutumnError>,
4670                    > + Send
4671                    + 'a,
4672            >,
4673        >,
4674    ) -> std::pin::Pin<
4675        Box<
4676            dyn std::future::Future<
4677                    Output = Result<crate::db::PooledConnection, crate::AutumnError>,
4678                > + Send
4679                + 'a,
4680        >,
4681    > {
4682        let next_wrapped = self.second.intercept_checkout(ctx.clone(), next);
4683        self.first.intercept_checkout(ctx, next_wrapped)
4684    }
4685
4686    fn is_transactional_test(&self) -> bool {
4687        self.first.is_transactional_test() || self.second.is_transactional_test()
4688    }
4689}
4690
4691// ── TestDb ─────────────────────────────────────────────────────
4692
4693/// Shared Postgres testcontainer for database integration tests.
4694///
4695/// Under `--features sqlite` this type is a tempfile `SQLite` database instead
4696/// (issue #2555); the API is the same.
4697///
4698/// Rather than spinning up a new container per test (slow!), `TestDb`
4699/// provides a shared container that all tests in a binary can reuse.
4700/// This mirrors Spring Boot's `@Testcontainers` with `@Container` +
4701/// `static` pattern.
4702///
4703/// Requires the `test-support` feature (and `db`):
4704///
4705/// ```toml
4706/// [dev-dependencies]
4707/// autumn-web = { path = "..", features = ["test-support"] }
4708/// ```
4709///
4710/// # Examples
4711///
4712/// ```rust,ignore
4713/// use autumn_web::test::{TestApp, TestDb};
4714///
4715/// #[tokio::test]
4716/// #[ignore = "requires Docker"]
4717/// async fn db_test() {
4718///     let db = TestDb::shared().await;
4719///     let client = TestApp::new()
4720///         .routes(routes![my_handler])
4721///         .with_db(db.pool())
4722///         .build();
4723///
4724///     // Run migrations or seed data via db.pool()
4725///     client.get("/data").send().await.assert_ok();
4726/// }
4727/// ```
4728#[cfg(all(feature = "db", feature = "test-support", not(feature = "sqlite")))]
4729pub struct TestDb {
4730    _container: testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>,
4731    pool: Pool<AsyncPgConnection>,
4732    url: String,
4733}
4734
4735#[cfg(all(feature = "db", feature = "test-support", not(feature = "sqlite")))]
4736impl TestDb {
4737    /// Start a new Postgres testcontainer and create a connection pool.
4738    ///
4739    /// For most test suites, prefer [`TestDb::shared()`] to reuse a
4740    /// single container across all tests.
4741    pub async fn new() -> Self {
4742        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
4743        use testcontainers::runners::AsyncRunner;
4744        use testcontainers_modules::postgres::Postgres;
4745
4746        let container = Postgres::default()
4747            .start()
4748            .await
4749            .expect("failed to start Postgres testcontainer (is Docker running?)");
4750
4751        let host = container
4752            .get_host()
4753            .await
4754            .expect("failed to build test router");
4755        let port = container
4756            .get_host_port_ipv4(5432)
4757            .await
4758            .expect("failed to build test router");
4759        let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
4760
4761        let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(&url);
4762        let pool = Pool::builder(manager)
4763            .max_size(5)
4764            .build()
4765            .expect("failed to build connection pool");
4766
4767        Self {
4768            _container: container,
4769            pool,
4770            url,
4771        }
4772    }
4773
4774    /// Get a shared `TestDb` instance, starting the container on first use.
4775    ///
4776    /// Uses a process-global `OnceLock` so the container is started only
4777    /// once per test binary, regardless of how many tests call this method.
4778    /// This dramatically speeds up test suites with multiple DB tests.
4779    ///
4780    /// The container is automatically cleaned up when the process exits.
4781    pub async fn shared() -> &'static Self {
4782        use std::sync::OnceLock;
4783        use tokio::sync::OnceCell;
4784
4785        // Two-phase init: OnceLock for the OnceCell, OnceCell for the async init.
4786        static CELL: OnceLock<OnceCell<TestDb>> = OnceLock::new();
4787        let once = CELL.get_or_init(OnceCell::new);
4788        once.get_or_init(Self::new).await
4789    }
4790
4791    /// Get the database connection pool.
4792    #[must_use]
4793    pub fn pool(&self) -> Pool<AsyncPgConnection> {
4794        self.pool.clone()
4795    }
4796
4797    /// Get the Postgres connection URL.
4798    #[must_use]
4799    pub fn url(&self) -> &str {
4800        &self.url
4801    }
4802
4803    /// Execute raw SQL against the test database.
4804    ///
4805    /// Useful for creating tables, seeding data, or running migrations
4806    /// in tests.
4807    ///
4808    /// # Examples
4809    ///
4810    /// ```rust,ignore
4811    /// let db = TestDb::shared().await;
4812    /// db.execute_sql("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)")
4813    ///     .await;
4814    /// ```
4815    pub async fn execute_sql(&self, sql: &str) {
4816        use diesel_async::RunQueryDsl;
4817        let mut conn = self.pool.get().await.expect("failed to get connection");
4818        diesel::sql_query(sql)
4819            .execute(&mut *conn)
4820            .await
4821            .unwrap_or_else(|e| panic!("SQL execution failed: {e}\nSQL: {sql}"));
4822    }
4823
4824    /// Empty `table` and restart its id sequence (`TRUNCATE … RESTART IDENTITY`).
4825    ///
4826    /// `table` must match `[A-Za-z0-9_]+`. Schema-qualified names are not allowed.
4827    ///
4828    /// # Panics
4829    ///
4830    /// Panics if `table` is not a plain identifier, or if the SQL fails.
4831    pub async fn reset_table(&self, table: &str) {
4832        assert_plain_identifier(table);
4833        self.execute_sql(&format!("TRUNCATE \"{table}\" RESTART IDENTITY"))
4834            .await;
4835    }
4836}
4837
4838/// Throwaway `SQLite` database for integration tests (issue #2555).
4839///
4840/// This is the `--features sqlite` counterpart of the Postgres `TestDb`. It has
4841/// the same API and needs no Docker. Each instance owns a tempfile database.
4842/// [`TestDb::shared`] reuses one per test binary.
4843///
4844/// # Examples
4845///
4846/// ```rust,ignore
4847/// use autumn_web::test::{TestApp, TestDb};
4848///
4849/// #[tokio::test]
4850/// async fn db_test() {
4851///     let db = TestDb::shared().await;
4852///     db.execute_sql("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)").await;
4853///     let client = TestApp::new().routes(routes![h]).with_db(db.pool()).build();
4854///     client.get("/t").send().await.assert_ok();
4855/// }
4856/// ```
4857#[cfg(all(feature = "db", feature = "test-support", feature = "sqlite"))]
4858pub struct TestDb {
4859    pool: Pool<crate::db::RuntimeConnection>,
4860    url: String,
4861    path: std::path::PathBuf,
4862}
4863
4864#[cfg(all(feature = "db", feature = "test-support", feature = "sqlite"))]
4865impl TestDb {
4866    /// Create a new tempfile database and a connection pool for it.
4867    ///
4868    /// # Panics
4869    ///
4870    /// Panics if the pool cannot be built.
4871    pub async fn new() -> Self {
4872        use std::sync::atomic::{AtomicU64, Ordering};
4873        static NEXT: AtomicU64 = AtomicU64::new(0);
4874
4875        let path = std::env::temp_dir().join(format!(
4876            "autumn-testdb-{}-{}.db",
4877            std::process::id(),
4878            NEXT.fetch_add(1, Ordering::Relaxed)
4879        ));
4880        // A file from a crashed run can share this name (the process id is
4881        // reused). Remove it so the database starts empty.
4882        remove_sqlite_files(&path);
4883        let url = format!("sqlite://{}", path.display());
4884        let config = crate::config::DatabaseConfig {
4885            primary_url: Some(url.clone()),
4886            ..Default::default()
4887        };
4888        let pool = crate::db::create_pool(&config)
4889            .expect("failed to build SQLite test pool")
4890            .expect("SQLite test URL did not yield a pool");
4891        // Create the file now so a failure shows up here, not in a later test.
4892        drop(
4893            pool.get()
4894                .await
4895                .expect("failed to open SQLite test database"),
4896        );
4897        Self { pool, url, path }
4898    }
4899
4900    /// Get a shared `TestDb`, created on first use.
4901    ///
4902    /// The database is created once per test binary. Rust never drops a
4903    /// static, so its file stays in the temp directory. Use [`TestDb::new`] when
4904    /// the file must be removed.
4905    pub async fn shared() -> &'static Self {
4906        use std::sync::OnceLock;
4907        use tokio::sync::OnceCell;
4908
4909        static CELL: OnceLock<OnceCell<TestDb>> = OnceLock::new();
4910        let once = CELL.get_or_init(OnceCell::new);
4911        once.get_or_init(Self::new).await
4912    }
4913
4914    /// Get the database connection pool.
4915    #[must_use]
4916    pub fn pool(&self) -> Pool<crate::db::RuntimeConnection> {
4917        self.pool.clone()
4918    }
4919
4920    /// Get the `sqlite://` connection URL.
4921    #[must_use]
4922    pub fn url(&self) -> &str {
4923        &self.url
4924    }
4925
4926    /// Execute raw SQL against the test database.
4927    ///
4928    /// # Panics
4929    ///
4930    /// Panics if the SQL fails.
4931    pub async fn execute_sql(&self, sql: &str) {
4932        use diesel_async::SimpleAsyncConnection as _;
4933        let mut conn = self.pool.get().await.expect("failed to get connection");
4934        conn.batch_execute(sql)
4935            .await
4936            .unwrap_or_else(|e| panic!("SQL execution failed: {e}\nSQL: {sql}"));
4937    }
4938
4939    /// Empty `table` and restart its id counter.
4940    ///
4941    /// `SQLite` has no `TRUNCATE`. This deletes every row, then clears the
4942    /// `AUTOINCREMENT` counter in `sqlite_sequence`. `table` must match
4943    /// `[A-Za-z0-9_]+`.
4944    ///
4945    /// # Panics
4946    ///
4947    /// Panics if `table` is not a plain identifier, or if the SQL fails.
4948    pub async fn reset_table(&self, table: &str) {
4949        use diesel_async::RunQueryDsl as _;
4950        use diesel_async::SimpleAsyncConnection as _;
4951
4952        #[derive(diesel::QueryableByName)]
4953        struct Found {
4954            #[diesel(sql_type = diesel::sql_types::BigInt)]
4955            n: i64,
4956        }
4957
4958        assert_plain_identifier(table);
4959        // One connection for all steps, so no other writer slips in between.
4960        let mut conn = self.pool.get().await.expect("failed to get connection");
4961        conn.batch_execute(&format!("DELETE FROM \"{table}\""))
4962            .await
4963            .unwrap_or_else(|e| panic!("failed to empty {table}: {e}"));
4964        // `sqlite_sequence` exists only after the first AUTOINCREMENT table.
4965        let found = diesel::sql_query(
4966            "SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'sqlite_sequence'",
4967        )
4968        .get_result::<Found>(&mut *conn)
4969        .await
4970        .expect("failed to probe sqlite_sequence");
4971        if found.n > 0 {
4972            conn.batch_execute(&format!(
4973                "DELETE FROM sqlite_sequence WHERE name = '{table}'"
4974            ))
4975            .await
4976            .unwrap_or_else(|e| panic!("failed to reset the id counter of {table}: {e}"));
4977        }
4978    }
4979}
4980
4981#[cfg(all(feature = "db", feature = "test-support", feature = "sqlite"))]
4982impl Drop for TestDb {
4983    fn drop(&mut self) {
4984        self.pool.close();
4985        remove_sqlite_files(&self.path);
4986    }
4987}
4988
4989/// Remove a `SQLite` database file and its `-wal` and `-shm` files.
4990#[cfg(all(feature = "db", feature = "test-support", feature = "sqlite"))]
4991fn remove_sqlite_files(path: &std::path::Path) {
4992    for suffix in ["", "-wal", "-shm"] {
4993        let mut name = path.as_os_str().to_owned();
4994        name.push(suffix);
4995        let _ = std::fs::remove_file(name);
4996    }
4997}
4998
4999/// Reject anything but `[A-Za-z0-9_]+`: table names go into SQL text.
5000#[cfg(all(feature = "db", feature = "test-support"))]
5001fn assert_plain_identifier(name: &str) {
5002    assert!(
5003        !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
5004        "table name must be a plain identifier: {name:?}"
5005    );
5006}
5007
5008/// Deterministically claims and runs up to `max_rows` ready durable repository
5009/// commit hooks, returning the number of ready hooks selected for this drain
5010/// pass.
5011///
5012/// Intended for integration tests that need to drive the real worker→drain
5013/// wiring (claim → run the registered runner → ack/nack) **without** the
5014/// timing-based background commit-hook worker that a served app starts. It
5015/// generates its own worker id and delegates to the same backend-appropriate
5016/// drain the production worker uses, so a test can enqueue a durable hook,
5017/// assert its side effect has not happened, drain once, and assert the side
5018/// effect deterministically — no `sleep`, no polling.
5019///
5020/// Pass a `max_rows` >= the number of enqueued hooks to fully drain in one
5021/// call. Hooks whose `run_at` is still in the future, or whose handler runner
5022/// is not registered in this process, are left untouched.
5023///
5024/// The returned count is the size of the ready set measured *before* the pass
5025/// (`status = 'enqueued'` and due), capped at `max_rows`
5026/// (`min(ready_hooks, max_rows)`). Because it is measured up front — the
5027/// underlying private drains return `()` and expose no per-hook success tally —
5028/// it reflects the rows *selected* for draining, not a success count. In the
5029/// intended single-threaded / private-pool test (no competing worker) every
5030/// selected hook runs, so this equals the number processed; a hook that fails
5031/// and is re-queued with a future backoff during the pass is still counted here.
5032///
5033/// # Example
5034///
5035/// ```rust,ignore
5036/// use autumn_web::test::TestDb;
5037///
5038/// let db = TestDb::shared().await;
5039/// // ... enqueue a durable repository commit hook and register its runner ...
5040///
5041/// let processed = autumn_web::test::drain_ready_repository_commit_hooks(&db.pool(), 16).await;
5042/// assert_eq!(processed, 1);
5043/// // ... assert the hook's side effect now exists ...
5044/// ```
5045///
5046/// # Panics
5047///
5048/// Panics if a pooled database connection cannot be acquired or the ready-hook
5049/// count query fails — this helper is for tests, where surfacing such a
5050/// database failure loudly is the desired behavior.
5051#[cfg(feature = "db")]
5052pub async fn drain_ready_repository_commit_hooks(
5053    pool: &Pool<crate::db::RuntimeConnection>,
5054    max_rows: usize,
5055) -> usize {
5056    use diesel_async::RunQueryDsl as _;
5057
5058    #[derive(diesel::QueryableByName)]
5059    struct ReadyCount {
5060        #[diesel(sql_type = diesel::sql_types::BigInt)]
5061        ready: i64,
5062    }
5063
5064    // The private drains return `()`, so measure the ready set up-front and
5065    // report how many this pass will claim-and-run. In a single-threaded test
5066    // (no competing worker) this equals the number processed, capped at
5067    // `max_rows`. Predicate mirrors the claim query's readiness gate
5068    // (`status = 'enqueued' AND run_at <= now`); `CURRENT_TIMESTAMP` is standard
5069    // SQL on both the Postgres and SQLite backends.
5070    let ready_before: usize = {
5071        let mut conn = pool
5072            .get()
5073            .await
5074            .expect("drain_ready_repository_commit_hooks: acquire pooled connection");
5075        let row = diesel::sql_query(
5076            "SELECT COUNT(*) AS ready \
5077             FROM autumn_repository_commit_hooks \
5078             WHERE status = 'enqueued' AND run_at <= CURRENT_TIMESTAMP",
5079        )
5080        .get_result::<ReadyCount>(&mut *conn)
5081        .await
5082        .expect(
5083            "drain_ready_repository_commit_hooks: count ready hooks \
5084             (querying autumn_repository_commit_hooks). An app mounted on a sim \
5085             substrate must have the framework repository-commit-hook migrations \
5086             applied — SqliteSubstrate applies them automatically, so a bare \
5087             SqliteSubstrate satisfies this; a custom DB substrate must apply them \
5088             too, or run_to_idle cannot drain durable commit hooks",
5089        );
5090        usize::try_from(row.ready).unwrap_or(0)
5091    };
5092
5093    let worker_id = crate::repository_commit_hooks::repository_commit_hook_worker_id();
5094
5095    #[cfg(not(feature = "sqlite"))]
5096    crate::repository_commit_hooks::drain_ready_repository_commit_hooks(pool, &worker_id, max_rows)
5097        .await;
5098    #[cfg(feature = "sqlite")]
5099    crate::repository_commit_hooks::sqlite_drain_ready_repository_commit_hooks(
5100        pool,
5101        &worker_id,
5102        max_rows,
5103        &tokio_util::sync::CancellationToken::new(),
5104    )
5105    .await;
5106
5107    ready_before.min(max_rows)
5108}
5109
5110#[cfg(test)]
5111mod tests {
5112    #[cfg(all(feature = "db", feature = "test-support"))]
5113    #[test]
5114    fn plain_identifiers_are_accepted() {
5115        super::assert_plain_identifier("posts");
5116        super::assert_plain_identifier("Post_2");
5117    }
5118
5119    #[cfg(all(feature = "db", feature = "test-support"))]
5120    #[test]
5121    #[should_panic(expected = "plain identifier")]
5122    fn an_empty_identifier_is_rejected() {
5123        super::assert_plain_identifier("");
5124    }
5125
5126    #[cfg(all(feature = "db", feature = "test-support"))]
5127    #[test]
5128    #[should_panic(expected = "plain identifier")]
5129    fn sql_in_an_identifier_is_rejected() {
5130        super::assert_plain_identifier("t; DROP TABLE x");
5131    }
5132
5133    use super::*;
5134
5135    fn cleanup_probe_job(
5136        _state: crate::state::AppState,
5137        _payload: serde_json::Value,
5138    ) -> std::pin::Pin<
5139        Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'static>,
5140    > {
5141        Box::pin(async move { Ok(()) })
5142    }
5143
5144    struct CleanupJobPlugin;
5145
5146    impl crate::plugin::Plugin for CleanupJobPlugin {
5147        fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
5148            app.jobs(vec![crate::job::JobInfo {
5149                version: 1,
5150                name: "cleanup_probe".to_string(),
5151                max_attempts: 1,
5152                initial_backoff_ms: 1,
5153                queue: "default".to_string(),
5154                uniqueness: None,
5155                concurrency: None,
5156                handler: cleanup_probe_job,
5157                timeout: None,
5158            }])
5159        }
5160    }
5161
5162    fn test_routes() -> Vec<Route> {
5163        use axum::routing;
5164
5165        async fn hello() -> &'static str {
5166            "hello"
5167        }
5168
5169        async fn echo_json(
5170            axum::Json(value): axum::Json<serde_json::Value>,
5171        ) -> axum::Json<serde_json::Value> {
5172            axum::Json(value)
5173        }
5174
5175        async fn status_201() -> (StatusCode, &'static str) {
5176            (StatusCode::CREATED, "created")
5177        }
5178
5179        vec![
5180            Route {
5181                method: Method::GET,
5182                path: "/hello",
5183                handler: routing::get(hello),
5184                name: "hello",
5185                api_doc: crate::openapi::ApiDoc {
5186                    method: "GET",
5187                    path: "/hello",
5188                    operation_id: "hello",
5189                    success_status: 200,
5190                    ..Default::default()
5191                },
5192                repository: None,
5193                idempotency: crate::route::RouteIdempotency::Direct,
5194                timeout: crate::route::RouteTimeout::Inherit,
5195                criticality: crate::admission::Criticality::Default,
5196                seo: crate::seo::SeoRouteDefaults::EMPTY,
5197                api_version: None,
5198                sunset_opt_out: false,
5199            },
5200            Route {
5201                method: Method::POST,
5202                path: "/echo",
5203                handler: routing::post(echo_json),
5204                name: "echo",
5205                api_doc: crate::openapi::ApiDoc {
5206                    method: "POST",
5207                    path: "/echo",
5208                    operation_id: "echo",
5209                    success_status: 200,
5210                    ..Default::default()
5211                },
5212                repository: None,
5213                idempotency: crate::route::RouteIdempotency::Direct,
5214                timeout: crate::route::RouteTimeout::Inherit,
5215                criticality: crate::admission::Criticality::Default,
5216                seo: crate::seo::SeoRouteDefaults::EMPTY,
5217                api_version: None,
5218                sunset_opt_out: false,
5219            },
5220            Route {
5221                method: Method::POST,
5222                path: "/create",
5223                handler: routing::post(status_201),
5224                name: "create",
5225                api_doc: crate::openapi::ApiDoc {
5226                    method: "POST",
5227                    path: "/create",
5228                    operation_id: "create",
5229                    success_status: 201,
5230                    ..Default::default()
5231                },
5232                repository: None,
5233                idempotency: crate::route::RouteIdempotency::Direct,
5234                timeout: crate::route::RouteTimeout::Inherit,
5235                criticality: crate::admission::Criticality::Default,
5236                seo: crate::seo::SeoRouteDefaults::EMPTY,
5237                api_version: None,
5238                sunset_opt_out: false,
5239            },
5240        ]
5241    }
5242
5243    #[tokio::test]
5244    async fn test_app_get_request() {
5245        let client = TestApp::new().routes(test_routes()).build();
5246        client.get("/hello").send().await.assert_ok();
5247    }
5248
5249    #[tokio::test]
5250    async fn test_app_post_json() {
5251        let client = TestApp::new().routes(test_routes()).build();
5252
5253        client
5254            .post("/echo")
5255            .json(&serde_json::json!({"key": "value"}))
5256            .send()
5257            .await
5258            .assert_ok()
5259            .assert_body_contains("key");
5260    }
5261
5262    #[tokio::test]
5263    async fn test_response_assert_status() {
5264        let client = TestApp::new().routes(test_routes()).build();
5265
5266        client
5267            .post("/create")
5268            .send()
5269            .await
5270            .assert_status(201)
5271            .assert_body_eq("created");
5272    }
5273
5274    #[tokio::test]
5275    async fn test_response_assert_success() {
5276        let client = TestApp::new().routes(test_routes()).build();
5277        client.get("/hello").send().await.assert_success();
5278    }
5279
5280    #[tokio::test]
5281    async fn test_not_found() {
5282        let client = TestApp::new().routes(test_routes()).build();
5283        client.get("/nonexistent").send().await.assert_status(404);
5284    }
5285
5286    #[tokio::test]
5287    async fn test_response_json_deserialization() {
5288        let client = TestApp::new().routes(test_routes()).build();
5289
5290        let resp = client
5291            .post("/echo")
5292            .json(&serde_json::json!({"count": 42}))
5293            .send()
5294            .await;
5295
5296        resp.assert_ok().assert_json::<serde_json::Value, _>(|v| {
5297            assert_eq!(v["count"], 42);
5298        });
5299    }
5300
5301    #[tokio::test]
5302    async fn test_custom_header() {
5303        let client = TestApp::new().routes(test_routes()).build();
5304
5305        let resp = client
5306            .get("/hello")
5307            .header("x-custom", "test-value")
5308            .send()
5309            .await;
5310        resp.assert_ok();
5311    }
5312
5313    #[tokio::test]
5314    async fn test_client_default() {
5315        let _app = TestApp::default();
5316    }
5317
5318    #[tokio::test]
5319    async fn dropping_test_client_stops_test_started_job_runtime() {
5320        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
5321        crate::job::clear_global_job_client();
5322
5323        let client = TestApp::new().plugin(CleanupJobPlugin).build();
5324        let leaked_client = crate::job::global_job_client().expect("test job runtime should start");
5325
5326        drop(client);
5327
5328        assert!(
5329            crate::job::global_job_client().is_none(),
5330            "dropping a TestClient with jobs must clear its global job client"
5331        );
5332
5333        let mut last_enqueue_error = None;
5334        for _ in 0..25 {
5335            match leaked_client
5336                .enqueue("cleanup_probe", serde_json::json!({}))
5337                .await
5338            {
5339                Ok(()) => tokio::time::sleep(std::time::Duration::from_millis(10)).await,
5340                Err(error) => {
5341                    last_enqueue_error = Some(error.to_string());
5342                    break;
5343                }
5344            }
5345        }
5346
5347        assert!(
5348            last_enqueue_error
5349                .as_deref()
5350                .is_some_and(|message| message.contains("failed to enqueue job")),
5351            "captured pre-drop job client must stop accepting jobs after TestClient drop; \
5352             last error: {last_enqueue_error:?}"
5353        );
5354
5355        crate::job::clear_global_job_client();
5356    }
5357
5358    #[cfg(feature = "mail")]
5359    #[test]
5360    fn plugin_suppression_store_and_endpoint_optin_carry_into_test_app() {
5361        struct SuppressionPlugin;
5362        impl crate::plugin::Plugin for SuppressionPlugin {
5363            fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
5364                app.with_suppression_store(crate::mail::InMemorySuppressionStore::new())
5365                    .mount_unsubscribe_endpoint()
5366            }
5367        }
5368
5369        // A plugin that wires List-Unsubscribe storage and opts into the default
5370        // endpoint must propagate both into the TestApp, so unsubscribe POSTs /
5371        // send-time suppression behave under TestApp exactly as in production
5372        // without every test repeating the setup manually.
5373        let app = TestApp::new().plugin(SuppressionPlugin);
5374        assert!(
5375            app.suppression_store.is_some(),
5376            "plugin-registered suppression store must be carried into TestApp"
5377        );
5378        assert!(
5379            app.config.mail.mount_unsubscribe_endpoint,
5380            "plugin endpoint opt-in must be carried into TestApp config"
5381        );
5382    }
5383
5384    /// End-to-end acceptance for issue #605: a plain `<form method="post">`
5385    /// carrying `_method=DELETE` reaches the declared DELETE handler when
5386    /// dispatched through the same router/middleware stack the production
5387    /// app builder uses.
5388    #[tokio::test]
5389    async fn test_app_routes_html_method_override_to_delete() {
5390        use axum::routing;
5391        async fn deleted() -> &'static str {
5392            "deleted"
5393        }
5394        let routes = vec![Route {
5395            method: Method::DELETE,
5396            path: "/items/{id}",
5397            handler: routing::delete(deleted),
5398            name: "items_delete",
5399            api_doc: crate::openapi::ApiDoc {
5400                method: "DELETE",
5401                path: "/items/{id}",
5402                operation_id: "items_delete",
5403                success_status: 200,
5404                ..Default::default()
5405            },
5406            repository: None,
5407            idempotency: crate::route::RouteIdempotency::Direct,
5408            timeout: crate::route::RouteTimeout::Inherit,
5409            criticality: crate::admission::Criticality::Default,
5410            seo: crate::seo::SeoRouteDefaults::EMPTY,
5411            api_version: None,
5412            sunset_opt_out: false,
5413        }];
5414        let client = TestApp::new().routes(routes).build();
5415
5416        client
5417            .post("/items/1")
5418            .form("_method=DELETE")
5419            .send()
5420            .await
5421            .assert_ok()
5422            .assert_body_eq("deleted");
5423    }
5424
5425    // ── CSS-selector HTML assertions (issue #1147) ─────────────────────────
5426    //
5427    // These tests are the executable specification for the selector-aware
5428    // assertions on [`TestResponse`]. They exercise the success metric:
5429    // a structural assertion against a notes index survives a cosmetic
5430    // template refactor (indentation, attribute order, wrapping markup)
5431    // that would break the equivalent `assert_body_contains` substring test.
5432    #[cfg(feature = "maud")]
5433    mod html_assertions {
5434        use super::*;
5435        use axum::routing::get;
5436
5437        /// The "original" notes index: a 3-row table where each `<tr>` links
5438        /// to `/notes/{id}`.
5439        async fn notes_index_v1() -> maud::Markup {
5440            maud::html! {
5441                table.notes {
5442                    tbody {
5443                        @for id in 1..=3u32 {
5444                            tr.note-row {
5445                                td.title { a href=(format!("/notes/{id}")) { "Note " (id) } }
5446                            }
5447                        }
5448                    }
5449                }
5450            }
5451        }
5452
5453        /// The same index after a cosmetic refactor: attribute order changed,
5454        /// extra wrapping markup and classes, different nesting — but the same
5455        /// structural facts (3 rows, each linking to `/notes/{id}`).
5456        async fn notes_index_v2() -> maud::Markup {
5457            maud::html! {
5458                div.card {
5459                    table.notes.striped {
5460                        thead { tr { th { "Title" } } }
5461                        tbody.rows {
5462                            @for id in 1..=3u32 {
5463                                tr.note-row.is-clickable data-id=(id) {
5464                                    td.title {
5465                                        span.wrap {
5466                                            a.link href=(format!("/notes/{id}")) data-turbo="true" {
5467                                                "Note " (id)
5468                                            }
5469                                        }
5470                                    }
5471                                }
5472                            }
5473                        }
5474                    }
5475                }
5476            }
5477        }
5478
5479        /// An htmx swap fragment: a bare `<tr>` with no enclosing `<table>`.
5480        async fn note_row_fragment() -> maud::Markup {
5481            maud::html! {
5482                tr.note-row #note-7 {
5483                    td.title { a.link href="/notes/7" { "Note 7" } }
5484                }
5485            }
5486        }
5487
5488        fn client(
5489            path: &str,
5490            handler: axum::routing::MethodRouter<crate::state::AppState>,
5491        ) -> TestClient {
5492            let router = axum::Router::<crate::state::AppState>::new().route(path, handler);
5493            TestApp::new().merge(router).build()
5494        }
5495
5496        #[tokio::test]
5497        async fn counts_rows_by_tag_and_class() {
5498            let resp = client("/notes", get(notes_index_v1))
5499                .get("/notes")
5500                .send()
5501                .await;
5502            resp.assert_ok()
5503                .assert_selector("table.notes")
5504                .assert_selector_count("tbody tr", 3)
5505                .assert_selector_count("tr.note-row", 3)
5506                .assert_no_selector("form");
5507        }
5508
5509        #[tokio::test]
5510        async fn reads_text_and_attributes() {
5511            let resp = client("/notes", get(notes_index_v1))
5512                .get("/notes")
5513                .send()
5514                .await;
5515            resp.assert_text("tr.note-row td.title a", "Note 1")
5516                .assert_text_contains("tr.note-row", "Note 1")
5517                .assert_attr("tr.note-row td a", "href", "/notes/1");
5518
5519            // Non-asserting accessors compose for custom assertions.
5520            let links = resp.selector_text("tr.note-row a");
5521            assert_eq!(links, vec!["Note 1", "Note 2", "Note 3"]);
5522            let hrefs = resp.selector_attr("tr.note-row a", "href");
5523            assert_eq!(
5524                hrefs,
5525                vec![
5526                    Some("/notes/1".to_string()),
5527                    Some("/notes/2".to_string()),
5528                    Some("/notes/3".to_string()),
5529                ]
5530            );
5531            assert_eq!(resp.selector_count("tr.note-row"), 3);
5532        }
5533
5534        /// The success metric: identical structural assertions pass against
5535        /// both the original and the refactored template.
5536        #[tokio::test]
5537        async fn survives_cosmetic_refactor() {
5538            for handler in [get(notes_index_v1), get(notes_index_v2)] {
5539                let resp = client("/notes", handler).get("/notes").send().await;
5540                resp.assert_ok()
5541                    // Exactly three data rows, each linking to /notes/{id}.
5542                    .assert_selector_count("tbody tr.note-row", 3);
5543                let hrefs = resp.selector_attr("tbody tr.note-row a", "href");
5544                assert_eq!(
5545                    hrefs,
5546                    vec![
5547                        Some("/notes/1".to_string()),
5548                        Some("/notes/2".to_string()),
5549                        Some("/notes/3".to_string()),
5550                    ],
5551                    "row links must survive the refactor"
5552                );
5553            }
5554        }
5555
5556        /// AC: works for partial/fragment responses (htmx swaps) — a bare
5557        /// `<tr>` with no enclosing table must still be selectable.
5558        #[tokio::test]
5559        async fn works_for_htmx_fragment() {
5560            let resp = client("/rows/7", get(note_row_fragment))
5561                .get("/rows/7")
5562                .send()
5563                .await;
5564            resp.assert_selector("tr.note-row")
5565                .assert_selector("tr#note-7")
5566                .assert_attr("tr#note-7 a", "href", "/notes/7")
5567                .assert_text("tr#note-7 a.link", "Note 7");
5568        }
5569
5570        #[tokio::test]
5571        async fn id_and_attribute_selectors() {
5572            let resp = client("/rows/7", get(note_row_fragment))
5573                .get("/rows/7")
5574                .send()
5575                .await;
5576            resp.assert_selector("#note-7")
5577                .assert_selector("a[href=\"/notes/7\"]")
5578                .assert_selector("a[href^=\"/notes/\"]")
5579                .assert_no_selector("a[href=\"/other\"]");
5580        }
5581
5582        #[tokio::test]
5583        #[should_panic(expected = "expected 5 element(s) matching selector")]
5584        async fn count_mismatch_panics_with_actionable_message() {
5585            let resp = client("/notes", get(notes_index_v1))
5586                .get("/notes")
5587                .send()
5588                .await;
5589            resp.assert_selector_count("tr.note-row", 5);
5590        }
5591
5592        #[tokio::test]
5593        #[should_panic(expected = "no elements matched selector `table.missing`")]
5594        async fn missing_selector_panics() {
5595            let resp = client("/notes", get(notes_index_v1))
5596                .get("/notes")
5597                .send()
5598                .await;
5599            resp.assert_selector("table.missing");
5600        }
5601    }
5602
5603    /// Companion to the override test: an invalid `_method` value rejects
5604    /// with `400 Bad Request` before reaching any handler.
5605    #[tokio::test]
5606    async fn test_app_routes_invalid_method_override_rejected() {
5607        let client = TestApp::new().routes(test_routes()).build();
5608
5609        client
5610            .post("/create")
5611            .form("_method=BREW")
5612            .send()
5613            .await
5614            .assert_status(400);
5615    }
5616
5617    /// The outer `MethodOverrideLayer` stamps a `MethodOverrideRejection`
5618    /// extension instead of short-circuiting, so the inner
5619    /// `method_override_rejection_filter` produces the `400` from inside
5620    /// the per-route layer chain. Verify that framework response
5621    /// middleware (request-ID header, security headers) still wraps that
5622    /// `400` — i.e. malformed requests inherit the same response middleware
5623    /// as ordinary handler responses, rather than bypassing it.
5624    #[tokio::test]
5625    async fn invalid_method_override_response_carries_framework_middleware() {
5626        let client = TestApp::new().routes(test_routes()).build();
5627
5628        let response = client.post("/create").form("_method=BREW").send().await;
5629        response.assert_status(400);
5630
5631        // RequestIdLayer is applied via `Router::layer` in
5632        // `apply_middleware` and stamps a response header on every
5633        // request that flows through the inner router. If the override
5634        // layer short-circuited at the outer wrapper, this header would
5635        // be absent.
5636        assert!(
5637            response.header("x-request-id").is_some(),
5638            "framework request-id header must wrap method-override rejections; \
5639             observed headers: {:?}",
5640            response.headers
5641        );
5642        // SecurityHeadersLayer applies a default set of headers; pick a
5643        // representative one to assert the layer ran on this response.
5644        assert!(
5645            response.header("x-content-type-options").is_some(),
5646            "framework security headers must wrap method-override rejections; \
5647             observed headers: {:?}",
5648            response.headers
5649        );
5650    }
5651
5652    // ── #1262: query-count / N+1 assertions (pure, no Postgres) ─────────
5653    //
5654    // These exercise the assertion *logic* on a directly-constructed
5655    // `TestResponse`, so they run in the always-on CI lane without a database
5656    // — the framework self-test that guarantees the assertions actually fire.
5657
5658    fn resp_with_queries(sqls: &[&str], threshold: usize) -> TestResponse {
5659        TestResponse {
5660            queries: sqls
5661                .iter()
5662                .map(|s| crate::inspector::QueryRecord {
5663                    sql: (*s).to_owned(),
5664                    params: Vec::new(),
5665                    elapsed_ms: 1,
5666                    location: String::new(),
5667                })
5668                .collect(),
5669            request_method: "GET".to_owned(),
5670            request_path: "/posts".to_owned(),
5671            n_plus_one_threshold: threshold,
5672            ..Default::default()
5673        }
5674    }
5675
5676    fn panic_message(err: &(dyn std::any::Any + Send)) -> String {
5677        err.downcast_ref::<String>()
5678            .cloned()
5679            .or_else(|| err.downcast_ref::<&str>().map(|s| (*s).to_owned()))
5680            .unwrap_or_default()
5681    }
5682
5683    #[test]
5684    fn query_count_and_queries_reflect_captured_list() {
5685        let resp = resp_with_queries(&["SELECT 1", "SELECT 2"], 5);
5686        assert_eq!(resp.query_count(), 2);
5687        assert_eq!(resp.queries().len(), 2);
5688        assert_eq!(resp.queries()[0].sql, "SELECT 1");
5689    }
5690
5691    /// Regression guard: the `REQUEST_QUERY_CAPTURE` scope must stay active
5692    /// while a lazy/streaming response body is drained, so DB work performed
5693    /// *during* body polling (as `Sse` / `Body::from_stream` handlers do) is
5694    /// still captured. `service.oneshot` returns the response head without
5695    /// polling the stream; `send()` drains the body with `to_bytes` — if that
5696    /// drain happened after the scope closed, these body-time queries would be
5697    /// recorded against an unset task-local and silently dropped, so
5698    /// `query_count()` would under-report (here: read 0 instead of 3).
5699    #[cfg(feature = "db")]
5700    #[tokio::test]
5701    async fn query_capture_stays_active_while_draining_streaming_body() {
5702        use futures::StreamExt as _;
5703
5704        // Each streamed chunk records a DB query when it is polled. The stream is
5705        // lazy: nothing runs until `to_bytes` polls it inside `send()`.
5706        async fn stream_handler() -> axum::response::Response {
5707            let body_stream = futures::stream::iter(0..3).map(|_| {
5708                crate::db::record_request_db_query(
5709                    std::time::Duration::from_millis(1),
5710                    Some("SELECT 1"),
5711                );
5712                Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static(b"x"))
5713            });
5714            axum::response::Response::new(Body::from_stream(body_stream))
5715        }
5716
5717        let router = axum::Router::new().route("/stream", axum::routing::get(stream_handler));
5718        let resp = RequestBuilder {
5719            router,
5720            method: Method::GET,
5721            uri: "/stream".to_owned(),
5722            headers: Vec::new(),
5723            body: Body::empty(),
5724            cookie_jar: None,
5725            clock: None,
5726            n_plus_one_threshold: 5,
5727            observed_server_errors: None,
5728        }
5729        .send()
5730        .await;
5731
5732        resp.assert_ok();
5733        assert_eq!(
5734            resp.query_count(),
5735            3,
5736            "body-time DB queries must be captured while the streaming body is \
5737             drained inside the active capture scope"
5738        );
5739    }
5740
5741    #[test]
5742    fn assert_max_queries_passes_at_boundary() {
5743        // len == n is within budget and must not panic.
5744        resp_with_queries(&["SELECT 1", "SELECT 2"], 5).assert_max_queries(2);
5745    }
5746
5747    #[test]
5748    fn assert_max_queries_panics_when_exceeded() {
5749        let resp = resp_with_queries(&["SELECT 1", "SELECT 2", "SELECT 3"], 5);
5750        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5751            resp.assert_max_queries(2);
5752        }))
5753        .expect_err("assert_max_queries must panic when the query count exceeds the limit");
5754        let msg = panic_message(err.as_ref());
5755        assert!(
5756            msg.contains("GET /posts"),
5757            "message names the request: {msg}"
5758        );
5759        assert!(
5760            msg.contains("issued 3"),
5761            "message reports the actual count: {msg}"
5762        );
5763        assert!(msg.contains("<= 2"), "message reports the limit: {msg}");
5764    }
5765
5766    #[test]
5767    fn assert_no_n_plus_one_passes_for_distinct_queries() {
5768        // Three distinct statements: no normalized template repeats.
5769        resp_with_queries(&["SELECT 1", "SELECT 2", "SELECT 3"], 2).assert_no_n_plus_one();
5770    }
5771
5772    #[test]
5773    fn assert_no_n_plus_one_panics_on_repetition() {
5774        // Same statement modulo whitespace/case, repeated `threshold` times.
5775        let resp = resp_with_queries(
5776            &[
5777                "SELECT * FROM comments WHERE post_id = $1",
5778                "SELECT  * FROM comments WHERE post_id = $1",
5779                "select * from comments where post_id = $1",
5780            ],
5781            3,
5782        );
5783        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5784            resp.assert_no_n_plus_one();
5785        }))
5786        .expect_err("assert_no_n_plus_one must panic on an N+1 pattern");
5787        let msg = panic_message(err.as_ref());
5788        assert!(msg.contains("GET /posts"), "names the request: {msg}");
5789        assert!(
5790            msg.contains("3 times"),
5791            "reports the repetition count: {msg}"
5792        );
5793        assert!(
5794            msg.contains("select * from comments where post_id = $1"),
5795            "reports the normalized SQL template: {msg}"
5796        );
5797    }
5798
5799    #[test]
5800    fn assert_no_n_plus_one_with_threshold_overrides_default() {
5801        // Two identical queries; the configured default threshold (10) does not
5802        // fire, but an explicit override of 2 does.
5803        let resp = resp_with_queries(&["SELECT 1", "SELECT 1"], 10);
5804        resp.assert_no_n_plus_one(); // default threshold 10 -> passes
5805        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5806            resp.assert_no_n_plus_one_with_threshold(2);
5807        }))
5808        .expect_err("an explicit threshold override must be honoured");
5809        assert!(
5810            panic_message(err.as_ref()).contains("2 times"),
5811            "override fires at the explicit threshold"
5812        );
5813    }
5814
5815    #[test]
5816    fn default_test_response_inherits_detector_threshold() {
5817        // A directly-constructed `TestResponse` must inherit the shared detector
5818        // default (5), not a zero-filled `0` — otherwise `..Default::default()`
5819        // would silently DISABLE N+1 detection.
5820        assert_eq!(
5821            TestResponse::default().n_plus_one_threshold,
5822            crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD,
5823        );
5824    }
5825
5826    #[test]
5827    fn default_constructed_response_catches_n_plus_one() {
5828        // Build a response purely via the documented `{ .., ..Default::default() }`
5829        // pattern (no explicit threshold). With a zero-filled default this passed
5830        // silently (0 == DISABLED); with the detector default (5) it must panic on
5831        // the normalized template repeated to the threshold.
5832        let resp = TestResponse {
5833            queries: [
5834                "SELECT * FROM comments WHERE post_id = $1",
5835                "SELECT  * FROM comments WHERE post_id = $1",
5836                "select * from comments where post_id = $1",
5837                "SELECT * FROM  comments WHERE post_id = $1",
5838                "Select * From comments Where post_id = $1",
5839            ]
5840            .iter()
5841            .map(|s| crate::inspector::QueryRecord {
5842                sql: (*s).to_owned(),
5843                params: Vec::new(),
5844                elapsed_ms: 1,
5845                location: String::new(),
5846            })
5847            .collect(),
5848            ..Default::default()
5849        };
5850        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5851            resp.assert_no_n_plus_one();
5852        }))
5853        .expect_err(
5854            "a default-constructed TestResponse must inherit the non-zero detector \
5855             threshold and fire on an N+1 pattern",
5856        );
5857        assert!(
5858            panic_message(err.as_ref()).contains("select * from comments where post_id = $1"),
5859            "reports the normalized SQL template",
5860        );
5861    }
5862}