//! Press the buttons on the described Content panel. //! //! `described_screens` presses every screen quasi mounts for itself. This one //! is not mounted: its address carries the project slug, and a nest strips its //! prefix before the inner router sees it, so the panel is served by the same //! axum route that serves the Askama copy and chooses per request. It gets its //! own file for that reason, and the lesson is the one that file records -- //! an address is not an answer, so every control here is pressed rather than //! looked up in the route table. //! //! The behaviour under test is makeover-layout `N12`: the three narrowing //! controls are a server round trip, so a filter is a request and its answer is //! a shorter table rather than the same table with rows hidden. use crate::harness::{BuildOptions, TestHarness}; use serde_json::Value; /// A creator with one project and three items, viewing the described panel. /// /// Returns the harness, the project slug, and the three item ids in the order /// they were made. async fn catalogue() -> (TestHarness, String, Vec) { let mut h = TestHarness::build(BuildOptions { ..Default::default() }) .await; let user_id = h .signup("presser", "presser@example.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("presser", "password123").await; h.client.fetch_csrf_token().await; let resp = h .client .post_form("/api/projects", "slug=a-project&title=A+Project") .await; assert_eq!(resp.status, 200, "project: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_owned(); let mut items = Vec::new(); for title in ["Kick Pack", "Snare Pack", "Field Notes"] { let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), &format!("title={}&item_type=text", title.replace(' ', "+")), ) .await; assert_eq!(resp.status, 200, "item: {}", resp.text); let item: Value = resp.json(); items.push(item["id"].as_str().unwrap().to_owned()); } (h, "a-project".to_owned(), items) } /// The panel's address under a view. fn panel(query: &str) -> String { if query.is_empty() { "/dashboard/project/a-project/tabs/content".to_owned() } else { format!("/dashboard/project/a-project/tabs/content?{query}") } } #[tokio::test] async fn the_described_panel_is_what_is_served() { // Was `the_switch_decides_which_panel_is_served`, and asserted both sides: // with `QUASI_SCREENS` off, the Askama partial and its client-side // `filterContentTable`; with it on, the described panel. `64b33b26` deleted // the switch and the Askama partial, so only this half has a subject. The // negative assertion below is the one that still earns its keep: it is what // would catch the client-side filter coming back. let (mut h, _, _) = catalogue().await; let resp = h.client.htmx_get(&panel("")).await; assert_eq!(resp.status, 200); assert!(!resp.text.contains("filterContentTable"), "{}", resp.text); assert!(resp.text.contains("name=\"ticked\""), "{}", resp.text); // The answer carries the id it lands on, so the next control can aim at it. assert!( resp.text.contains("id=\"project-content\""), "{}", resp.text ); } #[tokio::test] async fn a_filter_is_answered_with_a_shorter_table() { let (mut h, _, _) = catalogue().await; let resp = h.client.htmx_get(&panel("q=pack")).await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Kick Pack"), "{}", resp.text); assert!(resp.text.contains("Snare Pack"), "{}", resp.text); // The row is gone rather than hidden, which is the whole of `N12`. assert!(!resp.text.contains("Field Notes"), "{}", resp.text); // A filter matching nothing says so and offers the way back. let resp = h.client.htmx_get(&panel("q=nothing+here")).await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("No items match these filters."), "{}", resp.text ); assert!(resp.text.contains("Clear filters"), "{}", resp.text); } #[tokio::test] async fn a_narrowed_panel_is_not_cached_as_the_whole_table() { // The ETag is the project's cache generation and does not move when a // filter does, so a narrowed answer must not carry one: a browser holding // the unfiltered table under that tag would answer the next filter itself. let (mut h, _, _) = catalogue().await; let whole = h.client.htmx_get(&panel("")).await; assert!(whole.headers.contains_key("etag"), "{:?}", whole.headers); let narrowed = h.client.htmx_get(&panel("q=pack")).await; assert!( !narrowed.headers.contains_key("etag"), "{:?}", narrowed.headers ); } #[tokio::test] async fn an_address_naming_a_column_no_heading_carries_is_refused() { let (mut h, _, _) = catalogue().await; let resp = h.client.htmx_get(&panel("sort=urgency")).await; assert_eq!(resp.status, 404, "{}", resp.text); } #[tokio::test] async fn a_bulk_verb_writes_the_ticked_rows_and_answers_the_panel() { let (mut h, _, items) = catalogue().await; let ticked = format!("ticked={}&ticked={}", items[0], items[1]); let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/publish", &ticked, ) .await; assert_eq!(resp.status, 200, "{}", resp.text); // The answer is the panel, which is what makes the write visible without // the page acting on a toast. assert!( resp.text.contains("id=\"project-content\""), "{}", resp.text ); let public: Vec = sqlx::query_scalar("SELECT is_public FROM items WHERE id = ANY($1)") .bind( items[..2] .iter() .map(|id| id.parse::().unwrap()) .collect::>(), ) .fetch_all(&h.db) .await .unwrap(); assert_eq!(public, [true, true]); } #[tokio::test] async fn a_bulk_verb_comes_back_under_the_filter_it_was_pressed_on() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/publish?q=pack", &format!("ticked={}", items[0]), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Kick Pack"), "{}", resp.text); assert!(!resp.text.contains("Field Notes"), "{}", resp.text); } #[tokio::test] async fn a_write_over_nothing_answers_the_panel_rather_than_erroring() { // Every renderer draws a control over an empty selection as disabled, so // arriving here with no ticks is a hand-typed request. It is not an error. let (mut h, _, _) = catalogue().await; let resp = h .client .post_form("/dashboard/project/a-project/tabs/content/bulk/delete", "") .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Kick Pack"), "{}", resp.text); } #[tokio::test] async fn a_verb_the_panel_does_not_offer_is_not_a_route_into_the_catalogue() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/incinerate", &format!("ticked={}", items[0]), ) .await; assert_eq!(resp.status, 404, "{}", resp.text); } #[tokio::test] async fn the_price_and_tag_verbs_carry_the_value_the_control_asked_for() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/price", &format!("ticked={}&price_dollars=7.50", items[0]), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let cents: i32 = sqlx::query_scalar("SELECT price_cents FROM items WHERE id = $1") .bind(items[0].parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(cents, 750); } #[tokio::test] async fn a_rename_lands_and_the_panel_says_the_new_name() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( &format!( "/dashboard/project/a-project/tabs/content/rename/{}", items[0] ), "title=Kick+Pack+II", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Kick Pack II"), "{}", resp.text); } #[tokio::test] async fn a_row_publish_is_the_bulk_verb_over_a_set_of_one() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( &format!( "/dashboard/project/a-project/tabs/content/publish/{}", items[2] ), "", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1") .bind(items[2].parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert!(public); } #[tokio::test] async fn an_arrow_moves_a_row_within_the_projects_own_order() { let (mut h, _, items) = catalogue().await; let before: Vec = sqlx::query_scalar( "SELECT id FROM items WHERE deleted_at IS NULL ORDER BY sort_order, created_at DESC", ) .fetch_all(&h.db) .await .unwrap(); let last = before.last().unwrap().to_string(); let resp = h .client .post_form( &format!("/dashboard/project/a-project/tabs/content/move/{last}"), "direction=up", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let after: Vec = sqlx::query_scalar( "SELECT id FROM items WHERE deleted_at IS NULL ORDER BY sort_order, created_at DESC", ) .fetch_all(&h.db) .await .unwrap(); assert_ne!(before, after, "the arrow moved nothing"); assert_eq!(after.len(), items.len()); } #[tokio::test] async fn a_deleted_item_can_be_restored_from_the_panel_it_left() { let (mut h, _, items) = catalogue().await; let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/delete", &format!("ticked={}", items[0]), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Recently Deleted (1)"), "{}", resp.text); let resp = h .client .post_form( &format!( "/dashboard/project/a-project/tabs/content/restore/{}", items[0] ), "", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(!resp.text.contains("Recently Deleted"), "{}", resp.text); let deleted: Option> = sqlx::query_scalar("SELECT deleted_at FROM items WHERE id = $1") .bind(items[0].parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert!(deleted.is_none()); } #[tokio::test] async fn another_creators_project_is_not_reachable_through_a_described_write() { let (mut h, _, items) = catalogue().await; h.client.post_form("/logout", "").await; let other = h .signup("stranger", "stranger@example.com", "password123") .await; h.grant_creator(other).await; h.client.post_form("/logout", "").await; h.login("stranger", "password123").await; h.client.fetch_csrf_token().await; let resp = h .client .post_form( "/dashboard/project/a-project/tabs/content/bulk/delete", &format!("ticked={}", items[0]), ) .await; assert_eq!(resp.status, 404, "{}", resp.text); let deleted: Option> = sqlx::query_scalar("SELECT deleted_at FROM items WHERE id = $1") .bind(items[0].parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert!(deleted.is_none(), "a stranger deleted somebody's item"); } // `the_described_writes_are_not_registered_when_the_panel_is_not_served` was // here until 2026-08-26. It asserted a bulk verb answered 404 with the switch // off, because the switch had to take the writes with it or a route would swap // markup the Askama page could not use. `64b33b26` deleted the switch, so the // writes are registered unconditionally and there is no "not served" state to // put them in. // // What it guarded is covered by the verb tests below, which post to these // routes and assert on the panel that comes back: a write that stopped being // registered would 404 there instead. #[tokio::test] async fn an_open_bundle_list_survives_the_address() { // `open` carries several ids in one param, space-separated, so it is the one // view member whose encoding could be lost between the emitter and the // extractor. Nothing here has children, so what is under test is that the // address parses and answers rather than refusing. let (mut h, _, items) = catalogue().await; let resp = h .client .htmx_get(&panel(&format!("open={}%20{}", items[0], items[1]))) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("Kick Pack"), "{}", resp.text); }