Skip to main content

renox_core/
testing.rs

1//! Testing helpers, in the spirit of Laravel's HTTP tests.
2//!
3//! ```
4//! # use renox::prelude::*;
5//! # mod shop { pub fn app() -> renox::App { renox::App::new() } }
6//! use renox::testing::TestApp;
7//!
8//! #[renox::test]
9//! async fn creating_a_product() {
10//!     let app = TestApp::new(shop::app()).await;           // in-memory DB, migrated
11//!     let user = User::register(app.db(), "Alex", "alex@example.com", "secret123").await.unwrap();
12//!
13//!     app.acting_as(&user)
14//!         .post("/products", &[("name", "Coffee"), ("price", "18000")])
15//!         .await
16//!         .assert_redirect("/products");
17//!     app.assert_database_has("products", &[("name", &"Coffee")]).await;
18//!     app.get("/products").await.assert_ok().assert_see("Coffee");
19//! }
20//! ```
21//!
22//! A `TestApp` runs the app's router in memory with a test configuration:
23//! an in-memory SQLite database with all migrations run, the `memory` mail
24//! driver, no background workers or scheduler, storage in a temporary
25//! directory, and the app's own `resources/` and `public/` directories. It
26//! keeps the session cookie between requests like a browser and sends the
27//! CSRF token itself, so tests read like user actions.
28
29use std::sync::Mutex;
30
31use axum::body::{Body, Bytes};
32use axum::http::header::{CONTENT_TYPE, COOKIE, LOCATION, SET_COOKIE};
33use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
34use http_body_util::BodyExt;
35use serde::Serialize;
36use serde::de::DeserializeOwned;
37use tower::ServiceExt;
38
39use crate::auth::{User, login};
40use crate::db::{Db, DbValue, ToDbValue, quote};
41use crate::mail::{Mail, Mailer};
42use crate::{App, AppState, Config, Environment, Kernel};
43
44/// The app under test, with a browser-like client.
45pub struct TestApp {
46    kernel: Kernel,
47    cookie: Mutex<Option<String>>,
48    /// Seconds the clock is moved by (`travel`).
49    offset: std::sync::atomic::AtomicI64,
50    _storage: tempfile::TempDir,
51}
52
53impl TestApp {
54    /// Boots `app` with the test configuration and runs its migrations.
55    pub async fn new(app: App) -> Self {
56        Self::with_config(app, |_| {}).await
57    }
58
59    /// Like `new`, after `configure` adjusts the test configuration.
60    pub async fn with_config(app: App, configure: impl FnOnce(&mut Config)) -> Self {
61        let storage = tempfile::tempdir().expect("a temporary storage directory");
62        let mut config = Config {
63            env: Environment::Testing,
64            key: Some(crate::generate_key()),
65            storage_path: storage.path().to_path_buf(),
66            ..Config::default()
67        };
68        configure(&mut config);
69        let kernel = app.config(config).boot().await.expect("the app boots");
70        kernel.migrate().await.expect("migrations run");
71        Self {
72            kernel,
73            cookie: Mutex::new(None),
74            offset: std::sync::atomic::AtomicI64::new(0),
75            _storage: storage,
76        }
77    }
78
79    /// The booted app behind this `TestApp`.
80    pub fn kernel(&self) -> &Kernel {
81        &self.kernel
82    }
83
84    /// The app's shared state.
85    pub fn state(&self) -> &AppState {
86        self.kernel.state()
87    }
88
89    /// The app's database, migrated at start.
90    pub fn db(&self) -> &Db {
91        self.kernel.db()
92    }
93
94    /// The app's mailer (the `memory` driver unless the config changes it).
95    pub fn mailer(&self) -> &Mailer {
96        self.kernel.mailer()
97    }
98
99    /// Answers `state.http` requests with fakes and records them, instead
100    /// of reaching the network; see [`crate::http::FakeHttp`].
101    pub fn fake_http(&self) -> crate::http::FakeHttp {
102        self.state().http.fake()
103    }
104
105    /// Mail sent so far.
106    pub fn sent_mail(&self) -> Vec<Mail> {
107        self.kernel.mailer().sent()
108    }
109
110    /// Runs the jobs queued so far; returns how many ran.
111    pub async fn run_jobs(&self) -> usize {
112        self.at_travelled_time(self.kernel.run_jobs())
113            .await
114            .expect("the queue runs")
115    }
116
117    /// Runs the scheduled task `name` now (as `schedule:run` does), at the
118    /// time [`TestApp::travel`] moved the clock to.
119    ///
120    /// ```
121    /// # use renox::prelude::*;
122    /// # use std::time::Duration;
123    /// # async fn demo(app: renox::testing::TestApp) -> Result {
124    /// app.travel(Duration::from_secs(31 * 24 * 60 * 60)); // a month later
125    /// app.run_scheduled("monthly-report").await?;
126    /// # Ok(()) }
127    /// ```
128    pub async fn run_scheduled(&self, name: &str) -> crate::Result {
129        self.at_travelled_time(self.kernel.run_scheduled(name))
130            .await
131    }
132
133    /// Moves the clock forward by `by` for what this `TestApp` does next:
134    /// requests, `run_jobs`, `run_scheduled`, and code run in [`TestApp::at_travelled_time`]
135    /// (`renox::db::now()`, sessions, signed URLs, the queue, the cache).
136    /// Adds up; [`TestApp::travel_back`] returns to the present.
137    ///
138    /// ```
139    /// # use renox::prelude::*;
140    /// # use std::time::Duration;
141    /// # async fn demo(app: renox::testing::TestApp, user: User) {
142    /// // The app guards `/settings/billing` with `.require_password_confirmed()`.
143    /// app.acting_as(&user).confirm_password();
144    /// app.travel(Duration::from_secs(3 * 60 * 60)); // past the password confirmation
145    /// app.get("/settings/billing").await.assert_redirect("/confirm-password");
146    /// # }
147    /// ```
148    pub fn travel(&self, by: std::time::Duration) -> &Self {
149        let seconds = i64::try_from(by.as_secs()).unwrap_or(i64::MAX);
150        self.offset
151            .fetch_add(seconds, std::sync::atomic::Ordering::SeqCst);
152        self
153    }
154
155    /// Returns the clock to the present.
156    pub fn travel_back(&self) -> &Self {
157        self.offset.store(0, std::sync::atomic::Ordering::SeqCst);
158        self
159    }
160
161    /// Runs `fut` with the clock where [`TestApp::travel`] moved it, e.g.
162    /// to call a model or a job's code directly.
163    pub async fn at_travelled_time<F: std::future::Future>(&self, fut: F) -> F::Output {
164        let offset = self.offset.load(std::sync::atomic::Ordering::SeqCst);
165        crate::clock::with_offset(offset, fut).await
166    }
167
168    /// Records events instead of running their listeners, from now on; read
169    /// them with [`TestApp::emitted`] or [`TestApp::assert_emitted`].
170    pub fn fake_events(&self) -> &Self {
171        let mut events = self
172            .state()
173            .fakes
174            .events
175            .lock()
176            .unwrap_or_else(|e| e.into_inner());
177        events.get_or_insert_with(Vec::new);
178        self
179    }
180
181    /// The events of type `E` emitted since `fake_events`, oldest first.
182    pub fn emitted<E: crate::events::Event>(&self) -> Vec<E> {
183        let events = self
184            .state()
185            .fakes
186            .events
187            .lock()
188            .unwrap_or_else(|e| e.into_inner());
189        events
190            .iter()
191            .flatten()
192            .filter_map(|event| event.downcast_ref::<E>().cloned())
193            .collect()
194    }
195
196    /// Panics unless an `E` matching `check` was emitted.
197    #[track_caller]
198    pub fn assert_emitted<E: crate::events::Event>(&self, check: impl Fn(&E) -> bool) -> &Self {
199        let emitted = self.emitted::<E>();
200        assert!(
201            emitted.iter().any(check),
202            "no matching {} was emitted ({} of that type)",
203            std::any::type_name::<E>(),
204            emitted.len()
205        );
206        self
207    }
208
209    /// Panics unless no `E` was emitted since `fake_events`.
210    #[track_caller]
211    pub fn assert_not_emitted<E: crate::events::Event>(&self) -> &Self {
212        let emitted = self.emitted::<E>();
213        assert!(
214            emitted.is_empty(),
215            "{} {} emitted",
216            emitted.len(),
217            std::any::type_name::<E>()
218        );
219        self
220    }
221
222    /// Records notifications instead of sending them (mail, database,
223    /// channels), from now on.
224    pub fn fake_notifications(&self) -> &Self {
225        let mut sent = self
226            .state()
227            .fakes
228            .notifications
229            .lock()
230            .unwrap_or_else(|e| e.into_inner());
231        sent.get_or_insert_with(Vec::new);
232        self
233    }
234
235    /// The notifications recorded since `fake_notifications`, oldest first.
236    pub fn notifications(&self) -> Vec<crate::SentNotification> {
237        let sent = self
238            .state()
239            .fakes
240            .notifications
241            .lock()
242            .unwrap_or_else(|e| e.into_inner());
243        sent.iter().flatten().cloned().collect()
244    }
245
246    /// Panics unless `user` was sent a notification of `kind`.
247    #[track_caller]
248    pub fn assert_notified(&self, user: &User, kind: &str) -> &Self {
249        let sent = self.notifications();
250        assert!(
251            sent.iter()
252                .any(|n| n.kind == kind && n.to.user().is_some_and(|u| u.id == user.id)),
253            "user {} got no `{kind}` notification; sent: {:?}",
254            user.id,
255            sent.iter().map(|n| n.kind).collect::<Vec<_>>()
256        );
257        self
258    }
259
260    /// Panics unless someone with `address` (on any channel, or a user's
261    /// email) was sent a notification of `kind`.
262    #[track_caller]
263    pub fn assert_notified_to(&self, address: &str, kind: &str) -> &Self {
264        let sent = self.notifications();
265        assert!(
266            sent.iter()
267                .any(|n| n.kind == kind && n.to.has_address(address)),
268            "`{address}` got no `{kind}` notification"
269        );
270        self
271    }
272
273    /// Records what `state.broadcast(…)` and `broadcast_to(…)` send from
274    /// now on, instead of sending it to the open pages.
275    pub fn fake_broadcasts(&self) -> &Self {
276        let mut sent = self
277            .state()
278            .fakes
279            .broadcasts
280            .lock()
281            .unwrap_or_else(|e| e.into_inner());
282        sent.get_or_insert_with(Vec::new);
283        self
284    }
285
286    /// The broadcasts recorded since `fake_broadcasts`, oldest first.
287    pub fn broadcasts(&self) -> Vec<crate::SentBroadcast> {
288        let sent = self
289            .state()
290            .fakes
291            .broadcasts
292            .lock()
293            .unwrap_or_else(|e| e.into_inner());
294        sent.iter().flatten().cloned().collect()
295    }
296
297    /// Panics unless an `event` whose data passes `check` was broadcast
298    /// (to anyone) since `fake_broadcasts`.
299    #[track_caller]
300    pub fn assert_broadcast(
301        &self,
302        event: &str,
303        check: impl Fn(&crate::SentBroadcast) -> bool,
304    ) -> &Self {
305        let sent = self.broadcasts();
306        assert!(
307            sent.iter().any(|b| b.event == event && check(b)),
308            "no matching `{event}` was broadcast; sent: {:?}",
309            sent.iter()
310                .map(|b| (b.event.as_str(), b.data.to_string()))
311                .collect::<Vec<_>>()
312        );
313        self
314    }
315
316    /// Panics unless no notification was recorded since `fake_notifications`.
317    #[track_caller]
318    pub fn assert_nothing_notified(&self) -> &Self {
319        let sent = self.notifications();
320        assert!(sent.is_empty(), "{} notification(s) were sent", sent.len());
321        self
322    }
323
324    /// A value in the session as the next request will see it.
325    pub fn session_get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
326        self.session().get(key)
327    }
328
329    /// Panics unless the session holds `key`.
330    #[track_caller]
331    pub fn assert_session_has(&self, key: &str) -> &Self {
332        assert!(
333            self.session().get::<serde_json::Value>(key).is_some(),
334            "the session has no `{key}`"
335        );
336        self
337    }
338
339    /// Panics if the session holds `key`.
340    #[track_caller]
341    pub fn assert_session_missing(&self, key: &str) -> &Self {
342        assert!(
343            self.session().get::<serde_json::Value>(key).is_none(),
344            "the session has `{key}`"
345        );
346        self
347    }
348
349    /// Panics unless the session is logged in (as `user`, when given).
350    #[track_caller]
351    pub fn assert_authenticated(&self, user: Option<&User>) -> &Self {
352        let id: Option<i64> = self.session().get(crate::auth::AUTH_ID);
353        match (id, user) {
354            (None, _) => panic!("expected a logged-in session, it's a guest"),
355            (Some(id), Some(user)) => assert_eq!(id, user.id, "logged in as another user"),
356            _ => {}
357        }
358        self
359    }
360
361    /// Panics unless the session is a guest's (no one logged in).
362    #[track_caller]
363    pub fn assert_guest(&self) -> &Self {
364        let id: Option<i64> = self.session().get(crate::auth::AUTH_ID);
365        assert!(id.is_none(), "expected a guest, logged in as user {id:?}");
366        self
367    }
368
369    /// Serves the app on a free local port and returns its base URL
370    /// (`http://127.0.0.1:…`), e.g. for a browser test (docs/testing.md).
371    /// The server runs until the test's runtime ends.
372    pub async fn serve(&self) -> String {
373        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
374            .await
375            .expect("a free port");
376        let url = format!("http://{}", listener.local_addr().expect("an address"));
377        let router = self.kernel.router();
378        tokio::spawn(async move {
379            let _ = axum::serve(
380                listener,
381                router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
382            )
383            .await;
384        });
385        url
386    }
387
388    /// Runs every queued job, including those waiting for a delay or a
389    /// retry's backoff, until none is left (at most 1,000 rounds); returns
390    /// how many attempts ran. For tests of retries and `failed` hooks.
391    pub async fn run_all_jobs(&self) -> usize {
392        let mut ran = 0;
393        for _ in 0..1000 {
394            crate::db::sql("UPDATE jobs SET available_at = 0 WHERE reserved_at IS NULL")
395                .execute(self.db())
396                .await
397                .expect("the jobs table can be written");
398            let now = self.run_jobs().await;
399            if now == 0 {
400                break;
401            }
402            ran += now;
403        }
404        ran
405    }
406
407    /// Names of the jobs waiting in the queue, oldest first.
408    pub async fn queued_jobs(&self) -> Vec<String> {
409        crate::db::sql("SELECT job FROM jobs ORDER BY id")
410            .scalars(self.db())
411            .await
412            .expect("the jobs table can be read")
413    }
414
415    /// Logs `user` in for the following requests.
416    pub fn acting_as(&self, user: &User) -> &Self {
417        self.at_travelled_time_sync(|| {
418            let session = self.session();
419            login(&session, user, None).expect("the session accepts the login");
420            self.set_cookie(crate::session::cookie_pair(self.state(), &session));
421        });
422        self
423    }
424
425    /// Marks the password as just typed, so routes behind
426    /// `require_password_confirmed` let the user through.
427    pub fn confirm_password(&self) -> &Self {
428        self.at_travelled_time_sync(|| {
429            let session = self.session();
430            crate::auth::account::mark_confirmed(&session).expect("the session accepts it");
431            self.set_cookie(crate::session::cookie_pair(self.state(), &session));
432        });
433        self
434    }
435
436    /// Forgets the session, like a browser with cookies cleared.
437    pub fn logout(&self) -> &Self {
438        *self.cookie.lock().unwrap_or_else(|e| e.into_inner()) = None;
439        self
440    }
441
442    /// The session cookie as the browser holds it (`name=value`), e.g. to
443    /// play a second device: save one, log in again, switch back with
444    /// [`TestApp::use_session_cookie`].
445    pub fn session_cookie(&self) -> Option<String> {
446        self.cookie
447            .lock()
448            .unwrap_or_else(|e| e.into_inner())
449            .clone()
450    }
451
452    /// Sends this session cookie from now on (see [`TestApp::session_cookie`]).
453    pub fn use_session_cookie(&self, cookie: Option<String>) -> &Self {
454        *self.cookie.lock().unwrap_or_else(|e| e.into_inner()) = cookie;
455        self
456    }
457
458    fn session(&self) -> crate::Session {
459        let cookie = self
460            .cookie
461            .lock()
462            .unwrap_or_else(|e| e.into_inner())
463            .clone();
464        // Read at the travelled time, as the next request will: a session
465        // that has expired by then starts afresh (with a new CSRF token).
466        self.at_travelled_time_sync(|| crate::session::from_cookie(self.state(), cookie.as_deref()))
467    }
468
469    fn at_travelled_time_sync<T>(&self, f: impl FnOnce() -> T) -> T {
470        let offset = self.offset.load(std::sync::atomic::Ordering::SeqCst);
471        crate::clock::with_offset_sync(offset, f)
472    }
473
474    fn set_cookie(&self, pair: String) {
475        *self.cookie.lock().unwrap_or_else(|e| e.into_inner()) = Some(pair);
476    }
477
478    /// The CSRF token of the current session, creating the session if needed.
479    pub fn csrf_token(&self) -> String {
480        self.at_travelled_time_sync(|| {
481            let session = self.session();
482            self.set_cookie(crate::session::cookie_pair(self.state(), &session));
483            session.token()
484        })
485    }
486
487    /// A request with extra headers, e.g. `app.request().htmx().post(..)`.
488    pub fn request(&self) -> TestRequest<'_> {
489        TestRequest {
490            app: self,
491            headers: Vec::new(),
492            csrf: true,
493        }
494    }
495
496    /// Shorthand for `request().htmx()`.
497    pub fn htmx(&self) -> TestRequest<'_> {
498        self.request().htmx()
499    }
500
501    /// A GET request.
502    pub async fn get(&self, uri: &str) -> TestResponse {
503        self.request().get(uri).await
504    }
505
506    /// A form post (urlencoded), with the CSRF token.
507    pub async fn post(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
508        self.request().post(uri, form).await
509    }
510
511    /// A form PUT (urlencoded), with the CSRF token.
512    pub async fn put(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
513        self.request().put(uri, form).await
514    }
515
516    /// A form PATCH (urlencoded), with the CSRF token.
517    pub async fn patch(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
518        self.request().patch(uri, form).await
519    }
520
521    /// A DELETE request, with the CSRF token.
522    pub async fn delete(&self, uri: &str) -> TestResponse {
523        self.request().delete(uri).await
524    }
525
526    /// A JSON post, with the CSRF token.
527    pub async fn post_json(&self, uri: &str, body: &impl Serialize) -> TestResponse {
528        self.request().post_json(uri, body).await
529    }
530
531    /// A multipart form with files; see `TestRequest::post_multipart`.
532    pub async fn post_multipart(
533        &self,
534        uri: &str,
535        fields: &[(&str, &str)],
536        files: &[(&str, &str, &[u8])],
537    ) -> TestResponse {
538        self.request().post_multipart(uri, fields, files).await
539    }
540
541    /// A POST with exactly these bytes (e.g. a signed webhook); no CSRF token.
542    pub async fn post_body(
543        &self,
544        uri: &str,
545        content_type: &str,
546        body: impl Into<Vec<u8>>,
547    ) -> TestResponse {
548        self.request()
549            .without_csrf()
550            .post_body(uri, content_type, body)
551            .await
552    }
553
554    async fn where_count(&self, table: &str, values: &[(&str, &(dyn ToDbValue + Sync))]) -> i64 {
555        let mut sql = format!("SELECT COUNT(*) FROM {}", quote(table));
556        let clauses: Vec<String> = values
557            .iter()
558            .map(|(column, value)| match value.to_db_value() {
559                DbValue::Null => format!("{} IS NULL", quote(column)),
560                _ => format!("{} = ?", quote(column)),
561            })
562            .collect();
563        if !clauses.is_empty() {
564            sql.push_str(" WHERE ");
565            sql.push_str(&clauses.join(" AND "));
566        }
567        crate::db::sql(sql)
568            .bind_all(
569                values
570                    .iter()
571                    .map(|(_, v)| v.to_db_value())
572                    .filter(|v| *v != DbValue::Null),
573            )
574            .scalar(self.db())
575            .await
576            .unwrap_or_else(|err| panic!("could not query `{table}`: {err}"))
577    }
578
579    /// Fails unless a row of `table` has all these column values.
580    pub async fn assert_database_has(
581        &self,
582        table: &str,
583        values: &[(&str, &(dyn ToDbValue + Sync))],
584    ) {
585        if self.where_count(table, values).await == 0 {
586            panic!("expected `{table}` to have a row with {}", describe(values));
587        }
588    }
589
590    /// Fails if a row of `table` has all these column values.
591    pub async fn assert_database_missing(
592        &self,
593        table: &str,
594        values: &[(&str, &(dyn ToDbValue + Sync))],
595    ) {
596        let count = self.where_count(table, values).await;
597        if count > 0 {
598            panic!(
599                "expected `{table}` to have no row with {}, found {count}",
600                describe(values)
601            );
602        }
603    }
604
605    /// Fails unless `table` has exactly `expected` rows.
606    pub async fn assert_database_count(&self, table: &str, expected: i64) {
607        let count = self.where_count(table, &[]).await;
608        assert_eq!(count, expected, "rows in `{table}`");
609    }
610
611    /// Fails unless a mail went to `to` with `subject` in its subject.
612    pub fn assert_mail_sent(&self, to: &str, subject: &str) {
613        let sent = self.sent_mail();
614        if !sent
615            .iter()
616            .any(|m| m.is_for(to) && m.subject.contains(subject))
617        {
618            let list: Vec<String> = sent
619                .iter()
620                .map(|m| format!("{} ({})", m.subject, m.to.join(", ")))
621                .collect();
622            panic!("no mail to {to} about \"{subject}\"; sent: {list:?}");
623        }
624    }
625}
626
627fn describe(values: &[(&str, &(dyn ToDbValue + Sync))]) -> String {
628    values
629        .iter()
630        .map(|(c, v)| format!("{c} = {:?}", v.to_db_value()))
631        .collect::<Vec<_>>()
632        .join(", ")
633}
634
635/// A request being built: `app.request().header("accept", "application/json").get(..)`.
636pub struct TestRequest<'a> {
637    app: &'a TestApp,
638    headers: Vec<(String, String)>,
639    csrf: bool,
640}
641
642impl TestRequest<'_> {
643    /// Adds a request header; may be called several times.
644    pub fn header(mut self, name: &str, value: &str) -> Self {
645        self.headers.push((name.to_owned(), value.to_owned()));
646        self
647    }
648
649    /// Marks the request as made by HTMX.
650    pub fn htmx(self) -> Self {
651        self.header("hx-request", "true")
652    }
653
654    /// Asks for JSON, e.g. to get 401/422 as JSON instead of redirects.
655    pub fn json(self) -> Self {
656        self.header("accept", "application/json")
657    }
658
659    /// Sends no CSRF token, to test that a form is protected.
660    pub fn without_csrf(mut self) -> Self {
661        self.csrf = false;
662        self
663    }
664
665    /// Sends it as a GET.
666    pub async fn get(self, uri: &str) -> TestResponse {
667        self.send(Method::GET, uri, None, Body::empty()).await
668    }
669
670    /// Sends it as a form post (urlencoded), with the CSRF token unless `without_csrf`.
671    pub async fn post(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
672        self.form(Method::POST, uri, form).await
673    }
674
675    /// Sends it as a form PUT (urlencoded), with the CSRF token unless `without_csrf`.
676    pub async fn put(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
677        self.form(Method::PUT, uri, form).await
678    }
679
680    /// Sends it as a form PATCH (urlencoded), with the CSRF token unless `without_csrf`.
681    pub async fn patch(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
682        self.form(Method::PATCH, uri, form).await
683    }
684
685    /// Sends it as a DELETE, with the CSRF token unless `without_csrf`.
686    pub async fn delete(self, uri: &str) -> TestResponse {
687        self.send(Method::DELETE, uri, None, Body::empty()).await
688    }
689
690    /// A multipart form, as a browser sends one with a file input:
691    /// `post_multipart("/photos", &[("title", "Coffee")], &[("photo", "coffee.png", &bytes)])`.
692    pub async fn post_multipart(
693        self,
694        uri: &str,
695        fields: &[(&str, &str)],
696        files: &[(&str, &str, &[u8])],
697    ) -> TestResponse {
698        const BOUNDARY: &str = "renox-test-boundary-7d1f";
699        let mut body = Vec::new();
700        for (name, value) in fields {
701            body.extend_from_slice(
702                format!("--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
703                    .as_bytes(),
704            );
705        }
706        for (name, file_name, bytes) in files {
707            body.extend_from_slice(
708                format!(
709                    "--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{file_name}\"\r\n\
710                     Content-Type: application/octet-stream\r\n\r\n"
711                )
712                .as_bytes(),
713            );
714            body.extend_from_slice(bytes);
715            body.extend_from_slice(b"\r\n");
716        }
717        body.extend_from_slice(format!("--{BOUNDARY}--\r\n").as_bytes());
718        let content_type = format!("multipart/form-data; boundary={BOUNDARY}");
719        self.send(Method::POST, uri, Some(&content_type), Body::from(body))
720            .await
721    }
722
723    /// A POST with exactly these bytes, e.g. a webhook whose signature
724    /// covers the raw body.
725    pub async fn post_body(
726        self,
727        uri: &str,
728        content_type: &str,
729        body: impl Into<Vec<u8>>,
730    ) -> TestResponse {
731        self.send(
732            Method::POST,
733            uri,
734            Some(content_type),
735            Body::from(body.into()),
736        )
737        .await
738    }
739
740    /// Sends `body` as a JSON POST, with the CSRF token unless `without_csrf`.
741    pub async fn post_json(self, uri: &str, body: &impl Serialize) -> TestResponse {
742        let body = serde_json::to_vec(body).expect("the body serializes");
743        self.send(
744            Method::POST,
745            uri,
746            Some("application/json"),
747            Body::from(body),
748        )
749        .await
750    }
751
752    async fn form(self, method: Method, uri: &str, form: &[(&str, &str)]) -> TestResponse {
753        let body = form_urlencoded::Serializer::new(String::new())
754            .extend_pairs(form)
755            .finish();
756        self.send(
757            method,
758            uri,
759            Some("application/x-www-form-urlencoded"),
760            Body::from(body),
761        )
762        .await
763    }
764
765    async fn send(
766        self,
767        method: Method,
768        uri: &str,
769        content_type: Option<&str>,
770        body: Body,
771    ) -> TestResponse {
772        let app = self.app;
773        let token = (self.csrf && method != Method::GET).then(|| app.csrf_token());
774        let mut req = Request::builder().method(method).uri(uri);
775        if let Some(cookie) = app.cookie.lock().unwrap_or_else(|e| e.into_inner()).clone() {
776            req = req.header(COOKIE, cookie);
777        }
778        if let Some(token) = token {
779            req = req.header(crate::CSRF_HEADER, token);
780        }
781        if let Some(content_type) = content_type {
782            req = req.header(CONTENT_TYPE, content_type);
783        }
784        for (name, value) in &self.headers {
785            req = req.header(name.as_str(), value.as_str());
786        }
787        let request = app
788            .kernel
789            .router()
790            .oneshot(req.body(body).expect("a valid request"));
791        let res = app
792            .at_travelled_time(request)
793            .await
794            .expect("the router answers");
795        let view = res
796            .extensions()
797            .get::<crate::view::RenderedView>()
798            .map(|v| v.0.clone());
799
800        let session_cookie = format!("{}=", app.state().config.session_cookie);
801        for set in res.headers().get_all(SET_COOKIE) {
802            let pair = set
803                .to_str()
804                .unwrap_or_default()
805                .split(';')
806                .next()
807                .unwrap_or_default();
808            if pair.starts_with(&session_cookie) {
809                app.set_cookie(pair.to_owned());
810            }
811        }
812        let status = res.status();
813        let headers = res.headers().clone();
814        let body = res
815            .into_body()
816            .collect()
817            .await
818            .expect("the body can be read")
819            .to_bytes();
820        TestResponse {
821            status,
822            headers,
823            body,
824            view,
825        }
826    }
827}
828
829/// A response with Laravel-style assertions; they panic with the status and
830/// the start of the body, and return `&Self` so they can be chained.
831#[derive(Debug, Clone)]
832pub struct TestResponse {
833    /// The response status.
834    pub status: StatusCode,
835    /// The response headers.
836    pub headers: HeaderMap,
837    /// The response body, read in full.
838    pub body: Bytes,
839    /// The template the page was rendered from, if it was a view.
840    pub view: Option<String>,
841}
842
843impl TestResponse {
844    /// The body as text (invalid UTF-8 replaced).
845    pub fn text(&self) -> String {
846        String::from_utf8_lossy(&self.body).into_owned()
847    }
848
849    /// The body as JSON; panics if it isn't.
850    pub fn json<T: DeserializeOwned>(&self) -> T {
851        serde_json::from_slice(&self.body).unwrap_or_else(|err| {
852            panic!(
853                "the body is not the expected JSON ({err}):\n{}",
854                self.excerpt()
855            )
856        })
857    }
858
859    /// The value of header `name`; `None` when it's missing or not text.
860    pub fn header(&self, name: &str) -> Option<&str> {
861        self.headers
862            .get(HeaderName::from_bytes(name.as_bytes()).ok()?)
863            .and_then(|v: &HeaderValue| v.to_str().ok())
864    }
865
866    fn excerpt(&self) -> String {
867        let text = self.text();
868        match text.char_indices().nth(600) {
869            Some((i, _)) => format!("{}…", &text[..i]),
870            None => text,
871        }
872    }
873
874    /// Panics unless the status is `expected`.
875    #[track_caller]
876    pub fn assert_status(&self, expected: u16) -> &Self {
877        if self.status.as_u16() != expected {
878            panic!(
879                "expected status {expected}, got {}:\n{}",
880                self.status,
881                self.excerpt()
882            );
883        }
884        self
885    }
886
887    /// Panics unless the status is 200.
888    #[track_caller]
889    pub fn assert_ok(&self) -> &Self {
890        self.assert_status(200)
891    }
892
893    /// Panics unless the status is 404.
894    #[track_caller]
895    pub fn assert_not_found(&self) -> &Self {
896        self.assert_status(404)
897    }
898
899    /// Panics unless the status is 403.
900    #[track_caller]
901    pub fn assert_forbidden(&self) -> &Self {
902        self.assert_status(403)
903    }
904
905    /// Panics unless the status is 401.
906    #[track_caller]
907    pub fn assert_unauthorized(&self) -> &Self {
908        self.assert_status(401)
909    }
910
911    /// A 3xx redirect whose `Location` is `to`.
912    #[track_caller]
913    pub fn assert_redirect(&self, to: &str) -> &Self {
914        if !self.status.is_redirection() {
915            panic!(
916                "expected a redirect to {to}, got {}:\n{}",
917                self.status,
918                self.excerpt()
919            );
920        }
921        let location = self.header(LOCATION.as_str()).unwrap_or_default();
922        if location != to {
923            panic!("expected a redirect to {to}, got one to {location}");
924        }
925        self
926    }
927
928    /// An `HX-Redirect` to `to` (HTMX requests).
929    #[track_caller]
930    pub fn assert_hx_redirect(&self, to: &str) -> &Self {
931        let location = self.header("hx-redirect").unwrap_or_default();
932        if location != to {
933            panic!(
934                "expected HX-Redirect to {to}, got `{location}` ({})",
935                self.status
936            );
937        }
938        self
939    }
940
941    /// The body contains `text` (as written, e.g. already HTML-escaped).
942    #[track_caller]
943    pub fn assert_see(&self, text: &str) -> &Self {
944        if !self.text().contains(text) {
945            panic!("expected to see {text:?} in:\n{}", self.excerpt());
946        }
947        self
948    }
949
950    /// Panics if the body contains `text` (as written, e.g. already HTML-escaped).
951    #[track_caller]
952    pub fn assert_dont_see(&self, text: &str) -> &Self {
953        if self.text().contains(text) {
954            panic!("expected not to see {text:?} in:\n{}", self.excerpt());
955        }
956        self
957    }
958
959    /// Panics unless the page was rendered from `name` (e.g.
960    /// `"products/index.html"`).
961    #[track_caller]
962    pub fn assert_view(&self, name: &str) -> &Self {
963        assert_eq!(self.view.as_deref(), Some(name), "the view rendered");
964        self
965    }
966
967    /// The value at `path` in the JSON body: keys and indexes separated by
968    /// dots (`data.0.name`); `null` when it isn't there.
969    pub fn json_path(&self, path: &str) -> serde_json::Value {
970        let body: serde_json::Value = serde_json::from_slice(&self.body).unwrap_or_default();
971        path.split('.')
972            .filter(|part| !part.is_empty())
973            .fold(body, |value, part| match part.parse::<usize>() {
974                Ok(i) if value.is_array() => value.get(i).cloned().unwrap_or_default(),
975                _ => value.get(part).cloned().unwrap_or_default(),
976            })
977    }
978
979    /// Panics unless the JSON body has `expected` at `path`
980    /// (`assert_json_path("data.0.name", "Coffee")`).
981    #[track_caller]
982    pub fn assert_json_path(&self, path: &str, expected: impl Serialize) -> &Self {
983        let expected = serde_json::to_value(expected).expect("a JSON value");
984        assert_eq!(self.json_path(path), expected, "JSON at `{path}`");
985        self
986    }
987
988    /// Panics unless the JSON body contains `expected`: every key of an
989    /// object in `expected` must be there with that value (other keys may be
990    /// too); arrays must match item by item.
991    #[track_caller]
992    pub fn assert_json(&self, expected: serde_json::Value) -> &Self {
993        let body: serde_json::Value = serde_json::from_slice(&self.body).unwrap_or_default();
994        assert!(
995            json_contains(&body, &expected),
996            "the JSON body doesn't contain {expected}; it's {body}"
997        );
998        self
999    }
1000
1001    /// Panics unless header `name` is exactly `value`.
1002    #[track_caller]
1003    pub fn assert_header(&self, name: &str, value: &str) -> &Self {
1004        let actual = self.header(name);
1005        if actual != Some(value) {
1006            panic!("expected header {name}: {value}, got {actual:?}");
1007        }
1008        self
1009    }
1010
1011    /// A 422 validation response (HTMX or JSON) with an error for `field`.
1012    #[track_caller]
1013    pub fn assert_invalid(&self, field: &str) -> &Self {
1014        self.assert_status(422);
1015        let body: serde_json::Value = self.json();
1016        if body["errors"][field]
1017            .as_array()
1018            .is_none_or(|e| e.is_empty())
1019        {
1020            panic!(
1021                "expected a validation error for `{field}`, got {}",
1022                body["errors"]
1023            );
1024        }
1025        self
1026    }
1027}
1028
1029/// Whether `actual` has everything `expected` has.
1030fn json_contains(actual: &serde_json::Value, expected: &serde_json::Value) -> bool {
1031    use serde_json::Value;
1032    match (actual, expected) {
1033        (Value::Object(a), Value::Object(e)) => e
1034            .iter()
1035            .all(|(k, v)| a.get(k).is_some_and(|av| json_contains(av, v))),
1036        (Value::Array(a), Value::Array(e)) => {
1037            a.len() == e.len() && a.iter().zip(e).all(|(av, ev)| json_contains(av, ev))
1038        }
1039        _ => actual == expected,
1040    }
1041}