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, empty_table, field_error,
19        field_error_message, form_body, groups, input_value, notification_in, response_cookies,
20        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    fn create_url(&self) -> String {
183        resolved::<R>(url::create::<R>(&self.cx()), "a create page")
184    }
185
186    fn edit_url(&self, record: &R::Model) -> String {
187        resolved::<R>(url::edit::<R>(&self.cx(), record), "an edit page")
188    }
189
190    fn cx(&self) -> Cx {
191        self.panel.enter(&self.panel.context())
192    }
193
194    async fn get(&self, url: String) -> Reply<'c, 'a, FieldOf<R>> {
195        Reply::read(self.client, self.client.get(&url).await, R::Form::fields()).await
196    }
197}
198
199/// The key a confirmation dialog posts, which a delete and an action that asks first require.
200const CONFIRM: &str = "confirm";
201
202/// The key the bulk bar posts its selection in, comma-separated.
203const IDS: &str = "ids";
204
205fn resolved<R>(url: Option<String>, what: &str) -> String {
206    url.unwrap_or_else(|| panic!("the panel serves `{}` no {what}", type_name::<R>()))
207}
208
209/// A form post being built: the pairs it sends, keyed by the form's field enum `K`.
210///
211/// A record form's field names its key, so [`set`](Self::set) takes `PostFormField::Title`
212/// rather than `"title"`. [`set_key`](Self::set_key) posts a key no field enum names: an
213/// action's input, one key of an embedded field, a repeater row's key.
214#[must_use = "a submission posts nothing until `submit`"]
215pub struct Submission<'c, 'a, K> {
216    client: &'c TestClient<'a>,
217    url: String,
218    fields: Vec<FormField<K>>,
219    pairs: Vec<(String, String)>,
220}
221
222impl<'c, 'a, K: PartialEq> Submission<'c, 'a, K> {
223    fn new(client: &'c TestClient<'a>, url: String, fields: Vec<FormField<K>>) -> Self {
224        Self {
225            client,
226            url,
227            fields,
228            pairs: Vec::new(),
229        }
230    }
231
232    /// The URL the submission posts to.
233    pub fn url(&self) -> &str {
234        &self.url
235    }
236
237    /// Posts `value` as `field`, replacing any value set before.
238    ///
239    /// # Panics
240    ///
241    /// When `field` binds more than one key, as an embedded field does: set each with
242    /// [`set_key`](Self::set_key).
243    pub fn set(self, field: K, value: &str) -> Self {
244        let key = single_key(&self.fields, &field).to_string();
245        self.set_key(&key, value)
246    }
247
248    /// Posts `value` as one more value of `field`, as a multiple choice posts each pick.
249    ///
250    /// # Panics
251    ///
252    /// When `field` binds more than one key.
253    pub fn add(mut self, field: K, value: &str) -> Self {
254        let key = single_key(&self.fields, &field).to_string();
255        self.pairs.push((key, value.to_string()));
256        self
257    }
258
259    /// Posts `value` under `key`, replacing any value set before.
260    pub fn set_key(mut self, key: &str, value: &str) -> Self {
261        self.pairs.retain(|(kept, _)| kept != key);
262        self.pairs.push((key.to_string(), value.to_string()));
263        self
264    }
265
266    /// Posts the pairs with a fresh CSRF token, as [`TestClient::submit`] does.
267    pub async fn submit(self) -> Reply<'c, 'a, K> {
268        let pairs: Vec<(&str, &str)> = self
269            .pairs
270            .iter()
271            .map(|(key, value)| (key.as_str(), value.as_str()))
272            .collect();
273        let response = self.client.submit(&self.url, &form_body(&pairs)).await;
274        Reply::read(self.client, response, self.fields).await
275    }
276}
277
278fn single_key<'f, K: PartialEq>(fields: &'f [FormField<K>], field: &K) -> &'f str {
279    let field = fields
280        .iter()
281        .find(|candidate| candidate.field == *field)
282        .expect("a field enum's variant is one of its form's fields");
283    match field.keys.as_slice() {
284        [key] => key,
285        keys => panic!(
286            "`{}` binds the keys {keys:?}: post each with `set_key`",
287            field.name
288        ),
289    }
290}
291
292/// A response with its body read, keyed by the form's field enum `K` for the queries over its
293/// form.
294///
295/// The queries are those of the free functions this crate exports, over the response's HTML.
296pub struct Reply<'c, 'a, K> {
297    client: &'c TestClient<'a>,
298    fields: Vec<FormField<K>>,
299    status: StatusCode,
300    location: Option<String>,
301    cookies: Vec<(String, String)>,
302    html: String,
303}
304
305impl<'c, 'a, K: PartialEq> Reply<'c, 'a, K> {
306    async fn read(
307        client: &'c TestClient<'a>,
308        response: http::Response<topcoat::router::Body>,
309        fields: Vec<FormField<K>>,
310    ) -> Self {
311        let location = response
312            .headers()
313            .get(LOCATION)
314            .and_then(|value| value.to_str().ok())
315            .map(str::to_string);
316        let cookies = response_cookies(&response);
317        Self {
318            client,
319            fields,
320            status: response.status(),
321            location,
322            cookies,
323            html: body_string(response).await,
324        }
325    }
326
327    pub fn status(&self) -> StatusCode {
328        self.status
329    }
330
331    /// The `Location` a redirect names.
332    pub fn location(&self) -> Option<&str> {
333        self.location.as_deref()
334    }
335
336    /// The `(name, value)` pairs the response's `Set-Cookie` headers carry.
337    pub fn cookies(&self) -> &[(String, String)] {
338        &self.cookies
339    }
340
341    /// The flash notification the response set for the next page.
342    pub fn notification(&self) -> Option<Notification> {
343        notification_in(&self.cookies)
344    }
345
346    pub fn html(&self) -> &str {
347        &self.html
348    }
349
350    /// Follows the redirect, carrying the cookies the response set, as a browser does.
351    ///
352    /// # Panics
353    ///
354    /// When the response names no `Location`.
355    pub async fn follow(&self) -> Reply<'c, 'a, K>
356    where
357        K: Clone,
358    {
359        let location = self
360            .location
361            .as_deref()
362            .unwrap_or_else(|| panic!("a {} response redirects nowhere", self.status));
363        let response = self.client.cookies(&self.cookies).get(location).await;
364        Reply::read(self.client, response, self.fields.clone()).await
365    }
366
367    /// See [`rows`](crate::rows).
368    pub fn rows(&self) -> Vec<Row> {
369        rows(&self.html)
370    }
371
372    /// See [`row_keys`](crate::row_keys).
373    pub fn row_keys(&self) -> Vec<String> {
374        row_keys(&self.html)
375    }
376
377    /// See [`groups`](crate::groups).
378    pub fn groups(&self) -> Vec<Group> {
379        groups(&self.html)
380    }
381
382    /// See [`empty_table`](crate::empty_table).
383    pub fn empty_table(&self) -> Option<EmptyTable> {
384        empty_table(&self.html)
385    }
386
387    /// See [`tabs`](crate::tabs).
388    pub fn tabs(&self) -> Vec<crate::Tab> {
389        crate::tabs(&self.html)
390    }
391
392    /// Whether the edit form refused the save as stale; see [`stale_form`](crate::stale_form).
393    pub fn stale(&self) -> bool {
394        stale_form(&self.html)
395    }
396
397    /// Why `field`'s error slot renders, read under its first key; see
398    /// [`field_error`](crate::field_error).
399    pub fn field_error(&self, field: K) -> Option<ErrorKind> {
400        field_error(&self.html, self.first_key(&field))
401    }
402
403    /// The message `field`'s error slot renders; see
404    /// [`field_error_message`](crate::field_error_message).
405    pub fn field_error_message(&self, field: K) -> Option<String> {
406        field_error_message(&self.html, self.first_key(&field))
407    }
408
409    /// The value `field`'s input renders, as an edit page fills it; see
410    /// [`input_value`](crate::input_value).
411    pub fn input_value(&self, field: K) -> Option<String> {
412        input_value(&self.html, self.first_key(&field))
413    }
414
415    fn first_key(&self, field: &K) -> &str {
416        self.fields
417            .iter()
418            .find(|candidate| candidate.field == *field)
419            .and_then(|field| field.keys.first())
420            .expect("a field enum's variant binds a key of its form")
421    }
422}