Skip to main content

max / makenotwork

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