Skip to main content

max / makenotwork

13.8 KB · 411 lines History Blame Raw
1 //! Press the buttons on the described Content panel.
2 //!
3 //! `described_screens` presses every screen quasi mounts for itself. This one
4 //! is not mounted: its address carries the project slug, and a nest strips its
5 //! prefix before the inner router sees it, so the panel is served by the same
6 //! axum route that serves the Askama copy and chooses per request. It gets its
7 //! own file for that reason, and the lesson is the one that file records --
8 //! an address is not an answer, so every control here is pressed rather than
9 //! looked up in the route table.
10 //!
11 //! The behaviour under test is makeover-layout `N12`: the three narrowing
12 //! controls are a server round trip, so a filter is a request and its answer is
13 //! a shorter table rather than the same table with rows hidden.
14
15 use crate::harness::{BuildOptions, TestHarness};
16 use makenotwork::config::QuasiScreens;
17 use serde_json::Value;
18
19 /// A creator with one project and three items, viewing the described panel.
20 ///
21 /// Returns the harness, the project slug, and the three item ids in the order
22 /// they were made.
23 async fn catalogue(screens: &str) -> (TestHarness, String, Vec<String>) {
24 let mut h = TestHarness::build(BuildOptions {
25 quasi_screens: QuasiScreens::parse(screens),
26 ..Default::default()
27 })
28 .await;
29
30 let user_id = h
31 .signup("presser", "presser@example.com", "password123")
32 .await;
33 h.grant_creator(user_id).await;
34 h.client.post_form("/logout", "").await;
35 h.login("presser", "password123").await;
36 h.client.fetch_csrf_token().await;
37
38 let resp = h
39 .client
40 .post_form("/api/projects", "slug=a-project&title=A+Project")
41 .await;
42 assert_eq!(resp.status, 200, "project: {}", resp.text);
43 let project: Value = resp.json();
44 let project_id = project["id"].as_str().unwrap().to_owned();
45
46 let mut items = Vec::new();
47 for title in ["Kick Pack", "Snare Pack", "Field Notes"] {
48 let resp = h
49 .client
50 .post_form(
51 &format!("/api/projects/{project_id}/items"),
52 &format!("title={}&item_type=text", title.replace(' ', "+")),
53 )
54 .await;
55 assert_eq!(resp.status, 200, "item: {}", resp.text);
56 let item: Value = resp.json();
57 items.push(item["id"].as_str().unwrap().to_owned());
58 }
59
60 (h, "a-project".to_owned(), items)
61 }
62
63 /// The panel's address under a view.
64 fn panel(query: &str) -> String {
65 if query.is_empty() {
66 "/dashboard/project/a-project/tabs/content".to_owned()
67 } else {
68 format!("/dashboard/project/a-project/tabs/content?{query}")
69 }
70 }
71
72 #[tokio::test]
73 async fn the_switch_decides_which_panel_is_served() {
74 // Off: the Askama partial, with the client-side filter it has always had.
75 let (mut h, _, _) = catalogue("").await;
76 let resp = h.client.htmx_get(&panel("")).await;
77 assert_eq!(resp.status, 200);
78 assert!(resp.text.contains("filterContentTable"), "{}", resp.text);
79 assert!(!resp.text.contains("name=\"ticked\""), "{}", resp.text);
80
81 // On: the described one, and nothing addresses a client-side function.
82 let (mut h, _, _) = catalogue("project_content").await;
83 let resp = h.client.htmx_get(&panel("")).await;
84 assert_eq!(resp.status, 200);
85 assert!(!resp.text.contains("filterContentTable"), "{}", resp.text);
86 assert!(resp.text.contains("name=\"ticked\""), "{}", resp.text);
87 // The answer carries the id it lands on, so the next control can aim at it.
88 assert!(
89 resp.text.contains("id=\"project-content\""),
90 "{}",
91 resp.text
92 );
93 }
94
95 #[tokio::test]
96 async fn a_filter_is_answered_with_a_shorter_table() {
97 let (mut h, _, _) = catalogue("project_content").await;
98
99 let resp = h.client.htmx_get(&panel("q=pack")).await;
100 assert_eq!(resp.status, 200, "{}", resp.text);
101 assert!(resp.text.contains("Kick Pack"), "{}", resp.text);
102 assert!(resp.text.contains("Snare Pack"), "{}", resp.text);
103 // The row is gone rather than hidden, which is the whole of `N12`.
104 assert!(!resp.text.contains("Field Notes"), "{}", resp.text);
105
106 // A filter matching nothing says so and offers the way back.
107 let resp = h.client.htmx_get(&panel("q=nothing+here")).await;
108 assert_eq!(resp.status, 200, "{}", resp.text);
109 assert!(
110 resp.text.contains("No items match these filters."),
111 "{}",
112 resp.text
113 );
114 assert!(resp.text.contains("Clear filters"), "{}", resp.text);
115 }
116
117 #[tokio::test]
118 async fn a_narrowed_panel_is_not_cached_as_the_whole_table() {
119 // The ETag is the project's cache generation and does not move when a
120 // filter does, so a narrowed answer must not carry one: a browser holding
121 // the unfiltered table under that tag would answer the next filter itself.
122 let (mut h, _, _) = catalogue("project_content").await;
123
124 let whole = h.client.htmx_get(&panel("")).await;
125 assert!(whole.headers.contains_key("etag"), "{:?}", whole.headers);
126
127 let narrowed = h.client.htmx_get(&panel("q=pack")).await;
128 assert!(
129 !narrowed.headers.contains_key("etag"),
130 "{:?}",
131 narrowed.headers
132 );
133 }
134
135 #[tokio::test]
136 async fn an_address_naming_a_column_no_heading_carries_is_refused() {
137 let (mut h, _, _) = catalogue("project_content").await;
138 let resp = h.client.htmx_get(&panel("sort=urgency")).await;
139 assert_eq!(resp.status, 404, "{}", resp.text);
140 }
141
142 #[tokio::test]
143 async fn a_bulk_verb_writes_the_ticked_rows_and_answers_the_panel() {
144 let (mut h, _, items) = catalogue("project_content").await;
145
146 let ticked = format!("ticked={}&ticked={}", items[0], items[1]);
147 let resp = h
148 .client
149 .post_form(
150 "/dashboard/project/a-project/tabs/content/bulk/publish",
151 &ticked,
152 )
153 .await;
154 assert_eq!(resp.status, 200, "{}", resp.text);
155 // The answer is the panel, which is what makes the write visible without
156 // the page acting on a toast.
157 assert!(
158 resp.text.contains("id=\"project-content\""),
159 "{}",
160 resp.text
161 );
162
163 let public: Vec<bool> = sqlx::query_scalar("SELECT is_public FROM items WHERE id = ANY($1)")
164 .bind(
165 items[..2]
166 .iter()
167 .map(|id| id.parse::<uuid::Uuid>().unwrap())
168 .collect::<Vec<_>>(),
169 )
170 .fetch_all(&h.db)
171 .await
172 .unwrap();
173 assert_eq!(public, [true, true]);
174 }
175
176 #[tokio::test]
177 async fn a_bulk_verb_comes_back_under_the_filter_it_was_pressed_on() {
178 let (mut h, _, items) = catalogue("project_content").await;
179
180 let resp = h
181 .client
182 .post_form(
183 "/dashboard/project/a-project/tabs/content/bulk/publish?q=pack",
184 &format!("ticked={}", items[0]),
185 )
186 .await;
187 assert_eq!(resp.status, 200, "{}", resp.text);
188 assert!(resp.text.contains("Kick Pack"), "{}", resp.text);
189 assert!(!resp.text.contains("Field Notes"), "{}", resp.text);
190 }
191
192 #[tokio::test]
193 async fn a_write_over_nothing_answers_the_panel_rather_than_erroring() {
194 // Every renderer draws a control over an empty selection as disabled, so
195 // arriving here with no ticks is a hand-typed request. It is not an error.
196 let (mut h, _, _) = catalogue("project_content").await;
197
198 let resp = h
199 .client
200 .post_form("/dashboard/project/a-project/tabs/content/bulk/delete", "")
201 .await;
202 assert_eq!(resp.status, 200, "{}", resp.text);
203 assert!(resp.text.contains("Kick Pack"), "{}", resp.text);
204 }
205
206 #[tokio::test]
207 async fn a_verb_the_panel_does_not_offer_is_not_a_route_into_the_catalogue() {
208 let (mut h, _, items) = catalogue("project_content").await;
209 let resp = h
210 .client
211 .post_form(
212 "/dashboard/project/a-project/tabs/content/bulk/incinerate",
213 &format!("ticked={}", items[0]),
214 )
215 .await;
216 assert_eq!(resp.status, 404, "{}", resp.text);
217 }
218
219 #[tokio::test]
220 async fn the_price_and_tag_verbs_carry_the_value_the_control_asked_for() {
221 let (mut h, _, items) = catalogue("project_content").await;
222
223 let resp = h
224 .client
225 .post_form(
226 "/dashboard/project/a-project/tabs/content/bulk/price",
227 &format!("ticked={}&price_dollars=7.50", items[0]),
228 )
229 .await;
230 assert_eq!(resp.status, 200, "{}", resp.text);
231
232 let cents: i32 = sqlx::query_scalar("SELECT price_cents FROM items WHERE id = $1")
233 .bind(items[0].parse::<uuid::Uuid>().unwrap())
234 .fetch_one(&h.db)
235 .await
236 .unwrap();
237 assert_eq!(cents, 750);
238 }
239
240 #[tokio::test]
241 async fn a_rename_lands_and_the_panel_says_the_new_name() {
242 let (mut h, _, items) = catalogue("project_content").await;
243
244 let resp = h
245 .client
246 .post_form(
247 &format!(
248 "/dashboard/project/a-project/tabs/content/rename/{}",
249 items[0]
250 ),
251 "title=Kick+Pack+II",
252 )
253 .await;
254 assert_eq!(resp.status, 200, "{}", resp.text);
255 assert!(resp.text.contains("Kick Pack II"), "{}", resp.text);
256 }
257
258 #[tokio::test]
259 async fn a_row_publish_is_the_bulk_verb_over_a_set_of_one() {
260 let (mut h, _, items) = catalogue("project_content").await;
261
262 let resp = h
263 .client
264 .post_form(
265 &format!(
266 "/dashboard/project/a-project/tabs/content/publish/{}",
267 items[2]
268 ),
269 "",
270 )
271 .await;
272 assert_eq!(resp.status, 200, "{}", resp.text);
273
274 let public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1")
275 .bind(items[2].parse::<uuid::Uuid>().unwrap())
276 .fetch_one(&h.db)
277 .await
278 .unwrap();
279 assert!(public);
280 }
281
282 #[tokio::test]
283 async fn an_arrow_moves_a_row_within_the_projects_own_order() {
284 let (mut h, _, items) = catalogue("project_content").await;
285
286 let before: Vec<uuid::Uuid> = sqlx::query_scalar(
287 "SELECT id FROM items WHERE deleted_at IS NULL ORDER BY sort_order, created_at DESC",
288 )
289 .fetch_all(&h.db)
290 .await
291 .unwrap();
292
293 let last = before.last().unwrap().to_string();
294 let resp = h
295 .client
296 .post_form(
297 &format!("/dashboard/project/a-project/tabs/content/move/{last}"),
298 "direction=up",
299 )
300 .await;
301 assert_eq!(resp.status, 200, "{}", resp.text);
302
303 let after: Vec<uuid::Uuid> = sqlx::query_scalar(
304 "SELECT id FROM items WHERE deleted_at IS NULL ORDER BY sort_order, created_at DESC",
305 )
306 .fetch_all(&h.db)
307 .await
308 .unwrap();
309 assert_ne!(before, after, "the arrow moved nothing");
310 assert_eq!(after.len(), items.len());
311 }
312
313 #[tokio::test]
314 async fn a_deleted_item_can_be_restored_from_the_panel_it_left() {
315 let (mut h, _, items) = catalogue("project_content").await;
316
317 let resp = h
318 .client
319 .post_form(
320 "/dashboard/project/a-project/tabs/content/bulk/delete",
321 &format!("ticked={}", items[0]),
322 )
323 .await;
324 assert_eq!(resp.status, 200, "{}", resp.text);
325 assert!(resp.text.contains("Recently Deleted (1)"), "{}", resp.text);
326
327 let resp = h
328 .client
329 .post_form(
330 &format!(
331 "/dashboard/project/a-project/tabs/content/restore/{}",
332 items[0]
333 ),
334 "",
335 )
336 .await;
337 assert_eq!(resp.status, 200, "{}", resp.text);
338 assert!(!resp.text.contains("Recently Deleted"), "{}", resp.text);
339
340 let deleted: Option<chrono::DateTime<chrono::Utc>> =
341 sqlx::query_scalar("SELECT deleted_at FROM items WHERE id = $1")
342 .bind(items[0].parse::<uuid::Uuid>().unwrap())
343 .fetch_one(&h.db)
344 .await
345 .unwrap();
346 assert!(deleted.is_none());
347 }
348
349 #[tokio::test]
350 async fn another_creators_project_is_not_reachable_through_a_described_write() {
351 let (mut h, _, items) = catalogue("project_content").await;
352
353 h.client.post_form("/logout", "").await;
354 let other = h
355 .signup("stranger", "stranger@example.com", "password123")
356 .await;
357 h.grant_creator(other).await;
358 h.client.post_form("/logout", "").await;
359 h.login("stranger", "password123").await;
360 h.client.fetch_csrf_token().await;
361
362 let resp = h
363 .client
364 .post_form(
365 "/dashboard/project/a-project/tabs/content/bulk/delete",
366 &format!("ticked={}", items[0]),
367 )
368 .await;
369 assert_eq!(resp.status, 404, "{}", resp.text);
370
371 let deleted: Option<chrono::DateTime<chrono::Utc>> =
372 sqlx::query_scalar("SELECT deleted_at FROM items WHERE id = $1")
373 .bind(items[0].parse::<uuid::Uuid>().unwrap())
374 .fetch_one(&h.db)
375 .await
376 .unwrap();
377 assert!(deleted.is_none(), "a stranger deleted somebody's item");
378 }
379
380 #[tokio::test]
381 async fn the_described_writes_are_not_registered_when_the_panel_is_not_served() {
382 // The switch is what reverts a conversion, so it has to take the writes with
383 // it: a route answering the described panel while the page serves the Askama
384 // one would swap markup the page cannot use.
385 let (mut h, _, items) = catalogue("").await;
386 let resp = h
387 .client
388 .post_form(
389 "/dashboard/project/a-project/tabs/content/bulk/publish",
390 &format!("ticked={}", items[0]),
391 )
392 .await;
393 assert_eq!(resp.status, 404, "{}", resp.text);
394 }
395
396 #[tokio::test]
397 async fn an_open_bundle_list_survives_the_address() {
398 // `open` carries several ids in one param, space-separated, so it is the one
399 // view member whose encoding could be lost between the emitter and the
400 // extractor. Nothing here has children, so what is under test is that the
401 // address parses and answers rather than refusing.
402 let (mut h, _, items) = catalogue("project_content").await;
403
404 let resp = h
405 .client
406 .htmx_get(&panel(&format!("open={}%20{}", items[0], items[1])))
407 .await;
408 assert_eq!(resp.status, 200, "{}", resp.text);
409 assert!(resp.text.contains("Kick Pack"), "{}", resp.text);
410 }
411