1use 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
44pub struct TestApp {
46 kernel: Kernel,
47 cookie: Mutex<Option<String>>,
48 offset: std::sync::atomic::AtomicI64,
50 _storage: tempfile::TempDir,
51}
52
53impl TestApp {
54 pub async fn new(app: App) -> Self {
56 Self::with_config(app, |_| {}).await
57 }
58
59 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 pub fn kernel(&self) -> &Kernel {
81 &self.kernel
82 }
83
84 pub fn state(&self) -> &AppState {
86 self.kernel.state()
87 }
88
89 pub fn db(&self) -> &Db {
91 self.kernel.db()
92 }
93
94 pub fn mailer(&self) -> &Mailer {
96 self.kernel.mailer()
97 }
98
99 pub fn fake_http(&self) -> crate::http::FakeHttp {
102 self.state().http.fake()
103 }
104
105 pub fn sent_mail(&self) -> Vec<Mail> {
107 self.kernel.mailer().sent()
108 }
109
110 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 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 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 pub fn travel_back(&self) -> &Self {
157 self.offset.store(0, std::sync::atomic::Ordering::SeqCst);
158 self
159 }
160
161 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 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 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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 pub fn session_get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
326 self.session().get(key)
327 }
328
329 #[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 #[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 #[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 #[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 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 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 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 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 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 pub fn logout(&self) -> &Self {
438 *self.cookie.lock().unwrap_or_else(|e| e.into_inner()) = None;
439 self
440 }
441
442 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 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 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 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 pub fn request(&self) -> TestRequest<'_> {
489 TestRequest {
490 app: self,
491 headers: Vec::new(),
492 csrf: true,
493 }
494 }
495
496 pub fn htmx(&self) -> TestRequest<'_> {
498 self.request().htmx()
499 }
500
501 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 pub async fn get(&self, uri: &str) -> TestResponse {
547 self.request().get(uri).await
548 }
549
550 pub async fn post(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
552 self.request().post(uri, form).await
553 }
554
555 pub async fn put(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
557 self.request().put(uri, form).await
558 }
559
560 pub async fn patch(&self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
562 self.request().patch(uri, form).await
563 }
564
565 pub async fn delete(&self, uri: &str) -> TestResponse {
567 self.request().delete(uri).await
568 }
569
570 pub async fn post_json(&self, uri: &str, body: &impl Serialize) -> TestResponse {
572 self.request().post_json(uri, body).await
573 }
574
575 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 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 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 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 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 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 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 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
720pub struct TestRequest<'a> {
722 app: &'a TestApp,
723 headers: Vec<(String, String)>,
724 csrf: bool,
725}
726
727impl TestRequest<'_> {
728 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 pub fn htmx(self) -> Self {
736 self.header("hx-request", "true")
737 }
738
739 pub fn json(self) -> Self {
741 self.header("accept", "application/json")
742 }
743
744 pub fn without_csrf(mut self) -> Self {
746 self.csrf = false;
747 self
748 }
749
750 pub async fn get(self, uri: &str) -> TestResponse {
752 self.send(Method::GET, uri, None, Body::empty()).await
753 }
754
755 pub async fn post(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
757 self.form(Method::POST, uri, form).await
758 }
759
760 pub async fn put(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
762 self.form(Method::PUT, uri, form).await
763 }
764
765 pub async fn patch(self, uri: &str, form: &[(&str, &str)]) -> TestResponse {
767 self.form(Method::PATCH, uri, form).await
768 }
769
770 pub async fn delete(self, uri: &str) -> TestResponse {
772 self.send(Method::DELETE, uri, None, Body::empty()).await
773 }
774
775 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 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 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#[derive(Debug, Clone)]
917pub struct TestResponse {
918 pub status: StatusCode,
920 pub headers: HeaderMap,
922 pub body: Bytes,
924 pub view: Option<String>,
926}
927
928impl TestResponse {
929 pub fn text(&self) -> String {
931 String::from_utf8_lossy(&self.body).into_owned()
932 }
933
934 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 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 #[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 #[track_caller]
974 pub fn assert_ok(&self) -> &Self {
975 self.assert_status(200)
976 }
977
978 #[track_caller]
980 pub fn assert_not_found(&self) -> &Self {
981 self.assert_status(404)
982 }
983
984 #[track_caller]
986 pub fn assert_forbidden(&self) -> &Self {
987 self.assert_status(403)
988 }
989
990 #[track_caller]
992 pub fn assert_unauthorized(&self) -> &Self {
993 self.assert_status(401)
994 }
995
996 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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
1114fn 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
1128pub 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 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 pub async fn call(&mut self, action: &str) -> TestResponse {
1152 self.call_with(action, serde_json::json!([])).await
1153 }
1154
1155 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 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}