Skip to main content

max / makenotwork

test: discover/search workflow coverage (13 tests) Closes one of three coverage gaps in R26-115-122. The audit flagged 8 advertised features needing tests; discover/search had no dedicated workflow file despite being the largest public surface. Covers all four endpoints: - GET /discover (full page, faceted) - GET /discover/results (HTMX partial swap) - GET /discover/suggestions (JSON suggestions) - GET /discover/tags (tag tree partial) Privacy invariants pinned across the corpus — drafts (is_public=false), unlisted (listed=false), quarantined files, sandbox users, and soft-deleted items must never surface in discover. Each gets its own test with a direct-SQL setup so the assertion exercises the discover query's WHERE, not the publish-flow internals. Functional tests: - anonymous visitor can load /discover - search-by-title finds the matching item - item_type filter narrows results - mode=projects switches to project mode - empty/whitespace q returns all listed items - /discover/results returns an HTMX partial (no full chrome) - /discover/suggestions returns a JSON array - /discover/tags renders even when empty Helper: `make_discoverable_item` sets all six discover-required flags (is_public, listed, scan_status='clean', deleted_at=NULL, p.is_public, u.is_sandbox=false) via direct SQL so tests don't depend on publish- flow internals. Coverage status (in todo.md): - 6/8 features now have dedicated workflows (added discover.rs) - audio/video streaming + embeddable widgets remain ungated
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 15:34 UTC
Commit: a8eaf83319a76da259e613bf74127860657fe850
Parent: e95f476
2 files changed, +267 insertions, -0 deletions
@@ -0,0 +1,266 @@
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 + }
@@ -1,4 +1,5 @@
1 1 mod auth;
2 + mod discover;
2 3 mod creator;
3 4 mod purchase;
4 5 mod content;