Skip to main content

tablo_test/
resource.rs

1//! Typed requests to one resource of a mounted panel: [`ResourceClient`], the [`Submission`] a
2//! form posts, and the [`Reply`] each request answers with.
3//!
4//! The client reads every URL from [`tablo_core::url`] and every form key from the resource's
5//! [`RecordForm::fields`]: a test follows a renamed slug or form key, fails to compile on a
6//! renamed field or action, and panics naming the resource when the panel serves no such page,
7//! instead of asserting on a 404.
8
9use std::{any::type_name, convert::Infallible, marker::PhantomData};
10
11use http::{StatusCode, header::LOCATION};
12use tablo_core::{Action, FormField, Notification, PanelHandle, RecordForm, Resource, url};
13use topcoat::context::Cx;
14
15use crate::{
16    TestClient,
17    protocol::{
18        EmptyTable, ErrorKind, Group, Row, body_string, declined_message, dirty_form, empty_table,
19        field_error, field_error_message, form_body, groups, input_value, notification_in,
20        response_cookies, row_keys, rows, stale_form,
21    },
22};
23
24/// The field enum of `R`'s record form, e.g. `PostFormField`.
25pub type FieldOf<R> = <<R as Resource>::Form as RecordForm>::Field;
26
27/// Requests to `R` in the panel [`TestClient::panel`] names, built by
28/// [`TestClient::resource`].
29///
30/// ```rust,no_run
31/// # use tablo_core::Resource;
32/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns, tablo_core::ModelForm)]
33/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
34/// # struct PostResource;
35/// # impl Resource for PostResource { type Model = Post; type Form = PostForm; }
36/// # async fn create(client: &tablo_test::TestClient<'_>) {
37/// let posts = client.resource::<PostResource>();
38/// let reply = posts
39///     .create()
40///     .set(PostFormField::Title, "Hello")
41///     .submit()
42///     .await;
43/// assert_eq!(reply.status(), 303);
44/// assert_eq!(reply.follow().await.rows().len(), 1);
45/// # }
46/// ```
47pub struct ResourceClient<'c, 'a, R> {
48    client: &'c TestClient<'a>,
49    panel: &'c PanelHandle,
50    resource: PhantomData<fn() -> R>,
51}
52
53impl<'c, 'a, R: Resource> ResourceClient<'c, 'a, R> {
54    pub(crate) fn new(client: &'c TestClient<'a>, panel: &'c PanelHandle) -> Self {
55        Self {
56            client,
57            panel,
58            resource: PhantomData,
59        }
60    }
61
62    /// `R`'s list URL, e.g. `/admin/posts`.
63    ///
64    /// # Panics
65    ///
66    /// When the panel does not mount `R`.
67    pub fn url(&self) -> String {
68        resolved::<R>(url::resource::<R>(&self.cx()), "a list")
69    }
70
71    /// The list page.
72    pub async fn list(&self) -> Reply<'c, 'a, FieldOf<R>> {
73        self.get(self.url()).await
74    }
75
76    /// The list page with `query`, e.g. `"search=ada&page=2"`.
77    pub async fn list_with(&self, query: &str) -> Reply<'c, 'a, FieldOf<R>> {
78        self.get(format!("{}?{query}", self.url())).await
79    }
80
81    /// The create page.
82    ///
83    /// # Panics
84    ///
85    /// When `R` serves no create page.
86    pub async fn create_page(&self) -> Reply<'c, 'a, FieldOf<R>> {
87        self.get(self.create_url()).await
88    }
89
90    /// A submission of the create form, posting only the fields [`Submission::set`] names.
91    ///
92    /// # Panics
93    ///
94    /// When `R` serves no create page.
95    pub fn create(&self) -> Submission<'c, 'a, FieldOf<R>> {
96        Submission::new(self.client, self.create_url(), R::Form::fields())
97    }
98
99    /// `record`'s detail page.
100    ///
101    /// # Panics
102    ///
103    /// When `R` declares no detail page, or its rows have no route.
104    pub async fn view(&self, record: &R::Model) -> Reply<'c, 'a, FieldOf<R>> {
105        let url = resolved::<R>(url::record::<R>(&self.cx(), record), "a detail page");
106        self.get(url).await
107    }
108
109    /// `record`'s edit page.
110    ///
111    /// # Panics
112    ///
113    /// When `R` has no record form, or its rows have no route.
114    pub async fn edit_page(&self, record: &R::Model) -> Reply<'c, 'a, FieldOf<R>> {
115        self.get(self.edit_url(record)).await
116    }
117
118    /// A submission of `record`'s edit form. It posts only the fields [`Submission::set`] names,
119    /// and the edit leaves every other field as stored.
120    ///
121    /// # Panics
122    ///
123    /// When `R` has no record form, or its rows have no route.
124    pub fn edit(&self, record: &R::Model) -> Submission<'c, 'a, FieldOf<R>> {
125        Submission::new(self.client, self.edit_url(record), R::Form::fields())
126    }
127
128    /// A confirmed delete of `record`, as the delete dialog posts it.
129    ///
130    /// # Panics
131    ///
132    /// When `R`'s rows have no route.
133    pub fn delete(&self, record: &R::Model) -> Submission<'c, 'a, Infallible> {
134        let url = resolved::<R>(url::delete::<R>(&self.cx(), record), "a delete route");
135        Submission::new(self.client, url, Vec::new()).set_key(CONFIRM, "1")
136    }
137
138    /// A confirmed run of the action `A` on `record`. An input the action asks for goes in with
139    /// [`Submission::set_key`].
140    ///
141    /// # Panics
142    ///
143    /// When `R` does not declare `A` on a record.
144    pub fn action<A: Action<R>>(&self, record: &R::Model) -> Submission<'c, 'a, Infallible> {
145        let url = url::action::<R, A>(&self.cx(), record).unwrap_or_else(|| {
146            panic!(
147                "`{}` declares no record action `{}`",
148                type_name::<R>(),
149                A::NAME
150            )
151        });
152        Submission::new(self.client, url, Vec::new()).set_key(CONFIRM, "1")
153    }
154
155    /// A confirmed run of the bulk action `A` on the rows keyed `keys`, as [`Reply::row_keys`]
156    /// reads them.
157    ///
158    /// # Panics
159    ///
160    /// When `R` does not declare `A` in its bulk bar.
161    pub fn bulk_action<A: Action<R>>(
162        &self,
163        keys: impl IntoIterator<Item = impl AsRef<str>>,
164    ) -> Submission<'c, 'a, Infallible> {
165        let url = url::bulk_action::<R, A>(&self.cx()).unwrap_or_else(|| {
166            panic!(
167                "`{}` declares no bulk action `{}`",
168                type_name::<R>(),
169                A::NAME
170            )
171        });
172        let ids = keys
173            .into_iter()
174            .map(|key| key.as_ref().to_string())
175            .collect::<Vec<_>>()
176            .join(",");
177        Submission::new(self.client, url, Vec::new())
178            .set_key(IDS, &ids)
179            .set_key(CONFIRM, "1")
180    }
181
182    /// A confirmed run of the bulk action `A` on every record the list's `query` selects, as the
183    /// bulk bar's Select all matching posts it: `query` spells the list's URL query, without the
184    /// leading `?`, such as `f.status=draft`.
185    ///
186    /// # Panics
187    ///
188    /// When `R` does not declare `A` in its bulk bar.
189    pub fn bulk_action_matching<A: Action<R>>(
190        &self,
191        query: &str,
192    ) -> Submission<'c, 'a, Infallible> {
193        let url = url::bulk_action::<R, A>(&self.cx()).unwrap_or_else(|| {
194            panic!(
195                "`{}` declares no bulk action `{}`",
196                type_name::<R>(),
197                A::NAME
198            )
199        });
200        Submission::new(self.client, url, Vec::new())
201            .set_key(MATCHING, query)
202            .set_key(CONFIRM, "1")
203    }
204
205    fn create_url(&self) -> String {
206        resolved::<R>(url::create::<R>(&self.cx()), "a create page")
207    }
208
209    fn edit_url(&self, record: &R::Model) -> String {
210        resolved::<R>(url::edit::<R>(&self.cx(), record), "an edit page")
211    }
212
213    fn cx(&self) -> Cx {
214        self.panel.enter(&self.panel.context())
215    }
216
217    async fn get(&self, url: String) -> Reply<'c, 'a, FieldOf<R>> {
218        Reply::read(self.client, self.client.get(&url).await, R::Form::fields()).await
219    }
220}
221
222/// The key a confirmation dialog posts, which a delete and an action that asks first require.
223const CONFIRM: &str = "confirm";
224
225/// The key the bulk bar posts its selection in, comma-separated.
226const IDS: &str = "ids";
227
228/// The key the bulk bar posts the list's query in when it selects every matching record.
229const MATCHING: &str = "matching";
230
231/// The key the create page's Create & create another button posts.
232const CREATE_ANOTHER: &str = "create_another";
233
234fn resolved<R>(url: Option<String>, what: &str) -> String {
235    url.unwrap_or_else(|| panic!("the panel serves `{}` no {what}", type_name::<R>()))
236}
237
238/// A form post being built: the pairs it sends, keyed by the form's field enum `K`.
239///
240/// A record form's field names its key, so [`set`](Self::set) takes `PostFormField::Title`
241/// rather than `"title"`. [`set_key`](Self::set_key) posts a key no field enum names: an
242/// action's input, one key of an embedded field, a repeater row's key.
243#[must_use = "a submission posts nothing until `submit`"]
244pub struct Submission<'c, 'a, K> {
245    client: &'c TestClient<'a>,
246    url: String,
247    fields: Vec<FormField<K>>,
248    pairs: Vec<(String, String)>,
249}
250
251impl<'c, 'a, K: PartialEq> Submission<'c, 'a, K> {
252    fn new(client: &'c TestClient<'a>, url: String, fields: Vec<FormField<K>>) -> Self {
253        Self {
254            client,
255            url,
256            fields,
257            pairs: Vec::new(),
258        }
259    }
260
261    /// The URL the submission posts to.
262    pub fn url(&self) -> &str {
263        &self.url
264    }
265
266    /// Posts `value` as `field`, replacing any value set before.
267    ///
268    /// # Panics
269    ///
270    /// When `field` binds more than one key, as an embedded field does: set each with
271    /// [`set_key`](Self::set_key).
272    pub fn set(self, field: K, value: &str) -> Self {
273        let key = single_key(&self.fields, &field).to_string();
274        self.set_key(&key, value)
275    }
276
277    /// Posts `value` as one more value of `field`, as a multiple choice posts each pick.
278    ///
279    /// # Panics
280    ///
281    /// When `field` binds more than one key.
282    pub fn add(mut self, field: K, value: &str) -> Self {
283        let key = single_key(&self.fields, &field).to_string();
284        self.pairs.push((key, value.to_string()));
285        self
286    }
287
288    /// Posts `value` under `key`, replacing any value set before.
289    pub fn set_key(mut self, key: &str, value: &str) -> Self {
290        self.pairs.retain(|(kept, _)| kept != key);
291        self.pairs.push((key.to_string(), value.to_string()));
292        self
293    }
294
295    /// Posts as the create page's Create & create another button does, which lands back on a
296    /// fresh create form.
297    pub fn create_another(self) -> Self {
298        self.set_key(CREATE_ANOTHER, "1")
299    }
300
301    /// Posts as the create page a creatable choice's dialog frames does, which lands on the page
302    /// [`Reply::picked`] reads.
303    pub fn picking(mut self) -> Self {
304        let joint = if self.url.contains('?') { '&' } else { '?' };
305        self.url = format!("{}{joint}pick=1", self.url);
306        self
307    }
308
309    /// Posts the pairs with a fresh CSRF token, as [`TestClient::submit`] does.
310    pub async fn submit(self) -> Reply<'c, 'a, K> {
311        let pairs: Vec<(&str, &str)> = self
312            .pairs
313            .iter()
314            .map(|(key, value)| (key.as_str(), value.as_str()))
315            .collect();
316        let response = self.client.submit(&self.url, &form_body(&pairs)).await;
317        Reply::read(self.client, response, self.fields).await
318    }
319}
320
321fn single_key<'f, K: PartialEq>(fields: &'f [FormField<K>], field: &K) -> &'f str {
322    let field = fields
323        .iter()
324        .find(|candidate| candidate.field == *field)
325        .expect("a field enum's variant is one of its form's fields");
326    match field.keys.as_slice() {
327        [key] => key,
328        keys => panic!(
329            "`{}` binds the keys {keys:?}: post each with `set_key`",
330            field.name
331        ),
332    }
333}
334
335/// A response with its body read, keyed by the form's field enum `K` for the queries over its
336/// form.
337///
338/// The queries are those of the free functions this crate exports, over the response's HTML.
339pub struct Reply<'c, 'a, K> {
340    client: &'c TestClient<'a>,
341    fields: Vec<FormField<K>>,
342    status: StatusCode,
343    location: Option<String>,
344    cookies: Vec<(String, String)>,
345    html: String,
346}
347
348impl<'c, 'a, K: PartialEq> Reply<'c, 'a, K> {
349    async fn read(
350        client: &'c TestClient<'a>,
351        response: http::Response<topcoat::router::Body>,
352        fields: Vec<FormField<K>>,
353    ) -> Self {
354        let location = response
355            .headers()
356            .get(LOCATION)
357            .and_then(|value| value.to_str().ok())
358            .map(str::to_string);
359        let cookies = response_cookies(&response);
360        Self {
361            client,
362            fields,
363            status: response.status(),
364            location,
365            cookies,
366            html: body_string(response).await,
367        }
368    }
369
370    pub fn status(&self) -> StatusCode {
371        self.status
372    }
373
374    /// The `Location` a redirect names.
375    pub fn location(&self) -> Option<&str> {
376        self.location.as_deref()
377    }
378
379    /// The `(name, value)` pairs the response's `Set-Cookie` headers carry.
380    pub fn cookies(&self) -> &[(String, String)] {
381        &self.cookies
382    }
383
384    /// The flash notification the response set for the next page.
385    pub fn notification(&self) -> Option<Notification> {
386        notification_in(&self.cookies)
387    }
388
389    pub fn html(&self) -> &str {
390        &self.html
391    }
392
393    /// Follows the redirect, carrying the cookies the response set, as a browser does.
394    ///
395    /// # Panics
396    ///
397    /// When the response names no `Location`.
398    pub async fn follow(&self) -> Reply<'c, 'a, K>
399    where
400        K: Clone,
401    {
402        let location = self
403            .location
404            .as_deref()
405            .unwrap_or_else(|| panic!("a {} response redirects nowhere", self.status));
406        let response = self.client.cookies(&self.cookies).get(location).await;
407        Reply::read(self.client, response, self.fields.clone()).await
408    }
409
410    /// See [`picked`](crate::picked).
411    pub fn picked(&self) -> Option<crate::Picked> {
412        crate::picked(&self.html)
413    }
414
415    /// See [`create_option_links`](crate::create_option_links).
416    pub fn create_option_links(&self) -> Vec<crate::CreateOptionLink> {
417        crate::create_option_links(&self.html)
418    }
419
420    /// See [`rows`](crate::rows).
421    pub fn rows(&self) -> Vec<Row> {
422        rows(&self.html)
423    }
424
425    /// See [`row_keys`](crate::row_keys).
426    pub fn row_keys(&self) -> Vec<String> {
427        row_keys(&self.html)
428    }
429
430    /// See [`groups`](crate::groups).
431    pub fn groups(&self) -> Vec<Group> {
432        groups(&self.html)
433    }
434
435    /// See [`empty_table`](crate::empty_table).
436    pub fn empty_table(&self) -> Option<EmptyTable> {
437        empty_table(&self.html)
438    }
439
440    /// See [`tabs`](crate::tabs).
441    pub fn tabs(&self) -> Vec<crate::Tab> {
442        crate::tabs(&self.html)
443    }
444
445    /// See [`wizard_steps`](crate::wizard_steps).
446    pub fn wizard_steps(&self) -> Vec<crate::WizardStep> {
447        crate::wizard_steps(&self.html)
448    }
449
450    /// See [`sections`](crate::sections).
451    pub fn sections(&self) -> Vec<crate::Section> {
452        crate::sections(&self.html)
453    }
454
455    /// Whether the edit form refused the save as stale; see [`stale_form`](crate::stale_form).
456    pub fn stale(&self) -> bool {
457        stale_form(&self.html)
458    }
459
460    /// The message a record fn declined the save with; see
461    /// [`declined_message`](crate::declined_message).
462    pub fn declined(&self) -> Option<String> {
463        declined_message(&self.html)
464    }
465
466    /// Whether the form renders dirty, guarding what it holds against leaving the page; see
467    /// [`dirty_form`](crate::dirty_form).
468    pub fn dirty(&self) -> bool {
469        dirty_form(&self.html)
470    }
471
472    /// Why `field`'s error slot renders, read under its first key; see
473    /// [`field_error`](crate::field_error).
474    pub fn field_error(&self, field: K) -> Option<ErrorKind> {
475        field_error(&self.html, self.first_key(&field))
476    }
477
478    /// The message `field`'s error slot renders; see
479    /// [`field_error_message`](crate::field_error_message).
480    pub fn field_error_message(&self, field: K) -> Option<String> {
481        field_error_message(&self.html, self.first_key(&field))
482    }
483
484    /// The value `field`'s input renders, as an edit page fills it; see
485    /// [`input_value`](crate::input_value).
486    pub fn input_value(&self, field: K) -> Option<String> {
487        input_value(&self.html, self.first_key(&field))
488    }
489
490    fn first_key(&self, field: &K) -> &str {
491        self.fields
492            .iter()
493            .find(|candidate| candidate.field == *field)
494            .and_then(|field| field.keys.first())
495            .expect("a field enum's variant binds a key of its form")
496    }
497}