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    /// Starts a test of a live component from `component`'s state: the way
502    /// a browser holds a component after a page mounted it.
503    ///
504    /// ```
505    /// # use renox::prelude::*;
506    /// # use renox::testing::TestApp;
507    /// # #[derive(serde::Serialize, serde::Deserialize, Default)]
508    /// # struct Counter { count: i64 }
509    /// # impl LiveComponent for Counter {
510    /// #     const NAME: &'static str = "counter";
511    /// #     const VIEW: &'static str = "live/counter.html";
512    /// #     async fn call(&mut self, _: &str, _: Vec<serde_json::Value>,
513    /// #         _: &mut renox::live_component::LiveContext) -> Result { Ok(()) }
514    /// # }
515    /// # async fn demo(app: TestApp) {
516    /// let mut counter = app.live(Counter::default());
517    /// counter.call("increment").await.assert_ok();
518    /// assert_eq!(counter.component().count, 0);
519    /// # }
520    /// ```
521    ///
522    /// # Panics
523    ///
524    /// When the state can't be sealed (for example, it is over
525    /// `LIVE_SNAPSHOT_MAX_SIZE`).
526    pub fn live<C: crate::live_component::LiveComponent>(&self, component: C) -> LiveTest<'_, C> {
527        let state = serde_json::to_value(&component)
528            .unwrap_or_else(|e| panic!("live component `{}` can't be serialized: {e}", C::NAME));
529        let snapshot = crate::live_component::snapshot::seal(
530            self.state().key.signing(),
531            self.state().config.live_snapshot_max,
532            C::NAME,
533            "rx-live-test",
534            &state,
535        )
536        .unwrap_or_else(|e| panic!("live component `{}` can't be sealed: {e:?}", C::NAME));
537        LiveTest {
538            app: self,
539            snapshot,
540            fields: Vec::new(),
541            marker: std::marker::PhantomData,
542        }
543    }
544
545    /// A GET request.
546    pub async fn get(&self, uri: &str) -> TestResponse {
547        self.request().get(uri).await
548    }
549
550    /// A form post (urlencoded), with the CSRF token.
551    pub async fn post(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
552        self.request().post(uri, form).await
553    }
554
555    /// A form PUT (urlencoded), with the CSRF token.
556    pub async fn put(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
557        self.request().put(uri, form).await
558    }
559
560    /// A form PATCH (urlencoded), with the CSRF token.
561    pub async fn patch(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
562        self.request().patch(uri, form).await
563    }
564
565    /// A DELETE request, with the CSRF token.
566    pub async fn delete(&self, uri: &str) -> TestResponse {
567        self.request().delete(uri).await
568    }
569
570    /// A JSON post, with the CSRF token.
571    pub async fn post_json(&self, uri: &str, body: &impl Serialize) -> TestResponse {
572        self.request().post_json(uri, body).await
573    }
574
575    /// A multipart form with files; see `TestRequest::post_multipart`.
576    pub async fn post_multipart(
577        &self,
578        uri: &str,
579        fields: &[(&str, &str)],
580        files: &[(&str, &str, &[u8])],
581    ) -> TestResponse {
582        self.request().post_multipart(uri, fields, files).await
583    }
584
585    /// A POST with exactly these bytes (e.g. a signed webhook); no CSRF token.
586    pub async fn post_body(
587        &self,
588        uri: &str,
589        content_type: &str,
590        body: impl Into<Vec<u8>>,
591    ) -> TestResponse {
592        self.request()
593            .without_csrf()
594            .post_body(uri, content_type, body)
595            .await
596    }
597
598    async fn where_count(&self, table: &str, values: &[(&str, &(dyn ToDbValue + Sync))]) -> i64 {
599        let mut sql = format!("SELECT COUNT(*) FROM {}", quote(table));
600        let clauses: Vec<String> = values
601            .iter()
602            .map(|(column, value)| match value.to_db_value() {
603                DbValue::Null => format!("{} IS NULL", quote(column)),
604                _ => format!("{} = ?", quote(column)),
605            })
606            .collect();
607        if !clauses.is_empty() {
608            sql.push_str(" WHERE ");
609            sql.push_str(&clauses.join(" AND "));
610        }
611        crate::db::sql(sql)
612            .bind_all(
613                values
614                    .iter()
615                    .map(|(_, v)| v.to_db_value())
616                    .filter(|v| *v != DbValue::Null),
617            )
618            .scalar(self.db())
619            .await
620            .unwrap_or_else(|err| panic!("could not query `{table}`: {err}"))
621    }
622
623    /// Fails unless a row of `table` has all these column values.
624    pub async fn assert_database_has(
625        &self,
626        table: &str,
627        values: &[(&str, &(dyn ToDbValue + Sync))],
628    ) {
629        if self.where_count(table, values).await == 0 {
630            panic!("expected `{table}` to have a row with {}", describe(values));
631        }
632    }
633
634    /// Fails unless every model registered with `App::model` matches the
635    /// table the migrations built for it.
636    ///
637    /// ```no_run
638    /// # async fn demo(app: renox::testing::TestApp) {
639    /// app.assert_models_match_schema().await;
640    /// # }
641    /// ```
642    pub async fn assert_models_match_schema(&self) {
643        let reports = crate::db::schema_check::check(self.db(), self.kernel.models())
644            .await
645            .expect("the schema can be read");
646        let (text, problems) = crate::db::schema_check::render(&reports);
647        if problems > 0 {
648            panic!("the models do not match the schema:\n{text}");
649        }
650    }
651
652    /// Fails unless every template compiles and every literal `route('name')` in them names
653    /// a route (the check `rnx view:check` runs). Panics with one line per problem,
654    /// `template:line: message`.
655    ///
656    /// ```no_run
657    /// # async fn demo(app: renox::testing::TestApp) {
658    /// app.assert_views_compile();
659    /// # }
660    /// ```
661    pub fn assert_views_compile(&self) -> &Self {
662        let state = self.state();
663        let problems = state.views.check(&state.routes);
664        if !problems.is_empty() {
665            let list: Vec<String> = problems.iter().map(|p| p.to_string()).collect();
666            panic!(
667                "{} template problem(s):\n{}",
668                problems.len(),
669                list.join("\n")
670            );
671        }
672        self
673    }
674
675    /// Fails if a row of `table` has all these column values.
676    pub async fn assert_database_missing(
677        &self,
678        table: &str,
679        values: &[(&str, &(dyn ToDbValue + Sync))],
680    ) {
681        let count = self.where_count(table, values).await;
682        if count > 0 {
683            panic!(
684                "expected `{table}` to have no row with {}, found {count}",
685                describe(values)
686            );
687        }
688    }
689
690    /// Fails unless `table` has exactly `expected` rows.
691    pub async fn assert_database_count(&self, table: &str, expected: i64) {
692        let count = self.where_count(table, &[]).await;
693        assert_eq!(count, expected, "rows in `{table}`");
694    }
695
696    /// Fails unless a mail went to `to` with `subject` in its subject.
697    pub fn assert_mail_sent(&self, to: &str, subject: &str) {
698        let sent = self.sent_mail();
699        if !sent
700            .iter()
701            .any(|m| m.is_for(to) && m.subject.contains(subject))
702        {
703            let list: Vec<String> = sent
704                .iter()
705                .map(|m| format!("{} ({})", m.subject, m.to.join(", ")))
706                .collect();
707            panic!("no mail to {to} about \"{subject}\"; sent: {list:?}");
708        }
709    }
710}
711
712fn describe(values: &[(&str, &(dyn ToDbValue + Sync))]) -> String {
713    values
714        .iter()
715        .map(|(c, v)| format!("{c} = {:?}", v.to_db_value()))
716        .collect::<Vec<_>>()
717        .join(", ")
718}
719
720/// A request being built: `app.request().header("accept", "application/json").get(..)`.
721pub struct TestRequest<'a> {
722    app: &'a TestApp,
723    headers: Vec<(String, String)>,
724    csrf: bool,
725}
726
727impl TestRequest<'_> {
728    /// Adds a request header; may be called several times.
729    pub fn header(mut self, name: &str, value: &str) -> Self {
730        self.headers.push((name.to_owned(), value.to_owned()));
731        self
732    }
733
734    /// Marks the request as made by HTMX.
735    pub fn htmx(self) -> Self {
736        self.header("hx-request", "true")
737    }
738
739    /// Asks for JSON, e.g. to get 401/422 as JSON instead of redirects.
740    pub fn json(self) -> Self {
741        self.header("accept", "application/json")
742    }
743
744    /// Sends no CSRF token, to test that a form is protected.
745    pub fn without_csrf(mut self) -> Self {
746        self.csrf = false;
747        self
748    }
749
750    /// Sends it as a GET.
751    pub async fn get(self, uri: &str) -> TestResponse {
752        self.send(Method::GET, uri, None, Body::empty()).await
753    }
754
755    /// Sends it as a form post (urlencoded), with the CSRF token unless `without_csrf`.
756    pub async fn post(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
757        self.form(Method::POST, uri, form).await
758    }
759
760    /// Sends it as a form PUT (urlencoded), with the CSRF token unless `without_csrf`.
761    pub async fn put(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
762        self.form(Method::PUT, uri, form).await
763    }
764
765    /// Sends it as a form PATCH (urlencoded), with the CSRF token unless `without_csrf`.
766    pub async fn patch(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
767        self.form(Method::PATCH, uri, form).await
768    }
769
770    /// Sends it as a DELETE, with the CSRF token unless `without_csrf`.
771    pub async fn delete(self, uri: &str) -> TestResponse {
772        self.send(Method::DELETE, uri, None, Body::empty()).await
773    }
774
775    /// A multipart form, as a browser sends one with a file input:
776    /// `post_multipart("/photos", &[("title", "Coffee")], &[("photo", "coffee.png", &bytes)])`.
777    pub async fn post_multipart(
778        self,
779        uri: &str,
780        fields: &[(&str, &str)],
781        files: &[(&str, &str, &[u8])],
782    ) -> TestResponse {
783        const BOUNDARY: &str = "renox-test-boundary-7d1f";
784        let mut body = Vec::new();
785        for (name, value) in fields {
786            body.extend_from_slice(
787                format!("--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
788                    .as_bytes(),
789            );
790        }
791        for (name, file_name, bytes) in files {
792            body.extend_from_slice(
793                format!(
794                    "--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{file_name}\"\r\n\
795                     Content-Type: application/octet-stream\r\n\r\n"
796                )
797                .as_bytes(),
798            );
799            body.extend_from_slice(bytes);
800            body.extend_from_slice(b"\r\n");
801        }
802        body.extend_from_slice(format!("--{BOUNDARY}--\r\n").as_bytes());
803        let content_type = format!("multipart/form-data; boundary={BOUNDARY}");
804        self.send(Method::POST, uri, Some(&content_type), Body::from(body))
805            .await
806    }
807
808    /// A POST with exactly these bytes, e.g. a webhook whose signature
809    /// covers the raw body.
810    pub async fn post_body(
811        self,
812        uri: &str,
813        content_type: &str,
814        body: impl Into<Vec<u8>>,
815    ) -> TestResponse {
816        self.send(
817            Method::POST,
818            uri,
819            Some(content_type),
820            Body::from(body.into()),
821        )
822        .await
823    }
824
825    /// Sends `body` as a JSON POST, with the CSRF token unless `without_csrf`.
826    pub async fn post_json(self, uri: &str, body: &impl Serialize) -> TestResponse {
827        let body = serde_json::to_vec(body).expect("the body serializes");
828        self.send(
829            Method::POST,
830            uri,
831            Some("application/json"),
832            Body::from(body),
833        )
834        .await
835    }
836
837    async fn form(self, method: Method, uri: &str, form: &[(&str, &str)]) -> TestResponse {
838        let body = form_urlencoded::Serializer::new(String::new())
839            .extend_pairs(form)
840            .finish();
841        self.send(
842            method,
843            uri,
844            Some("application/x-www-form-urlencoded"),
845            Body::from(body),
846        )
847        .await
848    }
849
850    async fn send(
851        self,
852        method: Method,
853        uri: &str,
854        content_type: Option<&str>,
855        body: Body,
856    ) -> TestResponse {
857        let app = self.app;
858        let token = (self.csrf && method != Method::GET).then(|| app.csrf_token());
859        let mut req = Request::builder().method(method).uri(uri);
860        if let Some(cookie) = app.cookie.lock().unwrap_or_else(|e| e.into_inner()).clone() {
861            req = req.header(COOKIE, cookie);
862        }
863        if let Some(token) = token {
864            req = req.header(crate::CSRF_HEADER, token);
865        }
866        if let Some(content_type) = content_type {
867            req = req.header(CONTENT_TYPE, content_type);
868        }
869        for (name, value) in &self.headers {
870            req = req.header(name.as_str(), value.as_str());
871        }
872        let request = app
873            .kernel
874            .router()
875            .oneshot(req.body(body).expect("a valid request"));
876        let res = app
877            .at_travelled_time(request)
878            .await
879            .expect("the router answers");
880        let view = res
881            .extensions()
882            .get::<crate::view::RenderedView>()
883            .map(|v| v.0.clone());
884
885        let session_cookie = format!("{}=", app.state().config.session_cookie);
886        for set in res.headers().get_all(SET_COOKIE) {
887            let pair = set
888                .to_str()
889                .unwrap_or_default()
890                .split(';')
891                .next()
892                .unwrap_or_default();
893            if pair.starts_with(&session_cookie) {
894                app.set_cookie(pair.to_owned());
895            }
896        }
897        let status = res.status();
898        let headers = res.headers().clone();
899        let body = res
900            .into_body()
901            .collect()
902            .await
903            .expect("the body can be read")
904            .to_bytes();
905        TestResponse {
906            status,
907            headers,
908            body,
909            view,
910        }
911    }
912}
913
914/// A response with Laravel-style assertions; they panic with the status and
915/// the start of the body, and return `&Self` so they can be chained.
916#[derive(Debug, Clone)]
917pub struct TestResponse {
918    /// The response status.
919    pub status: StatusCode,
920    /// The response headers.
921    pub headers: HeaderMap,
922    /// The response body, read in full.
923    pub body: Bytes,
924    /// The template the page was rendered from, if it was a view.
925    pub view: Option<String>,
926}
927
928impl TestResponse {
929    /// The body as text (invalid UTF-8 replaced).
930    pub fn text(&self) -> String {
931        String::from_utf8_lossy(&self.body).into_owned()
932    }
933
934    /// The body as JSON; panics if it isn't.
935    pub fn json<T: DeserializeOwned>(&self) -> T {
936        serde_json::from_slice(&self.body).unwrap_or_else(|err| {
937            panic!(
938                "the body is not the expected JSON ({err}):\n{}",
939                self.excerpt()
940            )
941        })
942    }
943
944    /// The value of header `name`; `None` when it's missing or not text.
945    pub fn header(&self, name: &str) -> Option<&str> {
946        self.headers
947            .get(HeaderName::from_bytes(name.as_bytes()).ok()?)
948            .and_then(|v: &HeaderValue| v.to_str().ok())
949    }
950
951    fn excerpt(&self) -> String {
952        let text = self.text();
953        match text.char_indices().nth(600) {
954            Some((i, _)) => format!("{}…", &text[..i]),
955            None => text,
956        }
957    }
958
959    /// Panics unless the status is `expected`.
960    #[track_caller]
961    pub fn assert_status(&self, expected: u16) -> &Self {
962        if self.status.as_u16() != expected {
963            panic!(
964                "expected status {expected}, got {}:\n{}",
965                self.status,
966                self.excerpt()
967            );
968        }
969        self
970    }
971
972    /// Panics unless the status is 200.
973    #[track_caller]
974    pub fn assert_ok(&self) -> &Self {
975        self.assert_status(200)
976    }
977
978    /// Panics unless the status is 404.
979    #[track_caller]
980    pub fn assert_not_found(&self) -> &Self {
981        self.assert_status(404)
982    }
983
984    /// Panics unless the status is 403.
985    #[track_caller]
986    pub fn assert_forbidden(&self) -> &Self {
987        self.assert_status(403)
988    }
989
990    /// Panics unless the status is 401.
991    #[track_caller]
992    pub fn assert_unauthorized(&self) -> &Self {
993        self.assert_status(401)
994    }
995
996    /// A 3xx redirect whose `Location` is `to`.
997    #[track_caller]
998    pub fn assert_redirect(&self, to: &str) -> &Self {
999        if !self.status.is_redirection() {
1000            panic!(
1001                "expected a redirect to {to}, got {}:\n{}",
1002                self.status,
1003                self.excerpt()
1004            );
1005        }
1006        let location = self.header(LOCATION.as_str()).unwrap_or_default();
1007        if location != to {
1008            panic!("expected a redirect to {to}, got one to {location}");
1009        }
1010        self
1011    }
1012
1013    /// An `HX-Redirect` to `to` (HTMX requests).
1014    #[track_caller]
1015    pub fn assert_hx_redirect(&self, to: &str) -> &Self {
1016        let location = self.header("hx-redirect").unwrap_or_default();
1017        if location != to {
1018            panic!(
1019                "expected HX-Redirect to {to}, got `{location}` ({})",
1020                self.status
1021            );
1022        }
1023        self
1024    }
1025
1026    /// The body contains `text` (as written, e.g. already HTML-escaped).
1027    #[track_caller]
1028    pub fn assert_see(&self, text: &str) -> &Self {
1029        if !self.text().contains(text) {
1030            panic!("expected to see {text:?} in:\n{}", self.excerpt());
1031        }
1032        self
1033    }
1034
1035    /// Panics if the body contains `text` (as written, e.g. already HTML-escaped).
1036    #[track_caller]
1037    pub fn assert_dont_see(&self, text: &str) -> &Self {
1038        if self.text().contains(text) {
1039            panic!("expected not to see {text:?} in:\n{}", self.excerpt());
1040        }
1041        self
1042    }
1043
1044    /// Panics unless the page was rendered from `name` (e.g.
1045    /// `"products/index.html"`).
1046    #[track_caller]
1047    pub fn assert_view(&self, name: &str) -> &Self {
1048        assert_eq!(self.view.as_deref(), Some(name), "the view rendered");
1049        self
1050    }
1051
1052    /// The value at `path` in the JSON body: keys and indexes separated by
1053    /// dots (`data.0.name`); `null` when it isn't there.
1054    pub fn json_path(&self, path: &str) -> serde_json::Value {
1055        let body: serde_json::Value = serde_json::from_slice(&self.body).unwrap_or_default();
1056        path.split('.')
1057            .filter(|part| !part.is_empty())
1058            .fold(body, |value, part| match part.parse::<usize>() {
1059                Ok(i) if value.is_array() => value.get(i).cloned().unwrap_or_default(),
1060                _ => value.get(part).cloned().unwrap_or_default(),
1061            })
1062    }
1063
1064    /// Panics unless the JSON body has `expected` at `path`
1065    /// (`assert_json_path("data.0.name", "Coffee")`).
1066    #[track_caller]
1067    pub fn assert_json_path(&self, path: &str, expected: impl Serialize) -> &Self {
1068        let expected = serde_json::to_value(expected).expect("a JSON value");
1069        assert_eq!(self.json_path(path), expected, "JSON at `{path}`");
1070        self
1071    }
1072
1073    /// Panics unless the JSON body contains `expected`: every key of an
1074    /// object in `expected` must be there with that value (other keys may be
1075    /// too); arrays must match item by item.
1076    #[track_caller]
1077    pub fn assert_json(&self, expected: serde_json::Value) -> &Self {
1078        let body: serde_json::Value = serde_json::from_slice(&self.body).unwrap_or_default();
1079        assert!(
1080            json_contains(&body, &expected),
1081            "the JSON body doesn't contain {expected}; it's {body}"
1082        );
1083        self
1084    }
1085
1086    /// Panics unless header `name` is exactly `value`.
1087    #[track_caller]
1088    pub fn assert_header(&self, name: &str, value: &str) -> &Self {
1089        let actual = self.header(name);
1090        if actual != Some(value) {
1091            panic!("expected header {name}: {value}, got {actual:?}");
1092        }
1093        self
1094    }
1095
1096    /// A 422 validation response (HTMX or JSON) with an error for `field`.
1097    #[track_caller]
1098    pub fn assert_invalid(&self, field: &str) -> &Self {
1099        self.assert_status(422);
1100        let body: serde_json::Value = self.json();
1101        if body["errors"][field]
1102            .as_array()
1103            .is_none_or(|e| e.is_empty())
1104        {
1105            panic!(
1106                "expected a validation error for `{field}`, got {}",
1107                body["errors"]
1108            );
1109        }
1110        self
1111    }
1112}
1113
1114/// Whether `actual` has everything `expected` has.
1115fn json_contains(actual: &serde_json::Value, expected: &serde_json::Value) -> bool {
1116    use serde_json::Value;
1117    match (actual, expected) {
1118        (Value::Object(a), Value::Object(e)) => e
1119            .iter()
1120            .all(|(k, v)| a.get(k).is_some_and(|av| json_contains(av, v))),
1121        (Value::Array(a), Value::Array(e)) => {
1122            a.len() == e.len() && a.iter().zip(e).all(|(av, ev)| json_contains(av, ev))
1123        }
1124        _ => actual == expected,
1125    }
1126}
1127
1128/// A live component under test, made by [`TestApp::live`]. It keeps the
1129/// component's signed snapshot between calls, as the page does, and sends the
1130/// fields set with [`set`](Self::set) on every call.
1131pub struct LiveTest<'a, C> {
1132    app: &'a TestApp,
1133    snapshot: String,
1134    fields: Vec<(String, String)>,
1135    marker: std::marker::PhantomData<fn() -> C>,
1136}
1137
1138impl<C: crate::live_component::LiveComponent> LiveTest<'_, C> {
1139    /// Sets a form field, like typing in an input bound to the component.
1140    /// It is sent with every call from now on.
1141    pub fn set(&mut self, name: &str, value: impl ToString) -> &mut Self {
1142        let value = value.to_string();
1143        match self.fields.iter_mut().find(|(n, _)| n == name) {
1144            Some(field) => field.1 = value,
1145            None => self.fields.push((name.to_string(), value)),
1146        }
1147        self
1148    }
1149
1150    /// Calls an action without arguments.
1151    pub async fn call(&mut self, action: &str) -> TestResponse {
1152        self.call_with(action, serde_json::json!([])).await
1153    }
1154
1155    /// Calls an action with its arguments (a JSON array). When the answer is
1156    /// a 200, the component's new snapshot is kept for the next call.
1157    pub async fn call_with(&mut self, action: &str, args: serde_json::Value) -> TestResponse {
1158        let args = args.to_string();
1159        let mut form: Vec<(&str, &str)> = vec![("_snapshot", &self.snapshot), ("_args", &args)];
1160        form.extend(self.fields.iter().map(|(n, v)| (n.as_str(), v.as_str())));
1161        let uri = format!("/_renox/live/{}/{action}", C::NAME);
1162        let res = self.app.htmx().post(&uri, &form).await;
1163        if res.status.as_u16() == 200 {
1164            let html = res.text();
1165            const MARK: &str = "data-rx-snapshot=\"";
1166            if let Some(start) = html.find(MARK).map(|i| i + MARK.len())
1167                && let Some(len) = html[start..].find('"')
1168            {
1169                self.snapshot = html[start..start + len].to_string();
1170            }
1171        }
1172        res
1173    }
1174
1175    /// The component as of the last answer.
1176    ///
1177    /// # Panics
1178    ///
1179    /// When the snapshot can't be read back.
1180    pub fn component(&self) -> C {
1181        let state = self.app.state();
1182        let (_, value) = crate::live_component::snapshot::open(
1183            state.key.signing(),
1184            state.config.live_snapshot_max,
1185            C::NAME,
1186            &self.snapshot,
1187        )
1188        .unwrap_or_else(|e| panic!("live snapshot of `{}` is unreadable: {e:?}", C::NAME));
1189        serde_json::from_value(value)
1190            .unwrap_or_else(|e| panic!("live component `{}` can't be read: {e}", C::NAME))
1191    }
1192}