//! Discover page + search: faceted listings, filters, suggestions, privacy. //! //! Covers the four discover endpoints: //! - GET /discover (full page, faceted) //! - GET /discover/results (HTMX results partial) //! - GET /discover/suggestions (JSON search suggestions) //! - GET /discover/tags (tag tree partial) //! //! Privacy invariants that must hold across all of these (verified per-test): //! drafts (is_public=false), unlisted items, sandbox-user items, //! quarantined files, and soft-deleted items are NEVER returned. use crate::harness::TestHarness; /// Create a creator with a published, listed item that satisfies all five /// "shows on discover" preconditions. Returns (user_id, item_id). /// /// The discover query requires `is_public=true AND listed=true AND /// p.is_public=true AND scan_status!='quarantined' AND deleted_at IS NULL /// AND u.is_sandbox=false`. We set every one of these via direct SQL /// rather than the API so the test doesn't depend on the publish flow's /// internals (validation rules, scheduled-publish gates, etc). async fn make_discoverable_item( h: &mut TestHarness, username: &str, title: &str, item_type: &str, ) -> (String, String) { let setup = h.create_creator_with_item(username, item_type, 1000).await; sqlx::query( "UPDATE items SET title = $1, is_public = true, listed = true, \ scan_status = 'clean', deleted_at = NULL WHERE id = $2::uuid", ) .bind(title) .bind(&setup.item_id) .execute(&h.db) .await .expect("update item for discover"); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .expect("publish project for discover"); (setup.user_id.to_string(), setup.item_id) } /// The denormalized `projects.item_count` (read by discover instead of a /// COUNT-over-the-catalog) is maintained by a trigger on `items`. Pin that it /// tracks the active-item lifecycle exactly: list/unlist/soft-delete each move /// the cached count (ultra-fuzz Run 11 Perf SER-3). async fn project_item_count(h: &TestHarness, project_id: &str) -> i32 { sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1::uuid") .bind(project_id) .fetch_one(&h.db) .await .expect("read item_count") } #[tokio::test] async fn project_item_count_tracks_active_item_lifecycle() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("counttrack", "audio", 1000) .await; let pid = setup.project_id.clone(); // Active: public + listed + not deleted. sqlx::query( "UPDATE items SET is_public = true, listed = true, deleted_at = NULL WHERE id = $1::uuid", ) .bind(&setup.item_id) .execute(&h.db) .await .expect("activate item"); assert_eq!(project_item_count(&h, &pid).await, 1, "active item counts"); // Unlist -> drops out of the active set. sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .expect("unlist"); assert_eq!( project_item_count(&h, &pid).await, 0, "unlisted item not counted" ); // Relist -> back in. sqlx::query("UPDATE items SET listed = true WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .expect("relist"); assert_eq!( project_item_count(&h, &pid).await, 1, "relisted item counts again" ); // Soft-delete -> drops out. sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .expect("soft delete"); assert_eq!( project_item_count(&h, &pid).await, 0, "soft-deleted item not counted" ); } #[tokio::test] async fn discover_page_renders_for_anonymous_visitor() { let mut h = TestHarness::new().await; let resp = h.client.get("/discover").await; assert_eq!( resp.status, 200, "GET /discover: {} {}", resp.status, resp.text ); // Must contain the discover landmark, used by HTMX swaps + screen readers. assert!( resp.text.contains("discover") || resp.text.to_lowercase().contains("discover"), "Discover page should contain 'discover' marker" ); } #[tokio::test] async fn search_finds_published_item_by_title() { let mut h = TestHarness::new().await; let (_creator, _item) = make_discoverable_item(&mut h, "creator1", "Searchable Widget", "digital").await; // Default mode is "projects", items mode is opt-in via `?mode=items`. let resp = h.client.get("/discover?mode=items&q=Searchable").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("Searchable Widget"), "Search by title should find the item; body did not contain it" ); } /// The relevance expression has always scored `similarity(description) * 0.5`, /// but the match clause only looked at the title, an item whose description was /// a perfect match ranked as if it matched and was then never returned. #[tokio::test] async fn search_finds_published_item_by_description() { let mut h = TestHarness::new().await; let (_creator, item_id) = make_discoverable_item(&mut h, "desccreator", "Unrelated Title", "digital").await; sqlx::query("UPDATE items SET description = 'A hand-built theremin kit' WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=theremin").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("Unrelated Title"), "Search should match on description, not title only" ); } /// The 0.5 weighting on description similarity is the reason the relevance /// expression is written the way it is. Widening the match clause must not /// flatten that ordering. #[tokio::test] async fn title_match_outranks_description_only_match() { let mut h = TestHarness::new().await; let (_, desc_item) = make_discoverable_item(&mut h, "rankdesc", "Something Else", "digital").await; sqlx::query( "UPDATE items SET description = 'mentions theremin in passing' WHERE id = $1::uuid", ) .bind(&desc_item) .execute(&h.db) .await .unwrap(); let (_, title_item) = make_discoverable_item(&mut h, "ranktitle", "Theremin", "digital").await; sqlx::query("UPDATE items SET description = 'no match here' WHERE id = $1::uuid") .bind(&title_item) .execute(&h.db) .await .unwrap(); // No explicit sort: a search term makes match_score DESC the default order. let resp = h.client.get("/discover?mode=items&q=theremin").await; assert_eq!(resp.status, 200, "{}", resp.text); let title_pos = resp .text .find("Theremin") .expect("title match should be returned"); let desc_pos = resp .text .find("Something Else") .expect("description match should be returned"); assert!( title_pos < desc_pos, "Title match must outrank a description-only match (title at {title_pos}, description at {desc_pos})" ); } #[tokio::test] async fn search_does_not_leak_draft_items() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("draftcreator", "audio", 1000) .await; // Both items.is_public and projects.is_public default to TRUE (see // migrations/001_initial_schema.sql lines 71, 86), "draft" means the // creator explicitly toggled `is_public=false`. Set it directly. sqlx::query( "UPDATE items SET title = 'Sneaky Draft Title', is_public = false WHERE id = $1::uuid", ) .bind(&setup.item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=Sneaky").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( !resp.text.contains("Sneaky Draft Title"), "Discover must not return draft (is_public=false) items" ); } #[tokio::test] async fn search_excludes_quarantined_items() { let mut h = TestHarness::new().await; let (_, item_id) = make_discoverable_item(&mut h, "quarcreator", "Quarantine Sentinel", "digital").await; // Manually flip scan_status to quarantined, discover should drop it. sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=Quarantine").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( !resp.text.contains("Quarantine Sentinel"), "Quarantined items must not surface in discover" ); } #[tokio::test] async fn search_excludes_unlisted_items() { let mut h = TestHarness::new().await; let (_, item_id) = make_discoverable_item(&mut h, "unlistedcreator", "Unlisted Marker", "digital").await; // `listed = false` is the "public via direct URL but not in discover" mode. sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=Unlisted").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( !resp.text.contains("Unlisted Marker"), "listed=false items must not surface in discover" ); } #[tokio::test] async fn search_excludes_sandbox_users() { let mut h = TestHarness::new().await; let (creator_id, _item) = make_discoverable_item(&mut h, "sandboxcreator", "Sandbox Hidden", "digital").await; sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid") .bind(&creator_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=Sandbox").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( !resp.text.contains("Sandbox Hidden"), "Sandbox users' items must not surface in discover" ); } #[tokio::test] async fn search_excludes_soft_deleted_items() { let mut h = TestHarness::new().await; let (_, item_id) = make_discoverable_item(&mut h, "delcreator", "Deleted Marker", "digital").await; // Soft-delete keeps the row but sets deleted_at; discover must drop it. sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/discover?mode=items&q=Deleted").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( !resp.text.contains("Deleted Marker"), "Soft-deleted items must not surface in discover" ); } #[tokio::test] async fn item_type_filter_narrows_results() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "audiocreator", "AudioOnly Title", "audio").await; h.client.post_form("/logout", "").await; make_discoverable_item(&mut h, "softcreator", "SoftwareOnly Title", "digital").await; // Filter to audio only, software item must be absent. let resp = h.client.get("/discover?mode=items&item_type=audio").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("AudioOnly Title"), "Audio item should appear" ); assert!( !resp.text.contains("SoftwareOnly Title"), "Software item must NOT appear under item_type=audio" ); } #[tokio::test] async fn projects_mode_lists_projects_not_items() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("projmode", "digital", 1000) .await; // Rename project to a distinctive title. h.client .put_json( &format!("/api/projects/{}", setup.project_id), r#"{"title":"Discover Project Mode","is_public":true}"#, ) .await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; let resp = h.client.get("/discover?mode=projects").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("Discover Project Mode"), "Project should appear in projects mode" ); } #[tokio::test] async fn results_partial_is_htmx_swappable() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "partialcreator", "Partial Visible", "digital").await; // /discover/results returns the inner partial used by HTMX filter swaps. // It must NOT include the full page chrome (header, footer, ). let resp = h.client.htmx_get("/discover/results?mode=items").await; assert_eq!(resp.status, 200, "GET /discover/results: {}", resp.status); assert!(resp.text.contains("Partial Visible")); assert!( !resp.text.contains("`, must parse as JSON array. let parsed: serde_json::Value = resp.json(); assert!( parsed.is_array(), "Suggestions response must be a JSON array" ); } #[tokio::test] async fn empty_search_query_returns_all_listed_items() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "emptyq1", "First Empty Q", "digital").await; h.client.post_form("/logout", "").await; make_discoverable_item(&mut h, "emptyq2", "Second Empty Q", "digital").await; // q= (just spaces) should be treated as "no filter", the route strips // whitespace-only q values before applying the search filter. let resp = h.client.get("/discover?mode=items&q=%20%20").await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("First Empty Q"), "Whitespace q should show all items" ); assert!(resp.text.contains("Second Empty Q")); } #[tokio::test] async fn tag_tree_endpoint_renders() { let mut h = TestHarness::new().await; // No items needed, the tag tree should render even when empty so the // filter UI is always available. let resp = h.client.get("/discover/tags").await; assert_eq!(resp.status, 200, "GET /discover/tags: {}", resp.status); } // Facet-count correctness // // The sidebar counts are a separate query family from the result set, and they // had drifted from it in three ways. These pin each one. Note that every other // discover test in this file passed while all three were live: nothing here // exercised a facet count, so the bugs were invisible to the suite. /// Attach `slug` to `item_id`, creating the tag and its dot-path ancestry. async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) { let mut parent: Option = None; let segments: Vec<&str> = slug.split('.').collect(); for depth in 1..=segments.len() { let path = segments[..depth].join("."); let name = segments[depth - 1]; let id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \ ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id", ) .bind(name) .bind(&path) .bind(parent) .fetch_one(&h.db) .await .expect("upsert tag"); parent = Some(id); } sqlx::query( "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING", ) .bind(item_id) .bind(parent.expect("leaf tag id")) .execute(&h.db) .await .expect("attach tag"); } fn filters_for(tags: &[String]) -> makenotwork::db::discover::DiscoverFilters<'_> { makenotwork::db::discover::DiscoverFilters { search: None, item_types: &[], tags, min_price: None, max_price: None, sort_by: None, ai_tier: None, } } /// A tag filter matches the whole subtree, not just direct children. /// /// The result query has always matched descendants via `tags.path LIKE /// 'prefix.%'`, but the facet counts used `parent_id = (SELECT id ... )`, which /// stops at one level. An item tagged `audio.genre.electronic` therefore /// appeared in the results for `?tag=audio` while contributing 0 to the /// sidebar counts beside them. #[tokio::test] async fn facet_counts_match_the_whole_tag_subtree() { let mut h = TestHarness::new().await; let (_, item_id) = make_discoverable_item(&mut h, "facetdepth", "Deep Tagged", "audio").await; tag_item(&h, &item_id, "audio.genre.electronic").await; // `audio` is two levels above the item's tag. let counts = makenotwork::db::discover::get_item_type_counts( &h.db, &filters_for(&["audio".to_string()]), ) .await .expect("item type counts"); let total: i64 = counts.iter().map(|c| c.count).sum(); assert_eq!( total, 1, "grandchild tag audio.genre.electronic must count under ?tag=audio \ (was 0 while the facets used parent_id instead of path)" ); } /// The AI-tier filter constrains the facet counts. /// /// `ai_tier` was applied to the result set but to none of the four facet /// queries, so selecting "Handmade only" narrowed the listing while the sidebar /// kept reporting counts for the unfiltered catalog. /// Facet counts and results share `ITEM_SEARCH_CLAUSE`, that parity was the /// point of commit a668df6f. Widening the clause to match descriptions must /// therefore widen the counts by the same amount, or the sidebar would report a /// zero next to a facet the results list is populating. #[tokio::test] async fn facet_counts_widen_with_description_matches() { let mut h = TestHarness::new().await; let (_, item_id) = make_discoverable_item(&mut h, "facetdesc", "Nothing Relevant", "audio").await; sqlx::query("UPDATE items SET description = 'a hand-built theremin kit' WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .expect("set description"); let mut filters = filters_for(&[]); let term = String::from("theremin"); filters.search = Some(&term); let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters) .await .expect("item type counts"); let total: i64 = counts.iter().map(|c| c.count).sum(); assert_eq!( total, 1, "facet counts must count a description-only match, matching the results query" ); } #[tokio::test] async fn facet_counts_apply_the_ai_tier_filter() { let mut h = TestHarness::new().await; let (_, handmade) = make_discoverable_item(&mut h, "aihand", "Handmade One", "audio").await; let (_, assisted) = make_discoverable_item(&mut h, "aiasst", "Assisted One", "audio").await; sqlx::query("UPDATE items SET ai_tier = 'handmade' WHERE id = $1::uuid") .bind(&handmade) .execute(&h.db) .await .expect("set handmade"); sqlx::query("UPDATE items SET ai_tier = 'assisted' WHERE id = $1::uuid") .bind(&assisted) .execute(&h.db) .await .expect("set assisted"); let mut filters = filters_for(&[]); filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly); let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters) .await .expect("item type counts"); let total: i64 = counts.iter().map(|c| c.count).sum(); assert_eq!( total, 1, "handmade_only must exclude the assisted item from the facet counts (was 2)" ); } /// The price filter survives a second interaction. /// /// The number inputs carry `class="discover-filter"`, so `hx-include` resends /// them on every subsequent request. Without a rendered `value`, they come back /// empty, `empty_string_as_none` maps that to "unfiltered", and the price filter /// silently disappears the moment the user touches any other control. #[tokio::test] async fn price_filter_round_trips_into_its_inputs() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "priceround", "Priced Thing", "audio").await; let resp = h .client .get("/discover?mode=items&min_price=25&max_price=75.50") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"id="min-price""#), "price inputs must render" ); // Dollars in, dollars back out. The param was cents until 2026-08-04, which // is how `20` came to mean twenty cents (loose-wire g2-16). assert!( resp.text.contains(r#"value="25""#), "min_price must round-trip into its input or hx-include drops it" ); assert!( resp.text.contains(r#"value="75.50""#), "max_price must round-trip into its input or hx-include drops it" ); } // Tag following. /// The drill-down offers a follow control on every rung. /// /// Inverted 2026-07-20. Following used to be exact-match, `follows.target_id` /// joined `item_tags.tag_id` directly, with no descendant expansion, and since /// items carry depth-3+ leaves only, a follow on a category matched nothing /// forever. The control was therefore withheld on navigational rungs, even /// though the tag filter beside it did expand to descendants. Following is now /// hierarchical too, so the two agree and every rung is followable. #[tokio::test] async fn follow_control_appears_on_every_rung() { let mut h = TestHarness::new().await; h.signup("followui", "followui@example.com", "password123") .await; // Root: depth-1 categories, navigational rather than assignable. Followable // now that a follow on a branch covers its subtree. let root = h.client.get("/discover?mode=items").await; assert_eq!(root.status, 200, "{}", root.status); assert!( root.text.contains("tag-follow-btn"), "a category follow now covers its whole subtree, so offer the control" ); assert!( root.text.contains("/api/follow/tag/"), "the control must address the tag follow endpoint" ); // Drilled to leaves: the control is still there. let leaves = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert_eq!(leaves.status, 200, "{}", leaves.status); assert!( leaves.text.contains("tag-follow-btn"), "assignable leaves should still offer a follow control" ); } /// Anonymous visitors get no follow control, and cost no follow query. #[tokio::test] async fn follow_control_is_hidden_from_anonymous_visitors() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( !resp.text.contains("tag-follow-btn"), "a signed-out visitor has nothing to follow with" ); } /// Following a tag round-trips into the rendered control. #[tokio::test] async fn following_a_tag_is_reflected_in_the_sidebar() { let mut h = TestHarness::new().await; h.signup("followrt", "followrt@example.com", "password123") .await; let tag_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'") .fetch_one(&h.db) .await .expect("seeded leaf tag exists"); let before = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert!(before.text.contains(">Follow<"), "should start unfollowed"); let resp = h .client .post_form(&format!("/api/follow/tag/{tag_id}"), "") .await; assert_eq!(resp.status, 200, "follow failed: {}", resp.status); let after = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert!( after.text.contains(">Following<"), "the sidebar must reflect the follow it just recorded" ); } // State survives the out-of-band swap // // All three were found by driving the page in a browser, not by the suite. The // sidebar is replaced wholesale on every filter change, so anything carrying // state has to live inside it; anything left outside silently stops tracking. /// The state carriers live inside the swapped element. /// /// They used to sit in the page form, which the out-of-band swap never /// replaces. A tag selected from one drill rung was dropped as soon as the /// drill navigated away from its checkbox, because the hidden input that should /// have carried it was never re-rendered. #[tokio::test] async fn state_carriers_live_inside_the_swapped_sidebar() { let mut h = TestHarness::new().await; let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await; tag_item(&h, &item, "audio.genre.electronic").await; // Browsing elsewhere while a tag is selected: the tag has no checkbox on // screen, so a hidden input must carry it, and it must be in the sidebar. let resp = h .client .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing") .await; assert_eq!(resp.status, 200, "{}", resp.status); let sidebar_start = resp .text .find(r#"id="discover-sidebar""#) .expect("sidebar renders"); let sidebar_end = resp.text[sidebar_start..] .find("") .expect("sidebar closes") + sidebar_start; let sidebar = &resp.text[sidebar_start..sidebar_end]; assert!( sidebar.contains(r#"name="tag" value="audio.genre.electronic""#), "the off-screen tag must be carried by a hidden input inside the sidebar" ); assert!( sidebar.contains(r#"name="browse""#), "the drill cursor must be carried inside the sidebar too" ); } /// The drill cursor survives a filter interaction. /// /// Without a `browse` input in the `.discover-filter` set, every filter click /// rebuilt the request without a cursor and the sidebar snapped back to the tag /// roots, making the drill-down unusable for picking more than one tag. #[tokio::test] async fn drill_cursor_is_carried_as_a_filter_input() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"name="browse" value="audio.genre""#), "the cursor must round-trip as a filter input, not only as a URL param" ); } /// The results partial refreshes the total count out of band. /// /// `#total-count` sits in the page form, outside `#results-container`, so a /// plain swap left it reading the previous filter's total: the header said /// "13 items" beside a list showing 3. #[tokio::test] async fn results_partial_refreshes_the_total_count() { let mut h = TestHarness::new().await; let resp = h.client.htmx_get("/discover/results?mode=items").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"id="total-count" hx-swap-oob="true""#), "the total count must be swapped out of band or it goes stale" ); } /// The page and the out-of-band partial must render the same count text. /// /// Both emit `#total-count`, and the partial's copy replaces the page's on every /// filter interaction. If they word it differently the label silently rewrites /// itself on the first swap, which is the same class of bug as the count going /// stale (it used to sit outside `#results-container` and read "13 items" beside /// a list of 3). One helper builds the string for both; this checks nothing has /// reintroduced a second source. #[tokio::test] async fn page_and_partial_word_the_count_identically() { let mut h = TestHarness::new().await; for query in ["mode=items", "mode=items&q=guitar", "mode=projects"] { let page = h.client.get(&format!("/discover?{query}")).await; let partial = h .client .htmx_get(&format!("/discover/results?{query}")) .await; assert_eq!(page.status, 200, "{}", page.status); assert_eq!(partial.status, 200, "{}", partial.status); let from_page = total_count_text(&page.text); let from_partial = total_count_text(&partial.text); assert_eq!(from_page, from_partial, "count copy disagrees for ?{query}"); } } /// Searching counts "results", browsing counts the thing. /// /// Membership is any typed word, so a large number under a search is mostly /// partial matches. "N items" would assert N things matched; the number only /// ever described the list. #[tokio::test] async fn search_counts_results_and_browse_counts_items() { let mut h = TestHarness::new().await; let browsing = h.client.get("/discover?mode=items").await; let browsing_text = total_count_text(&browsing.text); assert!( browsing_text.ends_with("items") || browsing_text.ends_with("item"), "browsing should count items, got {browsing_text:?}" ); let searching = h.client.get("/discover?mode=items&q=guitar").await; let searching_text = total_count_text(&searching.text); assert!( searching_text.ends_with("results") || searching_text.ends_with("result"), "searching should count results, got {searching_text:?}" ); // A whitespace-only term is browsing as far as the query is concerned, so // the copy has to agree with it rather than reading the raw `?q=`. let blank = h.client.get("/discover?mode=items&q=%20%20").await; let blank_text = total_count_text(&blank.text); assert!( blank_text.ends_with("items") || blank_text.ends_with("item"), "a whitespace-only search is a browse, got {blank_text:?}" ); } /// Text inside the `#total-count` span, whitespace-normalized. fn total_count_text(html: &str) -> String { let after_id = html .split_once(r#"id="total-count""#) .expect("response has no #total-count") .1; let inner = after_id .split_once('>') .expect("#total-count span is unterminated") .1 .split_once("") .expect("#total-count span is unclosed") .0; inner.split_whitespace().collect::>().join(" ") } // Markup/stylesheet contract. /// Sidebar markup only uses class hooks the stylesheet defines. /// /// A class with no rule is invisible in review and silently unstyled in /// production. `.visually-hidden` shipped this way: it does not exist in this /// codebase (the utility is `.sr-only`), so the "Find a tag" label rendered as /// visible body text until someone read the stylesheet. #[tokio::test] async fn sidebar_uses_only_defined_style_hooks() { let css = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/static/style.css")) .expect("read style.css"); // Every class the rebuilt sidebar introduces. Kept explicit rather than // scraped from the template so adding a hook is a deliberate act. for class in [ "tag-spine", "tag-chips", "tag-chip", "tag-chip-label", "tag-chip-remove", "tag-chip-clear", "tag-combobox", "tag-crumbs", "tag-drill-select", "tag-drill-into", "tag-drill-chevron", "filter-refine", "filter-fieldset", "filter-check", "price-buckets", "price-bucket", "sr-only", // Search-result match reporting: the tier heading and the per-row // "Matched 3 of 5 words" note. Unstyled, .results-tier-break renders as // an unmarked row in the middle of the list, which reads as a result // rather than as the boundary it is. "results-tier-break", "results-tier-label", "results-tier-hint", "row-match-note", ] { assert!( css.contains(&format!(".{class}")), "class {class} is used by discover but has no rule in style.css" ); } // The grid is `repeat(auto-fill, minmax(280px, 1fr))`, so the tier heading // must span every column or it takes one card's slot and the two tiers // interleave on screen while being correctly ordered in the DOM. assert!( css.contains("grid-column: 1 / -1"), ".results-tier-break must span the grid, or the tiers interleave in grid view" ); // The utility this codebase does not have. Guarding the name directly // because reaching for it is the natural mistake. assert!( !css.contains(".visually-hidden"), "use .sr-only; .visually-hidden is not this codebase's utility" ); } // Form/handler name contract. /// Every filter parameter the handler reads is reachable from the markup. /// /// A form's control names are an untested contract with its handler. A rename /// pass once rewrote `name="has_source"` to `name="sidebar.has_source"` and the /// whole suite passed with the filter inert. /// /// The list below is hardcoded. `query_param_contract` in the handler is the /// exhaustive destructure of `DiscoverQuery`, but it lives behind two private /// module boundaries and this is a separate crate, so the two can drift: adding /// a query parameter there does NOT fail anything here. #[tokio::test] async fn every_filter_param_has_a_control_in_the_markup() { let mut h = TestHarness::new().await; // Seed both modes so every facet renders something. let setup = h .create_creator_with_item("namecontract", "audio", 1000) .await; sqlx::query( "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \ deleted_at = NULL WHERE id = $1::uuid", ) .bind(&setup.item_id) .execute(&h.db) .await .expect("publish item"); sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") .bind(&setup.project_id) .execute(&h.db) .await .expect("publish project"); // Items mode carries q, item_type, min_price, max_price, sort, mode, ai_tier. let items = h.client.get("/discover?mode=items").await; assert_eq!(items.status, 200, "{}", items.status); for name in [ "q", "item_type", "min_price", "max_price", "sort", "mode", "ai_tier", ] { assert!( items.text.contains(&format!(r#"name="{name}""#)), "items mode must expose a control named {name}" ); } // `tag` is deliberately absent at the root cursor: the rungs there are // depth-1 type roots, which cannot be assigned to an item and so render as // navigation rather than checkboxes. It appears once the cursor reaches a // level with assignable leaves (or via the typeahead, or as a hidden input // once something is selected). assert!( !items.text.contains(r#"name="tag""#), "the root rung has no assignable tags, so it should offer no tag control" ); let drilled = h .client .get("/discover?mode=items&browse=audio.genre") .await; assert_eq!(drilled.status, 200, "{}", drilled.status); assert!( drilled.text.contains(r#"name="tag""#), "drilling to a leaf level must expose a control named tag" ); // Projects mode carries category and has_source instead of the item facets. let projects = h.client.get("/discover?mode=projects").await; assert_eq!(projects.status, 200, "{}", projects.status); for name in ["q", "category", "has_source", "sort", "mode"] { assert!( projects.text.contains(&format!(r#"name="{name}""#)), "projects mode must expose a control named {name}" ); } // No control may submit under a template field path, which is what the // sidebar-extraction rename produced. for body in [&items.text, &projects.text] { assert!( !body.contains(r#"name="sidebar."#), "a form control is submitting under a template field path" ); } } // Projects mode facets. /// The projects-mode filters submit under the names the handler reads. /// /// A rename refactor once rewrote `name="has_source"` to `name="sidebar.has_source"` /// and nothing failed: no test asserted the wire names, so the source filter was /// simply inert. Names are the contract between the form and `DiscoverQuery`. #[tokio::test] async fn projects_mode_filters_use_the_query_param_names() { let mut h = TestHarness::new().await; let resp = h.client.get("/discover?mode=projects").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"name="has_source""#), "the source filter must submit as has_source, the name DiscoverQuery reads" ); assert!( resp.text.contains(r#"name="category""#), "the category facet must submit as category" ); assert!( !resp.text.contains(r#"name="sidebar."#), "no form control may submit under a template field path" ); } /// Projects mode has no non-native controls left. #[tokio::test] async fn projects_mode_category_facet_is_a_native_control() { let mut h = TestHarness::new().await; let resp = h.client.get("/discover?mode=projects").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"type="radio" name="category""#), "category should be radios: it is single-valued server-side" ); assert!( !resp.text.contains("filter-btn"), "no .filter-btn should remain; it needed JS to mirror its value into a hidden input" ); } /// The source filter actually narrows the project list. #[tokio::test] async fn has_source_filter_narrows_projects() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("hassrc", "digital", 1000).await; sqlx::query( "UPDATE projects SET is_public = true, title = 'Sourced Project' WHERE id = $1::uuid", ) .bind(&setup.project_id) .execute(&h.db) .await .expect("publish project"); // No git repo attached, so the filter must exclude it. let filtered = h.client.get("/discover?mode=projects&has_source=1").await; assert_eq!(filtered.status, 200, "{}", filtered.status); assert!( !filtered.text.contains("Sourced Project"), "a project with no git repo must not survive has_source=1" ); // Unfiltered, it shows. let all = h.client.get("/discover?mode=projects").await; assert!( all.text.contains("Sourced Project"), "the project should be listed without the filter" ); } // Out-of-band sidebar refresh. /// The results partial carries the sidebar for an out-of-band swap. /// /// Faceted browsing is steered by the counts, so a sidebar that only refreshes /// on a full page load is showing numbers for a filter the user has already /// moved past. #[tokio::test] async fn results_partial_carries_the_sidebar_out_of_band() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await; let resp = h.client.htmx_get("/discover/results?mode=items").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text .contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#), "results must carry an OOB swap for the sidebar" ); assert!( resp.text.contains(r#"id="discover-sidebar""#), "the OOB payload must include the sidebar element itself" ); } /// The out-of-band sidebar reflects the filter that produced the results. #[tokio::test] async fn oob_sidebar_counts_track_the_active_filter() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await; make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await; // Unfiltered: both types present, so the audio checkbox is not checked. let all = h.client.htmx_get("/discover/results?mode=items").await; assert!( all.text.contains(r#"id="typesel-audio""#), "audio facet should render" ); assert!( all.text.contains(r#"id="typesel-video""#), "video facet should render" ); // Filtered to audio: the audio control comes back checked in the OOB payload. let audio = h .client .htmx_get("/discover/results?mode=items&item_type=audio") .await; assert!( audio.text.contains("checked"), "the OOB sidebar must reflect the active selection, not the previous one" ); } /// Every sidebar control carries a stable id. /// /// htmx restores focus after a swap only to an element that has one. Without /// ids, replacing the sidebar on every filter change would drop a keyboard /// user back to the document body each time they tick a box. #[tokio::test] async fn sidebar_controls_have_stable_ids_for_focus_restoration() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await; let resp = h.client.get("/discover?mode=items").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"id="typesel-audio""#), "type checkboxes need ids" ); assert!( resp.text.contains(r#"id="aitier-"#), "ai tier radios need ids" ); // The typeahead box is described (N8), and it is addressed by the field's // own name, so focus restoration still has an id to find it back by. // // What it no longer carries is `hx-preserve`: nothing in the vocabulary // says "this control's value is the reader's, keep it through a swap", so a // sidebar swap while a tag name is half-typed now clears the box. Filed // against quasicoherent rather than kept by leaving the box hand-written. assert!( resp.text.contains(r#"id="tag-search""#), "the typeahead box needs an id for focus restoration" ); } // Tag spine: drill-down and typeahead. /// Drill-down counts roll up the whole subtree, not direct assignments. /// /// Items may only carry depth-3+ leaves, so a category's direct count is always /// zero. If the drill-down counted direct assignments every category would read /// 0 and the sidebar would be useless. #[tokio::test] async fn drill_down_counts_roll_up_the_subtree() { let mut h = TestHarness::new().await; let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await; tag_item(&h, &item, "audio.genre.electronic").await; // At the root, the `audio` type must report the leaf two levels below it. let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[])) .await .expect("root drill rows"); let audio = roots .iter() .find(|r| r.tag_slug == "audio") .expect("audio root present"); assert_eq!(audio.count, 1, "root count must roll up the whole subtree"); assert!(!audio.assignable, "depth-1 type roots are not assignable"); assert!(audio.has_children, "audio has categories beneath it"); } /// A category with no matching items is still listed, with a zero. /// /// Dropping empty rungs would make the taxonomy's shape flicker as filters /// change; showing a 0 tells the user the branch exists and is empty. #[tokio::test] async fn drill_down_keeps_empty_children_as_zero() { let h = TestHarness::new().await; // No items at all. let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[])) .await .expect("root drill rows"); assert!( !roots.is_empty(), "type roots must render even with an empty catalog" ); assert!( roots.iter().all(|r| r.count == 0), "an empty catalog means every rung reads zero, not missing" ); } /// Drilling in returns the cursor's immediate children only. #[tokio::test] async fn drill_down_returns_immediate_children_of_the_cursor() { let h = TestHarness::new().await; let rows = makenotwork::db::tags::tag_children_with_counts(&h.db, Some("audio"), &filters_for(&[])) .await .expect("audio children"); assert!(!rows.is_empty(), "audio has categories"); assert!( rows.iter() .all(|r| r.tag_slug.starts_with("audio.") && r.tag_slug.matches('.').count() == 1), "only depth-2 children of audio, got {:?}", rows.iter().map(|r| &r.tag_slug).collect::>() ); } /// The typeahead finds a leaf from a partial name, at any depth. /// /// The typed value arrives under the described field's own name, which is what /// a consult sends it under. `q` on this route is the search box's value riding /// along as one of the filters a pick carries forward. #[tokio::test] async fn tag_typeahead_finds_a_leaf_by_partial_name() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover/tag-suggest?tag-search=electr") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains("audio.genre.electronic"), "prefix search must reach a depth-3 leaf, got {}", resp.text ); } /// The typeahead never offers a tag that cannot filter. #[tokio::test] async fn tag_typeahead_omits_unassignable_categories() { let mut h = TestHarness::new().await; // "audio" and "audio.genre" both prefix-match, but neither is assignable. let resp = h .client .get("/discover/tag-suggest?tag-search=audio.genre") .await; assert_eq!(resp.status, 200, "{}", resp.status); // Asked of what a pick would add, not of what a row reads: every row here // draws `audio.genre` as its second line, which is the parent path doing // its job rather than a category being offered. assert!( !resp.text.contains("tag=audio.genre\"") && !resp.text.contains("tag=audio.genre&"), "a depth-2 category must not be offered as a filter: {}", resp.text ); } /// N8. The dropdown is the described suggestion list, so the route answers the /// markup a renderer drew rather than JSON a hand-written renderer parsed. #[tokio::test] async fn the_typeahead_answers_the_described_list() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover/tag-suggest?tag-search=electr") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains(r#"role="option""#), "rows are options in the list the field owns: {}", resp.text ); // The parent path is the second line, which is what tells four rows reading // "Format" apart. assert!( resp.text.contains("form-suggestion-detail"), "a candidate carries its second line: {}", resp.text ); } /// Picking a tag is the call the drill-down checkbox makes, under the filters /// the box sent with the question. Before N8 this was a `choose()` in /// `page-discover.js` that built a hidden input by hand. #[tokio::test] async fn a_suggested_tag_carries_the_filters_it_is_being_added_to() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover/tag-suggest?tag-search=electr&mode=items&sort=newest") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains("hx-get=\"/discover/results"), "a pick calls the results route: {}", resp.text ); assert!( resp.text.contains("mode=items") && resp.text.contains("sort=newest"), "and carries the screen it was offered under: {}", resp.text ); assert!( resp.text.contains("tag=audio.genre.electronic"), "and the tag it adds: {}", resp.text ); } /// Nothing but the filters travels. A caller appending its own parameters to /// the question does not get them echoed into every candidate's address. #[tokio::test] async fn the_typeahead_carries_only_the_filters_it_says_it_does() { let mut h = TestHarness::new().await; let resp = h .client .get("/discover/tag-suggest?tag-search=electr&smuggled=yes") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!(!resp.text.contains("smuggled"), "{}", resp.text); } // Faceted multi-select: OR within a facet, AND across facets. /// Repeated `tag=` params are OR'd, and each still matches its own subtree. #[tokio::test] async fn repeated_tag_params_are_ored_within_the_facet() { let mut h = TestHarness::new().await; let (_, electronic) = make_discoverable_item(&mut h, "orelec", "Electronic Pick", "audio").await; let (_, ambient) = make_discoverable_item(&mut h, "oramb", "Ambient Pick", "audio").await; let (_, folk) = make_discoverable_item(&mut h, "orfolk", "Folk Pick", "audio").await; tag_item(&h, &electronic, "audio.genre.electronic").await; tag_item(&h, &ambient, "audio.genre.ambient").await; tag_item(&h, &folk, "audio.genre.folk").await; let resp = h .client .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.genre.ambient") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains("Electronic Pick"), "first selected tag must match" ); assert!( resp.text.contains("Ambient Pick"), "second selected tag must match (OR, not AND)" ); assert!( !resp.text.contains("Folk Pick"), "an unselected tag must not match" ); } /// A tag link that carries no `mode` filters items rather than landing on the /// unfiltered project list. /// /// Six templates emit `/discover?tag={slug}` with no mode (item, project, both /// players, and the two reader views), so this is every tag click from a /// content page. Projects mode has no tag dimension at all — `discover_projects` /// takes no tag argument and the sidebar only builds tag filters in items mode /// — so the URL used to claim a filter the query could not apply, and the page /// answered with every project, which reads as "this tag matches everything". #[tokio::test] async fn a_tag_link_without_a_mode_filters_items() { let mut h = TestHarness::new().await; let (_, electronic) = make_discoverable_item(&mut h, "bareelec", "Electronic Pick", "audio").await; let (_, folk) = make_discoverable_item(&mut h, "barefolk", "Folk Pick", "audio").await; tag_item(&h, &electronic, "audio.genre.electronic").await; tag_item(&h, &folk, "audio.genre.folk").await; let resp = h.client.get("/discover?tag=audio.genre.electronic").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("Electronic Pick"), "a bare tag link must return the tagged item", ); assert!( !resp.text.contains("Folk Pick"), "a bare tag link must still exclude an unselected tag", ); } /// And the default is otherwise unchanged: no tag, no mode, still projects. /// Pinned because the tag rule above is a second default rather than a /// replacement, and the two are easy to collapse into one by accident. #[tokio::test] async fn discover_without_a_tag_still_defaults_to_projects() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "defmode", "Mode Default Pick", "audio").await; let resp = h.client.get("/discover").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("project-row"), "the bare landing view is the project list", ); } /// Repeated `item_type=` params are OR'd. #[tokio::test] async fn repeated_item_type_params_are_ored_within_the_facet() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "ortaudio", "Audio Thing", "audio").await; make_discoverable_item(&mut h, "ortsample", "Sample Thing", "sample").await; make_discoverable_item(&mut h, "ortvideo", "Video Thing", "video").await; let resp = h .client .get("/discover?mode=items&item_type=audio&item_type=sample") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!(resp.text.contains("Audio Thing")); assert!(resp.text.contains("Sample Thing")); assert!( !resp.text.contains("Video Thing"), "unselected type must not match" ); } /// Different facets AND together, so the two selections intersect. #[tokio::test] async fn separate_facets_are_anded_together() { let mut h = TestHarness::new().await; let (_, match_both) = make_discoverable_item(&mut h, "andboth", "Matches Both", "audio").await; let (_, wrong_type) = make_discoverable_item(&mut h, "andtype", "Wrong Type", "video").await; let (_, wrong_tag) = make_discoverable_item(&mut h, "andtag", "Wrong Tag", "audio").await; tag_item(&h, &match_both, "audio.genre.electronic").await; tag_item(&h, &wrong_type, "audio.genre.electronic").await; tag_item(&h, &wrong_tag, "audio.genre.folk").await; let resp = h .client .get("/discover?mode=items&item_type=audio&tag=audio.genre.electronic") .await; assert_eq!(resp.status, 200, "{}", resp.status); assert!(resp.text.contains("Matches Both")); assert!( !resp.text.contains("Wrong Type"), "tag matched but type did not: facets must AND" ); assert!( !resp.text.contains("Wrong Tag"), "type matched but tag did not: facets must AND" ); } /// A multi-select survives the round-trip through the form's hidden inputs. /// /// `hx-include=".discover-filter"` rebuilds the next request from these inputs, /// so if several selected tags collapsed into one delimited value (or into the /// first value only) the second interaction would silently drop the filter. /// One input per value, no delimiter convention. #[tokio::test] async fn multi_select_round_trips_through_the_form_inputs() { let mut h = TestHarness::new().await; let (_, item) = make_discoverable_item(&mut h, "roundtrip", "Round Trip", "audio").await; tag_item(&h, &item, "audio.genre.electronic").await; let resp = h .client .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.mood.dark") .await; assert_eq!(resp.status, 200, "{}", resp.status); let tag_inputs = resp.text.matches(r#"name="tag""#).count(); assert_eq!( tag_inputs, 2, "both selected tags must be carried as separate hidden inputs, got {tag_inputs}" ); assert!(resp.text.contains(r#"value="audio.genre.electronic""#)); assert!(resp.text.contains(r#"value="audio.mood.dark""#)); assert!( !resp.text.contains("audio.genre.electronic,audio.mood.dark"), "selections must not be joined into one delimited value" ); } /// A blank facet value is not a filter. /// /// `hx-include` ships every filter input on every request, so an untouched /// control arrives as `tag=`. That must mean "unfiltered", not "match the empty /// tag" (which would return nothing). #[tokio::test] async fn blank_repeated_params_do_not_filter() { let mut h = TestHarness::new().await; make_discoverable_item(&mut h, "blankp", "Still Visible", "audio").await; let resp = h.client.get("/discover?mode=items&tag=&item_type=").await; assert_eq!(resp.status, 200, "{}", resp.status); assert!( resp.text.contains("Still Visible"), "blank filter values must not narrow the result set" ); } /// Tag counts obey the same visibility predicate as the results. /// /// `get_tag_counts` had no `users` join at all, so it never applied /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it /// count items that discover would never display. #[tokio::test] async fn tag_counts_exclude_sandbox_and_unscanned_items() { let mut h = TestHarness::new().await; let (sandbox_user, sandboxed) = make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await; let (_, quarantined) = make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await; let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await; for id in [&sandboxed, &quarantined, &visible] { tag_item(&h, id, "audio.genre.electronic").await; } sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid") .bind(&sandbox_user) .execute(&h.db) .await .expect("sandbox the user"); sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid") .bind(&quarantined) .execute(&h.db) .await .expect("quarantine the item"); let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(&[])) .await .expect("tag counts"); let leaf = counts .iter() .find(|c| c.tag_slug == "audio.genre.electronic") .expect("leaf tag counted"); assert_eq!( leaf.count, 1, "only the visible item may count; sandbox + quarantined must be excluded (was 3)" ); }