Skip to main content

max / makenotwork

10.0 KB · 267 lines History Blame Raw
1 //! Discover page + search: faceted listings, filters, suggestions, privacy.
2 //!
3 //! Covers the four discover endpoints:
4 //! - GET /discover (full page, faceted)
5 //! - GET /discover/results (HTMX results partial)
6 //! - GET /discover/suggestions (JSON search suggestions)
7 //! - GET /discover/tags (tag tree partial)
8 //!
9 //! Privacy invariants that must hold across all of these (verified per-test):
10 //! drafts (is_public=false), unlisted items, sandbox-user items,
11 //! quarantined files, and soft-deleted items are NEVER returned.
12
13 use crate::harness::TestHarness;
14
15 /// Create a creator with a published, listed item that satisfies all five
16 /// "shows on discover" preconditions. Returns (user_id, item_id).
17 ///
18 /// The discover query requires `is_public=true AND listed=true AND
19 /// p.is_public=true AND scan_status!='quarantined' AND deleted_at IS NULL
20 /// AND u.is_sandbox=false`. We set every one of these via direct SQL
21 /// rather than the API so the test doesn't depend on the publish flow's
22 /// internals (validation rules, scheduled-publish gates, etc).
23 async fn make_discoverable_item(
24 h: &mut TestHarness,
25 username: &str,
26 title: &str,
27 item_type: &str,
28 ) -> (String, String) {
29 let setup = h.create_creator_with_item(username, item_type, 1000).await;
30 sqlx::query(
31 "UPDATE items SET title = $1, is_public = true, listed = true, \
32 scan_status = 'clean', deleted_at = NULL WHERE id = $2::uuid",
33 )
34 .bind(title)
35 .bind(&setup.item_id)
36 .execute(&h.db)
37 .await
38 .expect("update item for discover");
39 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
40 .bind(&setup.project_id)
41 .execute(&h.db)
42 .await
43 .expect("publish project for discover");
44 (setup.user_id.to_string(), setup.item_id)
45 }
46
47 #[tokio::test]
48 async fn discover_page_renders_for_anonymous_visitor() {
49 let mut h = TestHarness::new().await;
50 let resp = h.client.get("/discover").await;
51 assert!(resp.status.is_success(), "GET /discover: {} {}", resp.status, resp.text);
52 // Must contain the discover landmark — used by HTMX swaps + screen readers.
53 assert!(
54 resp.text.contains("discover") || resp.text.to_lowercase().contains("discover"),
55 "Discover page should contain 'discover' marker"
56 );
57 }
58
59 #[tokio::test]
60 async fn search_finds_published_item_by_title() {
61 let mut h = TestHarness::new().await;
62 let (_creator, _item) =
63 make_discoverable_item(&mut h, "creator1", "Searchable Widget", "digital").await;
64
65 // Default mode is "projects" — items mode is opt-in via `?mode=items`.
66 let resp = h.client.get("/discover?mode=items&q=Searchable").await;
67 assert!(resp.status.is_success());
68 assert!(
69 resp.text.contains("Searchable Widget"),
70 "Search by title should find the item; body did not contain it"
71 );
72 }
73
74 #[tokio::test]
75 async fn search_does_not_leak_draft_items() {
76 let mut h = TestHarness::new().await;
77 let setup = h.create_creator_with_item("draftcreator", "audio", 1000).await;
78 // Both items.is_public and projects.is_public default to TRUE (see
79 // migrations/001_initial_schema.sql lines 71, 86) — "draft" means the
80 // creator explicitly toggled `is_public=false`. Set it directly.
81 sqlx::query("UPDATE items SET title = 'Sneaky Draft Title', is_public = false WHERE id = $1::uuid")
82 .bind(&setup.item_id)
83 .execute(&h.db)
84 .await
85 .unwrap();
86
87 let resp = h.client.get("/discover?mode=items&q=Sneaky").await;
88 assert!(resp.status.is_success());
89 assert!(
90 !resp.text.contains("Sneaky Draft Title"),
91 "Discover must not return draft (is_public=false) items"
92 );
93 }
94
95 #[tokio::test]
96 async fn search_excludes_quarantined_items() {
97 let mut h = TestHarness::new().await;
98 let (_, item_id) =
99 make_discoverable_item(&mut h, "quarcreator", "Quarantine Sentinel", "digital").await;
100
101 // Manually flip scan_status to quarantined — discover should drop it.
102 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
103 .bind(&item_id)
104 .execute(&h.db)
105 .await
106 .unwrap();
107
108 let resp = h.client.get("/discover?mode=items&q=Quarantine").await;
109 assert!(resp.status.is_success());
110 assert!(
111 !resp.text.contains("Quarantine Sentinel"),
112 "Quarantined items must not surface in discover"
113 );
114 }
115
116 #[tokio::test]
117 async fn search_excludes_unlisted_items() {
118 let mut h = TestHarness::new().await;
119 let (_, item_id) =
120 make_discoverable_item(&mut h, "unlistedcreator", "Unlisted Marker", "digital").await;
121
122 // `listed = false` is the "public via direct URL but not in discover" mode.
123 sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
124 .bind(&item_id)
125 .execute(&h.db)
126 .await
127 .unwrap();
128
129 let resp = h.client.get("/discover?mode=items&q=Unlisted").await;
130 assert!(resp.status.is_success());
131 assert!(
132 !resp.text.contains("Unlisted Marker"),
133 "listed=false items must not surface in discover"
134 );
135 }
136
137 #[tokio::test]
138 async fn search_excludes_sandbox_users() {
139 let mut h = TestHarness::new().await;
140 let (creator_id, _item) =
141 make_discoverable_item(&mut h, "sandboxcreator", "Sandbox Hidden", "digital").await;
142
143 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
144 .bind(&creator_id)
145 .execute(&h.db)
146 .await
147 .unwrap();
148
149 let resp = h.client.get("/discover?mode=items&q=Sandbox").await;
150 assert!(resp.status.is_success());
151 assert!(
152 !resp.text.contains("Sandbox Hidden"),
153 "Sandbox users' items must not surface in discover"
154 );
155 }
156
157 #[tokio::test]
158 async fn search_excludes_soft_deleted_items() {
159 let mut h = TestHarness::new().await;
160 let (_, item_id) =
161 make_discoverable_item(&mut h, "delcreator", "Deleted Marker", "digital").await;
162
163 // Soft-delete keeps the row but sets deleted_at; discover must drop it.
164 sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
165 .bind(&item_id)
166 .execute(&h.db)
167 .await
168 .unwrap();
169
170 let resp = h.client.get("/discover?mode=items&q=Deleted").await;
171 assert!(resp.status.is_success());
172 assert!(
173 !resp.text.contains("Deleted Marker"),
174 "Soft-deleted items must not surface in discover"
175 );
176 }
177
178 #[tokio::test]
179 async fn item_type_filter_narrows_results() {
180 let mut h = TestHarness::new().await;
181 make_discoverable_item(&mut h, "audiocreator", "AudioOnly Title", "audio").await;
182 h.client.post_form("/logout", "").await;
183 make_discoverable_item(&mut h, "softcreator", "SoftwareOnly Title", "digital").await;
184
185 // Filter to audio only — software item must be absent.
186 let resp = h.client.get("/discover?mode=items&item_type=audio").await;
187 assert!(resp.status.is_success());
188 assert!(resp.text.contains("AudioOnly Title"), "Audio item should appear");
189 assert!(
190 !resp.text.contains("SoftwareOnly Title"),
191 "Software item must NOT appear under item_type=audio"
192 );
193 }
194
195 #[tokio::test]
196 async fn projects_mode_lists_projects_not_items() {
197 let mut h = TestHarness::new().await;
198 let setup = h.create_creator_with_item("projmode", "digital", 1000).await;
199 // Rename project to a distinctive title.
200 h.client
201 .put_json(
202 &format!("/api/projects/{}", setup.project_id),
203 r#"{"title":"Discover Project Mode","is_public":true}"#,
204 )
205 .await;
206 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
207
208 let resp = h.client.get("/discover?mode=projects").await;
209 assert!(resp.status.is_success());
210 assert!(
211 resp.text.contains("Discover Project Mode"),
212 "Project should appear in projects mode"
213 );
214 }
215
216 #[tokio::test]
217 async fn results_partial_is_htmx_swappable() {
218 let mut h = TestHarness::new().await;
219 make_discoverable_item(&mut h, "partialcreator", "Partial Visible", "digital").await;
220
221 // /discover/results returns the inner partial used by HTMX filter swaps.
222 // It must NOT include the full page chrome (header, footer, <html>).
223 let resp = h.client.get("/discover/results?mode=items").await;
224 assert!(resp.status.is_success(), "GET /discover/results: {}", resp.status);
225 assert!(resp.text.contains("Partial Visible"));
226 assert!(
227 !resp.text.contains("<html") && !resp.text.contains("<!DOCTYPE"),
228 "Results partial must not include full-page chrome"
229 );
230 }
231
232 #[tokio::test]
233 async fn suggestions_endpoint_returns_json() {
234 let mut h = TestHarness::new().await;
235 make_discoverable_item(&mut h, "suggcreator", "Suggestion Probe", "digital").await;
236
237 let resp = h.client.get("/discover/suggestions?q=Suggestion").await;
238 assert!(resp.status.is_success(), "GET suggestions: {} {}", resp.status, resp.text);
239 // Response is `Vec<SearchSuggestion>` — must parse as JSON array.
240 let parsed: serde_json::Value = resp.json();
241 assert!(parsed.is_array(), "Suggestions response must be a JSON array");
242 }
243
244 #[tokio::test]
245 async fn empty_search_query_returns_all_listed_items() {
246 let mut h = TestHarness::new().await;
247 make_discoverable_item(&mut h, "emptyq1", "First Empty Q", "digital").await;
248 h.client.post_form("/logout", "").await;
249 make_discoverable_item(&mut h, "emptyq2", "Second Empty Q", "digital").await;
250
251 // q= (just spaces) should be treated as "no filter" — the route strips
252 // whitespace-only q values before applying the search filter.
253 let resp = h.client.get("/discover?mode=items&q=%20%20").await;
254 assert!(resp.status.is_success());
255 assert!(resp.text.contains("First Empty Q"), "Whitespace q should show all items");
256 assert!(resp.text.contains("Second Empty Q"));
257 }
258
259 #[tokio::test]
260 async fn tag_tree_endpoint_renders() {
261 let mut h = TestHarness::new().await;
262 // No items needed — the tag tree should render even when empty so the
263 // filter UI is always available.
264 let resp = h.client.get("/discover/tags").await;
265 assert!(resp.status.is_success(), "GET /discover/tags: {}", resp.status);
266 }
267