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}