//! 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) } #[tokio::test] async fn discover_page_renders_for_anonymous_visitor() { let mut h = TestHarness::new().await; let resp = h.client.get("/discover").await; assert!(resp.status.is_success(), "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!(resp.status.is_success()); assert!( resp.text.contains("Searchable Widget"), "Search by title should find the item; body did not contain it" ); } #[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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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.get("/discover/results?mode=items").await; assert!(resp.status.is_success(), "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!(resp.status.is_success()); 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!(resp.status.is_success(), "GET /discover/tags: {}", resp.status); }