Skip to main content

max / makenotwork

53.4 KB · 1458 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 /// The denormalized `projects.item_count` (read by discover instead of a
48 /// COUNT-over-the-catalog) is maintained by a trigger on `items`. Pin that it
49 /// tracks the active-item lifecycle exactly: list/unlist/soft-delete each move
50 /// the cached count (ultra-fuzz Run 11 Perf SER-3).
51 async fn project_item_count(h: &TestHarness, project_id: &str) -> i32 {
52 sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1::uuid")
53 .bind(project_id)
54 .fetch_one(&h.db)
55 .await
56 .expect("read item_count")
57 }
58
59 #[tokio::test]
60 async fn project_item_count_tracks_active_item_lifecycle() {
61 let mut h = TestHarness::new().await;
62 let setup = h
63 .create_creator_with_item("counttrack", "audio", 1000)
64 .await;
65 let pid = setup.project_id.clone();
66
67 // Active: public + listed + not deleted.
68 sqlx::query(
69 "UPDATE items SET is_public = true, listed = true, deleted_at = NULL WHERE id = $1::uuid",
70 )
71 .bind(&setup.item_id)
72 .execute(&h.db)
73 .await
74 .expect("activate item");
75 assert_eq!(project_item_count(&h, &pid).await, 1, "active item counts");
76
77 // Unlist -> drops out of the active set.
78 sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
79 .bind(&setup.item_id)
80 .execute(&h.db)
81 .await
82 .expect("unlist");
83 assert_eq!(
84 project_item_count(&h, &pid).await,
85 0,
86 "unlisted item not counted"
87 );
88
89 // Relist -> back in.
90 sqlx::query("UPDATE items SET listed = true WHERE id = $1::uuid")
91 .bind(&setup.item_id)
92 .execute(&h.db)
93 .await
94 .expect("relist");
95 assert_eq!(
96 project_item_count(&h, &pid).await,
97 1,
98 "relisted item counts again"
99 );
100
101 // Soft-delete -> drops out.
102 sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
103 .bind(&setup.item_id)
104 .execute(&h.db)
105 .await
106 .expect("soft delete");
107 assert_eq!(
108 project_item_count(&h, &pid).await,
109 0,
110 "soft-deleted item not counted"
111 );
112 }
113
114 #[tokio::test]
115 async fn discover_page_renders_for_anonymous_visitor() {
116 let mut h = TestHarness::new().await;
117 let resp = h.client.get("/discover").await;
118 assert_eq!(
119 resp.status, 200,
120 "GET /discover: {} {}",
121 resp.status, resp.text
122 );
123 // Must contain the discover landmark, used by HTMX swaps + screen readers.
124 assert!(
125 resp.text.contains("discover") || resp.text.to_lowercase().contains("discover"),
126 "Discover page should contain 'discover' marker"
127 );
128 }
129
130 #[tokio::test]
131 async fn search_finds_published_item_by_title() {
132 let mut h = TestHarness::new().await;
133 let (_creator, _item) =
134 make_discoverable_item(&mut h, "creator1", "Searchable Widget", "digital").await;
135
136 // Default mode is "projects", items mode is opt-in via `?mode=items`.
137 let resp = h.client.get("/discover?mode=items&q=Searchable").await;
138 assert_eq!(resp.status, 200, "{}", resp.text);
139 assert!(
140 resp.text.contains("Searchable Widget"),
141 "Search by title should find the item; body did not contain it"
142 );
143 }
144
145 /// The relevance expression has always scored `similarity(description) * 0.5`,
146 /// but the match clause only looked at the title, an item whose description was
147 /// a perfect match ranked as if it matched and was then never returned.
148 #[tokio::test]
149 async fn search_finds_published_item_by_description() {
150 let mut h = TestHarness::new().await;
151 let (_creator, item_id) =
152 make_discoverable_item(&mut h, "desccreator", "Unrelated Title", "digital").await;
153 sqlx::query("UPDATE items SET description = 'A hand-built theremin kit' WHERE id = $1::uuid")
154 .bind(&item_id)
155 .execute(&h.db)
156 .await
157 .unwrap();
158
159 let resp = h.client.get("/discover?mode=items&q=theremin").await;
160 assert_eq!(resp.status, 200, "{}", resp.text);
161 assert!(
162 resp.text.contains("Unrelated Title"),
163 "Search should match on description, not title only"
164 );
165 }
166
167 /// The 0.5 weighting on description similarity is the reason the relevance
168 /// expression is written the way it is. Widening the match clause must not
169 /// flatten that ordering.
170 #[tokio::test]
171 async fn title_match_outranks_description_only_match() {
172 let mut h = TestHarness::new().await;
173 let (_, desc_item) =
174 make_discoverable_item(&mut h, "rankdesc", "Something Else", "digital").await;
175 sqlx::query(
176 "UPDATE items SET description = 'mentions theremin in passing' WHERE id = $1::uuid",
177 )
178 .bind(&desc_item)
179 .execute(&h.db)
180 .await
181 .unwrap();
182 let (_, title_item) = make_discoverable_item(&mut h, "ranktitle", "Theremin", "digital").await;
183 sqlx::query("UPDATE items SET description = 'no match here' WHERE id = $1::uuid")
184 .bind(&title_item)
185 .execute(&h.db)
186 .await
187 .unwrap();
188
189 // No explicit sort: a search term makes match_score DESC the default order.
190 let resp = h.client.get("/discover?mode=items&q=theremin").await;
191 assert_eq!(resp.status, 200, "{}", resp.text);
192 let title_pos = resp
193 .text
194 .find("Theremin")
195 .expect("title match should be returned");
196 let desc_pos = resp
197 .text
198 .find("Something Else")
199 .expect("description match should be returned");
200 assert!(
201 title_pos < desc_pos,
202 "Title match must outrank a description-only match (title at {title_pos}, description at {desc_pos})"
203 );
204 }
205
206 #[tokio::test]
207 async fn search_does_not_leak_draft_items() {
208 let mut h = TestHarness::new().await;
209 let setup = h
210 .create_creator_with_item("draftcreator", "audio", 1000)
211 .await;
212 // Both items.is_public and projects.is_public default to TRUE (see
213 // migrations/001_initial_schema.sql lines 71, 86), "draft" means the
214 // creator explicitly toggled `is_public=false`. Set it directly.
215 sqlx::query(
216 "UPDATE items SET title = 'Sneaky Draft Title', is_public = false WHERE id = $1::uuid",
217 )
218 .bind(&setup.item_id)
219 .execute(&h.db)
220 .await
221 .unwrap();
222
223 let resp = h.client.get("/discover?mode=items&q=Sneaky").await;
224 assert_eq!(resp.status, 200, "{}", resp.text);
225 assert!(
226 !resp.text.contains("Sneaky Draft Title"),
227 "Discover must not return draft (is_public=false) items"
228 );
229 }
230
231 #[tokio::test]
232 async fn search_excludes_quarantined_items() {
233 let mut h = TestHarness::new().await;
234 let (_, item_id) =
235 make_discoverable_item(&mut h, "quarcreator", "Quarantine Sentinel", "digital").await;
236
237 // Manually flip scan_status to quarantined, discover should drop it.
238 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
239 .bind(&item_id)
240 .execute(&h.db)
241 .await
242 .unwrap();
243
244 let resp = h.client.get("/discover?mode=items&q=Quarantine").await;
245 assert_eq!(resp.status, 200, "{}", resp.text);
246 assert!(
247 !resp.text.contains("Quarantine Sentinel"),
248 "Quarantined items must not surface in discover"
249 );
250 }
251
252 #[tokio::test]
253 async fn search_excludes_unlisted_items() {
254 let mut h = TestHarness::new().await;
255 let (_, item_id) =
256 make_discoverable_item(&mut h, "unlistedcreator", "Unlisted Marker", "digital").await;
257
258 // `listed = false` is the "public via direct URL but not in discover" mode.
259 sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
260 .bind(&item_id)
261 .execute(&h.db)
262 .await
263 .unwrap();
264
265 let resp = h.client.get("/discover?mode=items&q=Unlisted").await;
266 assert_eq!(resp.status, 200, "{}", resp.text);
267 assert!(
268 !resp.text.contains("Unlisted Marker"),
269 "listed=false items must not surface in discover"
270 );
271 }
272
273 #[tokio::test]
274 async fn search_excludes_sandbox_users() {
275 let mut h = TestHarness::new().await;
276 let (creator_id, _item) =
277 make_discoverable_item(&mut h, "sandboxcreator", "Sandbox Hidden", "digital").await;
278
279 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
280 .bind(&creator_id)
281 .execute(&h.db)
282 .await
283 .unwrap();
284
285 let resp = h.client.get("/discover?mode=items&q=Sandbox").await;
286 assert_eq!(resp.status, 200, "{}", resp.text);
287 assert!(
288 !resp.text.contains("Sandbox Hidden"),
289 "Sandbox users' items must not surface in discover"
290 );
291 }
292
293 #[tokio::test]
294 async fn search_excludes_soft_deleted_items() {
295 let mut h = TestHarness::new().await;
296 let (_, item_id) =
297 make_discoverable_item(&mut h, "delcreator", "Deleted Marker", "digital").await;
298
299 // Soft-delete keeps the row but sets deleted_at; discover must drop it.
300 sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
301 .bind(&item_id)
302 .execute(&h.db)
303 .await
304 .unwrap();
305
306 let resp = h.client.get("/discover?mode=items&q=Deleted").await;
307 assert_eq!(resp.status, 200, "{}", resp.text);
308 assert!(
309 !resp.text.contains("Deleted Marker"),
310 "Soft-deleted items must not surface in discover"
311 );
312 }
313
314 #[tokio::test]
315 async fn item_type_filter_narrows_results() {
316 let mut h = TestHarness::new().await;
317 make_discoverable_item(&mut h, "audiocreator", "AudioOnly Title", "audio").await;
318 h.client.post_form("/logout", "").await;
319 make_discoverable_item(&mut h, "softcreator", "SoftwareOnly Title", "digital").await;
320
321 // Filter to audio only, software item must be absent.
322 let resp = h.client.get("/discover?mode=items&item_type=audio").await;
323 assert_eq!(resp.status, 200, "{}", resp.text);
324 assert!(
325 resp.text.contains("AudioOnly Title"),
326 "Audio item should appear"
327 );
328 assert!(
329 !resp.text.contains("SoftwareOnly Title"),
330 "Software item must NOT appear under item_type=audio"
331 );
332 }
333
334 #[tokio::test]
335 async fn projects_mode_lists_projects_not_items() {
336 let mut h = TestHarness::new().await;
337 let setup = h
338 .create_creator_with_item("projmode", "digital", 1000)
339 .await;
340 // Rename project to a distinctive title.
341 h.client
342 .put_json(
343 &format!("/api/projects/{}", setup.project_id),
344 r#"{"title":"Discover Project Mode","is_public":true}"#,
345 )
346 .await;
347 h.publish_project_and_item(&setup.project_id, &setup.item_id)
348 .await;
349
350 let resp = h.client.get("/discover?mode=projects").await;
351 assert_eq!(resp.status, 200, "{}", resp.text);
352 assert!(
353 resp.text.contains("Discover Project Mode"),
354 "Project should appear in projects mode"
355 );
356 }
357
358 #[tokio::test]
359 async fn results_partial_is_htmx_swappable() {
360 let mut h = TestHarness::new().await;
361 make_discoverable_item(&mut h, "partialcreator", "Partial Visible", "digital").await;
362
363 // /discover/results returns the inner partial used by HTMX filter swaps.
364 // It must NOT include the full page chrome (header, footer, <html>).
365 let resp = h.client.htmx_get("/discover/results?mode=items").await;
366 assert_eq!(resp.status, 200, "GET /discover/results: {}", resp.status);
367 assert!(resp.text.contains("Partial Visible"));
368 assert!(
369 !resp.text.contains("<html") && !resp.text.contains("<!DOCTYPE"),
370 "Results partial must not include full-page chrome"
371 );
372 }
373
374 #[tokio::test]
375 async fn suggestions_endpoint_returns_json() {
376 let mut h = TestHarness::new().await;
377 make_discoverable_item(&mut h, "suggcreator", "Suggestion Probe", "digital").await;
378
379 let resp = h.client.get("/discover/suggestions?q=Suggestion").await;
380 assert_eq!(
381 resp.status, 200,
382 "GET suggestions: {} {}",
383 resp.status, resp.text
384 );
385 // Response is `Vec<SearchSuggestion>`, must parse as JSON array.
386 let parsed: serde_json::Value = resp.json();
387 assert!(
388 parsed.is_array(),
389 "Suggestions response must be a JSON array"
390 );
391 }
392
393 #[tokio::test]
394 async fn empty_search_query_returns_all_listed_items() {
395 let mut h = TestHarness::new().await;
396 make_discoverable_item(&mut h, "emptyq1", "First Empty Q", "digital").await;
397 h.client.post_form("/logout", "").await;
398 make_discoverable_item(&mut h, "emptyq2", "Second Empty Q", "digital").await;
399
400 // q= (just spaces) should be treated as "no filter", the route strips
401 // whitespace-only q values before applying the search filter.
402 let resp = h.client.get("/discover?mode=items&q=%20%20").await;
403 assert_eq!(resp.status, 200, "{}", resp.text);
404 assert!(
405 resp.text.contains("First Empty Q"),
406 "Whitespace q should show all items"
407 );
408 assert!(resp.text.contains("Second Empty Q"));
409 }
410
411 #[tokio::test]
412 async fn tag_tree_endpoint_renders() {
413 let mut h = TestHarness::new().await;
414 // No items needed, the tag tree should render even when empty so the
415 // filter UI is always available.
416 let resp = h.client.get("/discover/tags").await;
417 assert_eq!(resp.status, 200, "GET /discover/tags: {}", resp.status);
418 }
419
420 // Facet-count correctness
421 //
422 // The sidebar counts are a separate query family from the result set, and they
423 // had drifted from it in three ways. These pin each one. Note that every other
424 // discover test in this file passed while all three were live: nothing here
425 // exercised a facet count, so the bugs were invisible to the suite.
426
427 /// Attach `slug` to `item_id`, creating the tag and its dot-path ancestry.
428 async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) {
429 let mut parent: Option<uuid::Uuid> = None;
430 let segments: Vec<&str> = slug.split('.').collect();
431 for depth in 1..=segments.len() {
432 let path = segments[..depth].join(".");
433 let name = segments[depth - 1];
434 let id: uuid::Uuid = sqlx::query_scalar(
435 "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \
436 ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id",
437 )
438 .bind(name)
439 .bind(&path)
440 .bind(parent)
441 .fetch_one(&h.db)
442 .await
443 .expect("upsert tag");
444 parent = Some(id);
445 }
446 sqlx::query(
447 "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING",
448 )
449 .bind(item_id)
450 .bind(parent.expect("leaf tag id"))
451 .execute(&h.db)
452 .await
453 .expect("attach tag");
454 }
455
456 fn filters_for(tags: &[String]) -> makenotwork::db::discover::DiscoverFilters<'_> {
457 makenotwork::db::discover::DiscoverFilters {
458 search: None,
459 item_types: &[],
460 tags,
461 min_price: None,
462 max_price: None,
463 sort_by: None,
464 ai_tier: None,
465 }
466 }
467
468 /// A tag filter matches the whole subtree, not just direct children.
469 ///
470 /// The result query has always matched descendants via `tags.path LIKE
471 /// 'prefix.%'`, but the facet counts used `parent_id = (SELECT id ... )`, which
472 /// stops at one level. An item tagged `audio.genre.electronic` therefore
473 /// appeared in the results for `?tag=audio` while contributing 0 to the
474 /// sidebar counts beside them.
475 #[tokio::test]
476 async fn facet_counts_match_the_whole_tag_subtree() {
477 let mut h = TestHarness::new().await;
478 let (_, item_id) = make_discoverable_item(&mut h, "facetdepth", "Deep Tagged", "audio").await;
479 tag_item(&h, &item_id, "audio.genre.electronic").await;
480
481 // `audio` is two levels above the item's tag.
482 let counts = makenotwork::db::discover::get_item_type_counts(
483 &h.db,
484 &filters_for(&["audio".to_string()]),
485 )
486 .await
487 .expect("item type counts");
488 let total: i64 = counts.iter().map(|c| c.count).sum();
489
490 assert_eq!(
491 total, 1,
492 "grandchild tag audio.genre.electronic must count under ?tag=audio \
493 (was 0 while the facets used parent_id instead of path)"
494 );
495 }
496
497 /// The AI-tier filter constrains the facet counts.
498 ///
499 /// `ai_tier` was applied to the result set but to none of the four facet
500 /// queries, so selecting "Handmade only" narrowed the listing while the sidebar
501 /// kept reporting counts for the unfiltered catalog.
502 /// Facet counts and results share `ITEM_SEARCH_CLAUSE`, that parity was the
503 /// point of commit a668df6f. Widening the clause to match descriptions must
504 /// therefore widen the counts by the same amount, or the sidebar would report a
505 /// zero next to a facet the results list is populating.
506 #[tokio::test]
507 async fn facet_counts_widen_with_description_matches() {
508 let mut h = TestHarness::new().await;
509 let (_, item_id) =
510 make_discoverable_item(&mut h, "facetdesc", "Nothing Relevant", "audio").await;
511 sqlx::query("UPDATE items SET description = 'a hand-built theremin kit' WHERE id = $1::uuid")
512 .bind(&item_id)
513 .execute(&h.db)
514 .await
515 .expect("set description");
516
517 let mut filters = filters_for(&[]);
518 let term = String::from("theremin");
519 filters.search = Some(&term);
520 let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
521 .await
522 .expect("item type counts");
523 let total: i64 = counts.iter().map(|c| c.count).sum();
524
525 assert_eq!(
526 total, 1,
527 "facet counts must count a description-only match, matching the results query"
528 );
529 }
530
531 #[tokio::test]
532 async fn facet_counts_apply_the_ai_tier_filter() {
533 let mut h = TestHarness::new().await;
534 let (_, handmade) = make_discoverable_item(&mut h, "aihand", "Handmade One", "audio").await;
535 let (_, assisted) = make_discoverable_item(&mut h, "aiasst", "Assisted One", "audio").await;
536 sqlx::query("UPDATE items SET ai_tier = 'handmade' WHERE id = $1::uuid")
537 .bind(&handmade)
538 .execute(&h.db)
539 .await
540 .expect("set handmade");
541 sqlx::query("UPDATE items SET ai_tier = 'assisted' WHERE id = $1::uuid")
542 .bind(&assisted)
543 .execute(&h.db)
544 .await
545 .expect("set assisted");
546
547 let mut filters = filters_for(&[]);
548 filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly);
549 let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
550 .await
551 .expect("item type counts");
552 let total: i64 = counts.iter().map(|c| c.count).sum();
553
554 assert_eq!(
555 total, 1,
556 "handmade_only must exclude the assisted item from the facet counts (was 2)"
557 );
558 }
559
560 /// The price filter survives a second interaction.
561 ///
562 /// The number inputs carry `class="discover-filter"`, so `hx-include` resends
563 /// them on every subsequent request. Without a rendered `value`, they come back
564 /// empty, `empty_string_as_none` maps that to "unfiltered", and the price filter
565 /// silently disappears the moment the user touches any other control.
566 #[tokio::test]
567 async fn price_filter_round_trips_into_its_inputs() {
568 let mut h = TestHarness::new().await;
569 make_discoverable_item(&mut h, "priceround", "Priced Thing", "audio").await;
570
571 let resp = h
572 .client
573 .get("/discover?mode=items&min_price=25&max_price=75.50")
574 .await;
575 assert_eq!(resp.status, 200, "{}", resp.status);
576 assert!(
577 resp.text.contains(r#"id="min-price""#),
578 "price inputs must render"
579 );
580 // Dollars in, dollars back out. The param was cents until 2026-08-04, which
581 // is how `20` came to mean twenty cents (loose-wire g2-16).
582 assert!(
583 resp.text.contains(r#"value="25""#),
584 "min_price must round-trip into its input or hx-include drops it"
585 );
586 assert!(
587 resp.text.contains(r#"value="75.50""#),
588 "max_price must round-trip into its input or hx-include drops it"
589 );
590 }
591
592 // Tag following.
593
594 /// The drill-down offers a follow control on every rung.
595 ///
596 /// Inverted 2026-07-20. Following used to be exact-match, `follows.target_id`
597 /// joined `item_tags.tag_id` directly, with no descendant expansion, and since
598 /// items carry depth-3+ leaves only, a follow on a category matched nothing
599 /// forever. The control was therefore withheld on navigational rungs, even
600 /// though the tag filter beside it did expand to descendants. Following is now
601 /// hierarchical too, so the two agree and every rung is followable.
602 #[tokio::test]
603 async fn follow_control_appears_on_every_rung() {
604 let mut h = TestHarness::new().await;
605 h.signup("followui", "followui@example.com", "password123")
606 .await;
607
608 // Root: depth-1 categories, navigational rather than assignable. Followable
609 // now that a follow on a branch covers its subtree.
610 let root = h.client.get("/discover?mode=items").await;
611 assert_eq!(root.status, 200, "{}", root.status);
612 assert!(
613 root.text.contains("tag-follow-btn"),
614 "a category follow now covers its whole subtree, so offer the control"
615 );
616 assert!(
617 root.text.contains("/api/follow/tag/"),
618 "the control must address the tag follow endpoint"
619 );
620
621 // Drilled to leaves: the control is still there.
622 let leaves = h
623 .client
624 .get("/discover?mode=items&browse=audio.genre")
625 .await;
626 assert_eq!(leaves.status, 200, "{}", leaves.status);
627 assert!(
628 leaves.text.contains("tag-follow-btn"),
629 "assignable leaves should still offer a follow control"
630 );
631 }
632
633 /// Anonymous visitors get no follow control, and cost no follow query.
634 #[tokio::test]
635 async fn follow_control_is_hidden_from_anonymous_visitors() {
636 let mut h = TestHarness::new().await;
637 let resp = h
638 .client
639 .get("/discover?mode=items&browse=audio.genre")
640 .await;
641 assert_eq!(resp.status, 200, "{}", resp.status);
642 assert!(
643 !resp.text.contains("tag-follow-btn"),
644 "a signed-out visitor has nothing to follow with"
645 );
646 }
647
648 /// Following a tag round-trips into the rendered control.
649 #[tokio::test]
650 async fn following_a_tag_is_reflected_in_the_sidebar() {
651 let mut h = TestHarness::new().await;
652 h.signup("followrt", "followrt@example.com", "password123")
653 .await;
654
655 let tag_id: uuid::Uuid =
656 sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'")
657 .fetch_one(&h.db)
658 .await
659 .expect("seeded leaf tag exists");
660
661 let before = h
662 .client
663 .get("/discover?mode=items&browse=audio.genre")
664 .await;
665 assert!(before.text.contains(">Follow<"), "should start unfollowed");
666
667 let resp = h
668 .client
669 .post_form(&format!("/api/follow/tag/{tag_id}"), "")
670 .await;
671 assert_eq!(resp.status, 200, "follow failed: {}", resp.status);
672
673 let after = h
674 .client
675 .get("/discover?mode=items&browse=audio.genre")
676 .await;
677 assert!(
678 after.text.contains(">Following<"),
679 "the sidebar must reflect the follow it just recorded"
680 );
681 }
682
683 // State survives the out-of-band swap
684 //
685 // All three were found by driving the page in a browser, not by the suite. The
686 // sidebar is replaced wholesale on every filter change, so anything carrying
687 // state has to live inside it; anything left outside silently stops tracking.
688
689 /// The state carriers live inside the swapped element.
690 ///
691 /// They used to sit in the page form, which the out-of-band swap never
692 /// replaces. A tag selected from one drill rung was dropped as soon as the
693 /// drill navigated away from its checkbox, because the hidden input that should
694 /// have carried it was never re-rendered.
695 #[tokio::test]
696 async fn state_carriers_live_inside_the_swapped_sidebar() {
697 let mut h = TestHarness::new().await;
698 let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await;
699 tag_item(&h, &item, "audio.genre.electronic").await;
700
701 // Browsing elsewhere while a tag is selected: the tag has no checkbox on
702 // screen, so a hidden input must carry it, and it must be in the sidebar.
703 let resp = h
704 .client
705 .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing")
706 .await;
707 assert_eq!(resp.status, 200, "{}", resp.status);
708
709 let sidebar_start = resp
710 .text
711 .find(r#"id="discover-sidebar""#)
712 .expect("sidebar renders");
713 let sidebar_end = resp.text[sidebar_start..]
714 .find("</aside>")
715 .expect("sidebar closes")
716 + sidebar_start;
717 let sidebar = &resp.text[sidebar_start..sidebar_end];
718
719 assert!(
720 sidebar.contains(r#"name="tag" value="audio.genre.electronic""#),
721 "the off-screen tag must be carried by a hidden input inside the sidebar"
722 );
723 assert!(
724 sidebar.contains(r#"name="browse""#),
725 "the drill cursor must be carried inside the sidebar too"
726 );
727 }
728
729 /// The drill cursor survives a filter interaction.
730 ///
731 /// Without a `browse` input in the `.discover-filter` set, every filter click
732 /// rebuilt the request without a cursor and the sidebar snapped back to the tag
733 /// roots, making the drill-down unusable for picking more than one tag.
734 #[tokio::test]
735 async fn drill_cursor_is_carried_as_a_filter_input() {
736 let mut h = TestHarness::new().await;
737 let resp = h
738 .client
739 .get("/discover?mode=items&browse=audio.genre")
740 .await;
741 assert_eq!(resp.status, 200, "{}", resp.status);
742 assert!(
743 resp.text.contains(r#"name="browse" value="audio.genre""#),
744 "the cursor must round-trip as a filter input, not only as a URL param"
745 );
746 }
747
748 /// The results partial refreshes the total count out of band.
749 ///
750 /// `#total-count` sits in the page form, outside `#results-container`, so a
751 /// plain swap left it reading the previous filter's total: the header said
752 /// "13 items" beside a list showing 3.
753 #[tokio::test]
754 async fn results_partial_refreshes_the_total_count() {
755 let mut h = TestHarness::new().await;
756 let resp = h.client.htmx_get("/discover/results?mode=items").await;
757 assert_eq!(resp.status, 200, "{}", resp.status);
758 assert!(
759 resp.text.contains(r#"id="total-count" hx-swap-oob="true""#),
760 "the total count must be swapped out of band or it goes stale"
761 );
762 }
763
764 /// The page and the out-of-band partial must render the same count text.
765 ///
766 /// Both emit `#total-count`, and the partial's copy replaces the page's on every
767 /// filter interaction. If they word it differently the label silently rewrites
768 /// itself on the first swap, which is the same class of bug as the count going
769 /// stale (it used to sit outside `#results-container` and read "13 items" beside
770 /// a list of 3). One helper builds the string for both; this checks nothing has
771 /// reintroduced a second source.
772 #[tokio::test]
773 async fn page_and_partial_word_the_count_identically() {
774 let mut h = TestHarness::new().await;
775
776 for query in ["mode=items", "mode=items&q=guitar", "mode=projects"] {
777 let page = h.client.get(&format!("/discover?{query}")).await;
778 let partial = h
779 .client
780 .htmx_get(&format!("/discover/results?{query}"))
781 .await;
782 assert_eq!(page.status, 200, "{}", page.status);
783 assert_eq!(partial.status, 200, "{}", partial.status);
784
785 let from_page = total_count_text(&page.text);
786 let from_partial = total_count_text(&partial.text);
787 assert_eq!(from_page, from_partial, "count copy disagrees for ?{query}");
788 }
789 }
790
791 /// Searching counts "results", browsing counts the thing.
792 ///
793 /// Membership is any typed word, so a large number under a search is mostly
794 /// partial matches. "N items" would assert N things matched; the number only
795 /// ever described the list.
796 #[tokio::test]
797 async fn search_counts_results_and_browse_counts_items() {
798 let mut h = TestHarness::new().await;
799
800 let browsing = h.client.get("/discover?mode=items").await;
801 let browsing_text = total_count_text(&browsing.text);
802 assert!(
803 browsing_text.ends_with("items") || browsing_text.ends_with("item"),
804 "browsing should count items, got {browsing_text:?}"
805 );
806
807 let searching = h.client.get("/discover?mode=items&q=guitar").await;
808 let searching_text = total_count_text(&searching.text);
809 assert!(
810 searching_text.ends_with("results") || searching_text.ends_with("result"),
811 "searching should count results, got {searching_text:?}"
812 );
813
814 // A whitespace-only term is browsing as far as the query is concerned, so
815 // the copy has to agree with it rather than reading the raw `?q=`.
816 let blank = h.client.get("/discover?mode=items&q=%20%20").await;
817 let blank_text = total_count_text(&blank.text);
818 assert!(
819 blank_text.ends_with("items") || blank_text.ends_with("item"),
820 "a whitespace-only search is a browse, got {blank_text:?}"
821 );
822 }
823
824 /// Text inside the `#total-count` span, whitespace-normalized.
825 fn total_count_text(html: &str) -> String {
826 let after_id = html
827 .split_once(r#"id="total-count""#)
828 .expect("response has no #total-count")
829 .1;
830 let inner = after_id
831 .split_once('>')
832 .expect("#total-count span is unterminated")
833 .1
834 .split_once("</span>")
835 .expect("#total-count span is unclosed")
836 .0;
837 inner.split_whitespace().collect::<Vec<_>>().join(" ")
838 }
839
840 // Markup/stylesheet contract.
841
842 /// Sidebar markup only uses class hooks the stylesheet defines.
843 ///
844 /// A class with no rule is invisible in review and silently unstyled in
845 /// production. `.visually-hidden` shipped this way: it does not exist in this
846 /// codebase (the utility is `.sr-only`), so the "Find a tag" label rendered as
847 /// visible body text until someone read the stylesheet.
848 #[tokio::test]
849 async fn sidebar_uses_only_defined_style_hooks() {
850 let css = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/static/style.css"))
851 .expect("read style.css");
852
853 // Every class the rebuilt sidebar introduces. Kept explicit rather than
854 // scraped from the template so adding a hook is a deliberate act.
855 for class in [
856 "tag-spine",
857 "tag-chips",
858 "tag-chip",
859 "tag-chip-label",
860 "tag-chip-remove",
861 "tag-chip-clear",
862 "tag-combobox",
863 "tag-combobox-input",
864 "tag-suggest-list",
865 "tag-suggest-item",
866 "tag-suggest-label",
867 "tag-crumbs",
868 "tag-drill-select",
869 "tag-drill-into",
870 "tag-drill-chevron",
871 "filter-refine",
872 "filter-fieldset",
873 "filter-check",
874 "price-buckets",
875 "price-bucket",
876 "sr-only",
877 // Search-result match reporting: the tier heading and the per-row
878 // "Matched 3 of 5 words" note. Unstyled, .results-tier-break renders as
879 // an unmarked row in the middle of the list, which reads as a result
880 // rather than as the boundary it is.
881 "results-tier-break",
882 "results-tier-label",
883 "results-tier-hint",
884 "row-match-note",
885 ] {
886 assert!(
887 css.contains(&format!(".{class}")),
888 "class {class} is used by discover but has no rule in style.css"
889 );
890 }
891
892 // The grid is `repeat(auto-fill, minmax(280px, 1fr))`, so the tier heading
893 // must span every column or it takes one card's slot and the two tiers
894 // interleave on screen while being correctly ordered in the DOM.
895 assert!(
896 css.contains("grid-column: 1 / -1"),
897 ".results-tier-break must span the grid, or the tiers interleave in grid view"
898 );
899
900 // The utility this codebase does not have. Guarding the name directly
901 // because reaching for it is the natural mistake.
902 assert!(
903 !css.contains(".visually-hidden"),
904 "use .sr-only; .visually-hidden is not this codebase's utility"
905 );
906 }
907
908 // Form/handler name contract.
909
910 /// Every filter parameter the handler reads is reachable from the markup.
911 ///
912 /// A form's control names are an untested contract with its handler. A rename
913 /// pass once rewrote `name="has_source"` to `name="sidebar.has_source"` and the
914 /// whole suite passed with the filter inert.
915 ///
916 /// The list below is hardcoded. `query_param_contract` in the handler is the
917 /// exhaustive destructure of `DiscoverQuery`, but it lives behind two private
918 /// module boundaries and this is a separate crate, so the two can drift: adding
919 /// a query parameter there does NOT fail anything here.
920 #[tokio::test]
921 async fn every_filter_param_has_a_control_in_the_markup() {
922 let mut h = TestHarness::new().await;
923 // Seed both modes so every facet renders something.
924 let setup = h
925 .create_creator_with_item("namecontract", "audio", 1000)
926 .await;
927 sqlx::query(
928 "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \
929 deleted_at = NULL WHERE id = $1::uuid",
930 )
931 .bind(&setup.item_id)
932 .execute(&h.db)
933 .await
934 .expect("publish item");
935 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
936 .bind(&setup.project_id)
937 .execute(&h.db)
938 .await
939 .expect("publish project");
940
941 // Items mode carries q, item_type, min_price, max_price, sort, mode, ai_tier.
942 let items = h.client.get("/discover?mode=items").await;
943 assert_eq!(items.status, 200, "{}", items.status);
944 for name in [
945 "q",
946 "item_type",
947 "min_price",
948 "max_price",
949 "sort",
950 "mode",
951 "ai_tier",
952 ] {
953 assert!(
954 items.text.contains(&format!(r#"name="{name}""#)),
955 "items mode must expose a control named {name}"
956 );
957 }
958
959 // `tag` is deliberately absent at the root cursor: the rungs there are
960 // depth-1 type roots, which cannot be assigned to an item and so render as
961 // navigation rather than checkboxes. It appears once the cursor reaches a
962 // level with assignable leaves (or via the typeahead, or as a hidden input
963 // once something is selected).
964 assert!(
965 !items.text.contains(r#"name="tag""#),
966 "the root rung has no assignable tags, so it should offer no tag control"
967 );
968 let drilled = h
969 .client
970 .get("/discover?mode=items&browse=audio.genre")
971 .await;
972 assert_eq!(drilled.status, 200, "{}", drilled.status);
973 assert!(
974 drilled.text.contains(r#"name="tag""#),
975 "drilling to a leaf level must expose a control named tag"
976 );
977
978 // Projects mode carries category and has_source instead of the item facets.
979 let projects = h.client.get("/discover?mode=projects").await;
980 assert_eq!(projects.status, 200, "{}", projects.status);
981 for name in ["q", "category", "has_source", "sort", "mode"] {
982 assert!(
983 projects.text.contains(&format!(r#"name="{name}""#)),
984 "projects mode must expose a control named {name}"
985 );
986 }
987
988 // No control may submit under a template field path, which is what the
989 // sidebar-extraction rename produced.
990 for body in [&items.text, &projects.text] {
991 assert!(
992 !body.contains(r#"name="sidebar."#),
993 "a form control is submitting under a template field path"
994 );
995 }
996 }
997
998 // Projects mode facets.
999
1000 /// The projects-mode filters submit under the names the handler reads.
1001 ///
1002 /// A rename refactor once rewrote `name="has_source"` to `name="sidebar.has_source"`
1003 /// and nothing failed: no test asserted the wire names, so the source filter was
1004 /// simply inert. Names are the contract between the form and `DiscoverQuery`.
1005 #[tokio::test]
1006 async fn projects_mode_filters_use_the_query_param_names() {
1007 let mut h = TestHarness::new().await;
1008
1009 let resp = h.client.get("/discover?mode=projects").await;
1010 assert_eq!(resp.status, 200, "{}", resp.status);
1011 assert!(
1012 resp.text.contains(r#"name="has_source""#),
1013 "the source filter must submit as has_source, the name DiscoverQuery reads"
1014 );
1015 assert!(
1016 resp.text.contains(r#"name="category""#),
1017 "the category facet must submit as category"
1018 );
1019 assert!(
1020 !resp.text.contains(r#"name="sidebar."#),
1021 "no form control may submit under a template field path"
1022 );
1023 }
1024
1025 /// Projects mode has no non-native controls left.
1026 #[tokio::test]
1027 async fn projects_mode_category_facet_is_a_native_control() {
1028 let mut h = TestHarness::new().await;
1029
1030 let resp = h.client.get("/discover?mode=projects").await;
1031 assert_eq!(resp.status, 200, "{}", resp.status);
1032 assert!(
1033 resp.text.contains(r#"type="radio" name="category""#),
1034 "category should be radios: it is single-valued server-side"
1035 );
1036 assert!(
1037 !resp.text.contains("filter-btn"),
1038 "no .filter-btn should remain; it needed JS to mirror its value into a hidden input"
1039 );
1040 }
1041
1042 /// The source filter actually narrows the project list.
1043 #[tokio::test]
1044 async fn has_source_filter_narrows_projects() {
1045 let mut h = TestHarness::new().await;
1046 let setup = h.create_creator_with_item("hassrc", "digital", 1000).await;
1047 sqlx::query(
1048 "UPDATE projects SET is_public = true, title = 'Sourced Project' WHERE id = $1::uuid",
1049 )
1050 .bind(&setup.project_id)
1051 .execute(&h.db)
1052 .await
1053 .expect("publish project");
1054
1055 // No git repo attached, so the filter must exclude it.
1056 let filtered = h.client.get("/discover?mode=projects&has_source=1").await;
1057 assert_eq!(filtered.status, 200, "{}", filtered.status);
1058 assert!(
1059 !filtered.text.contains("Sourced Project"),
1060 "a project with no git repo must not survive has_source=1"
1061 );
1062
1063 // Unfiltered, it shows.
1064 let all = h.client.get("/discover?mode=projects").await;
1065 assert!(
1066 all.text.contains("Sourced Project"),
1067 "the project should be listed without the filter"
1068 );
1069 }
1070
1071 // Out-of-band sidebar refresh.
1072
1073 /// The results partial carries the sidebar for an out-of-band swap.
1074 ///
1075 /// Faceted browsing is steered by the counts, so a sidebar that only refreshes
1076 /// on a full page load is showing numbers for a filter the user has already
1077 /// moved past.
1078 #[tokio::test]
1079 async fn results_partial_carries_the_sidebar_out_of_band() {
1080 let mut h = TestHarness::new().await;
1081 make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await;
1082
1083 let resp = h.client.htmx_get("/discover/results?mode=items").await;
1084 assert_eq!(resp.status, 200, "{}", resp.status);
1085 assert!(
1086 resp.text
1087 .contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#),
1088 "results must carry an OOB swap for the sidebar"
1089 );
1090 assert!(
1091 resp.text.contains(r#"id="discover-sidebar""#),
1092 "the OOB payload must include the sidebar element itself"
1093 );
1094 }
1095
1096 /// The out-of-band sidebar reflects the filter that produced the results.
1097 #[tokio::test]
1098 async fn oob_sidebar_counts_track_the_active_filter() {
1099 let mut h = TestHarness::new().await;
1100 make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await;
1101 make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await;
1102
1103 // Unfiltered: both types present, so the audio checkbox is not checked.
1104 let all = h.client.htmx_get("/discover/results?mode=items").await;
1105 assert!(
1106 all.text.contains(r#"id="typesel-audio""#),
1107 "audio facet should render"
1108 );
1109 assert!(
1110 all.text.contains(r#"id="typesel-video""#),
1111 "video facet should render"
1112 );
1113
1114 // Filtered to audio: the audio control comes back checked in the OOB payload.
1115 let audio = h
1116 .client
1117 .htmx_get("/discover/results?mode=items&item_type=audio")
1118 .await;
1119 assert!(
1120 audio.text.contains("checked"),
1121 "the OOB sidebar must reflect the active selection, not the previous one"
1122 );
1123 }
1124
1125 /// Every sidebar control carries a stable id.
1126 ///
1127 /// htmx restores focus after a swap only to an element that has one. Without
1128 /// ids, replacing the sidebar on every filter change would drop a keyboard
1129 /// user back to the document body each time they tick a box.
1130 #[tokio::test]
1131 async fn sidebar_controls_have_stable_ids_for_focus_restoration() {
1132 let mut h = TestHarness::new().await;
1133 make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await;
1134
1135 let resp = h.client.get("/discover?mode=items").await;
1136 assert_eq!(resp.status, 200, "{}", resp.status);
1137 assert!(
1138 resp.text.contains(r#"id="typesel-audio""#),
1139 "type checkboxes need ids"
1140 );
1141 assert!(
1142 resp.text.contains(r#"id="aitier-"#),
1143 "ai tier radios need ids"
1144 );
1145 assert!(
1146 resp.text.contains(r#"hx-preserve="true""#),
1147 "the typeahead input holds unrendered user state and must be preserved"
1148 );
1149 }
1150
1151 // Tag spine: drill-down and typeahead.
1152
1153 /// Drill-down counts roll up the whole subtree, not direct assignments.
1154 ///
1155 /// Items may only carry depth-3+ leaves, so a category's direct count is always
1156 /// zero. If the drill-down counted direct assignments every category would read
1157 /// 0 and the sidebar would be useless.
1158 #[tokio::test]
1159 async fn drill_down_counts_roll_up_the_subtree() {
1160 let mut h = TestHarness::new().await;
1161 let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await;
1162 tag_item(&h, &item, "audio.genre.electronic").await;
1163
1164 // At the root, the `audio` type must report the leaf two levels below it.
1165 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1166 .await
1167 .expect("root drill rows");
1168 let audio = roots
1169 .iter()
1170 .find(|r| r.tag_slug == "audio")
1171 .expect("audio root present");
1172 assert_eq!(audio.count, 1, "root count must roll up the whole subtree");
1173 assert!(!audio.assignable, "depth-1 type roots are not assignable");
1174 assert!(audio.has_children, "audio has categories beneath it");
1175 }
1176
1177 /// A category with no matching items is still listed, with a zero.
1178 ///
1179 /// Dropping empty rungs would make the taxonomy's shape flicker as filters
1180 /// change; showing a 0 tells the user the branch exists and is empty.
1181 #[tokio::test]
1182 async fn drill_down_keeps_empty_children_as_zero() {
1183 let h = TestHarness::new().await;
1184 // No items at all.
1185 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1186 .await
1187 .expect("root drill rows");
1188 assert!(
1189 !roots.is_empty(),
1190 "type roots must render even with an empty catalog"
1191 );
1192 assert!(
1193 roots.iter().all(|r| r.count == 0),
1194 "an empty catalog means every rung reads zero, not missing"
1195 );
1196 }
1197
1198 /// Drilling in returns the cursor's immediate children only.
1199 #[tokio::test]
1200 async fn drill_down_returns_immediate_children_of_the_cursor() {
1201 let h = TestHarness::new().await;
1202 let rows =
1203 makenotwork::db::tags::tag_children_with_counts(&h.db, Some("audio"), &filters_for(&[]))
1204 .await
1205 .expect("audio children");
1206 assert!(!rows.is_empty(), "audio has categories");
1207 assert!(
1208 rows.iter()
1209 .all(|r| r.tag_slug.starts_with("audio.") && r.tag_slug.matches('.').count() == 1),
1210 "only depth-2 children of audio, got {:?}",
1211 rows.iter().map(|r| &r.tag_slug).collect::<Vec<_>>()
1212 );
1213 }
1214
1215 /// The typeahead finds a leaf from a partial name, at any depth.
1216 #[tokio::test]
1217 async fn tag_typeahead_finds_a_leaf_by_partial_name() {
1218 let mut h = TestHarness::new().await;
1219 let resp = h.client.get("/discover/tag-suggest?q=electr").await;
1220 assert_eq!(resp.status, 200, "{}", resp.status);
1221 assert!(
1222 resp.text.contains("audio.genre.electronic"),
1223 "prefix search must reach a depth-3 leaf, got {}",
1224 resp.text
1225 );
1226 }
1227
1228 /// The typeahead never offers a tag that cannot filter.
1229 #[tokio::test]
1230 async fn tag_typeahead_omits_unassignable_categories() {
1231 let mut h = TestHarness::new().await;
1232 // "audio" and "audio.genre" both prefix-match, but neither is assignable.
1233 let resp = h.client.get("/discover/tag-suggest?q=audio.genre").await;
1234 assert_eq!(resp.status, 200, "{}", resp.status);
1235 assert!(
1236 !resp.text.contains(r#""slug":"audio.genre""#),
1237 "a depth-2 category must not be offered as a filter: {}",
1238 resp.text
1239 );
1240 }
1241
1242 // Faceted multi-select: OR within a facet, AND across facets.
1243
1244 /// Repeated `tag=` params are OR'd, and each still matches its own subtree.
1245 #[tokio::test]
1246 async fn repeated_tag_params_are_ored_within_the_facet() {
1247 let mut h = TestHarness::new().await;
1248 let (_, electronic) =
1249 make_discoverable_item(&mut h, "orelec", "Electronic Pick", "audio").await;
1250 let (_, ambient) = make_discoverable_item(&mut h, "oramb", "Ambient Pick", "audio").await;
1251 let (_, folk) = make_discoverable_item(&mut h, "orfolk", "Folk Pick", "audio").await;
1252 tag_item(&h, &electronic, "audio.genre.electronic").await;
1253 tag_item(&h, &ambient, "audio.genre.ambient").await;
1254 tag_item(&h, &folk, "audio.genre.folk").await;
1255
1256 let resp = h
1257 .client
1258 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.genre.ambient")
1259 .await;
1260 assert_eq!(resp.status, 200, "{}", resp.status);
1261 assert!(
1262 resp.text.contains("Electronic Pick"),
1263 "first selected tag must match"
1264 );
1265 assert!(
1266 resp.text.contains("Ambient Pick"),
1267 "second selected tag must match (OR, not AND)"
1268 );
1269 assert!(
1270 !resp.text.contains("Folk Pick"),
1271 "an unselected tag must not match"
1272 );
1273 }
1274
1275 /// A tag link that carries no `mode` filters items rather than landing on the
1276 /// unfiltered project list.
1277 ///
1278 /// Six templates emit `/discover?tag={slug}` with no mode (item, project, both
1279 /// players, and the two reader views), so this is every tag click from a
1280 /// content page. Projects mode has no tag dimension at all — `discover_projects`
1281 /// takes no tag argument and the sidebar only builds tag filters in items mode
1282 /// — so the URL used to claim a filter the query could not apply, and the page
1283 /// answered with every project, which reads as "this tag matches everything".
1284 #[tokio::test]
1285 async fn a_tag_link_without_a_mode_filters_items() {
1286 let mut h = TestHarness::new().await;
1287 let (_, electronic) =
1288 make_discoverable_item(&mut h, "bareelec", "Electronic Pick", "audio").await;
1289 let (_, folk) = make_discoverable_item(&mut h, "barefolk", "Folk Pick", "audio").await;
1290 tag_item(&h, &electronic, "audio.genre.electronic").await;
1291 tag_item(&h, &folk, "audio.genre.folk").await;
1292
1293 let resp = h.client.get("/discover?tag=audio.genre.electronic").await;
1294 assert_eq!(resp.status, 200);
1295 assert!(
1296 resp.text.contains("Electronic Pick"),
1297 "a bare tag link must return the tagged item",
1298 );
1299 assert!(
1300 !resp.text.contains("Folk Pick"),
1301 "a bare tag link must still exclude an unselected tag",
1302 );
1303 }
1304
1305 /// And the default is otherwise unchanged: no tag, no mode, still projects.
1306 /// Pinned because the tag rule above is a second default rather than a
1307 /// replacement, and the two are easy to collapse into one by accident.
1308 #[tokio::test]
1309 async fn discover_without_a_tag_still_defaults_to_projects() {
1310 let mut h = TestHarness::new().await;
1311 make_discoverable_item(&mut h, "defmode", "Mode Default Pick", "audio").await;
1312
1313 let resp = h.client.get("/discover").await;
1314 assert_eq!(resp.status, 200);
1315 assert!(
1316 resp.text.contains("project-row"),
1317 "the bare landing view is the project list",
1318 );
1319 }
1320
1321 /// Repeated `item_type=` params are OR'd.
1322 #[tokio::test]
1323 async fn repeated_item_type_params_are_ored_within_the_facet() {
1324 let mut h = TestHarness::new().await;
1325 make_discoverable_item(&mut h, "ortaudio", "Audio Thing", "audio").await;
1326 make_discoverable_item(&mut h, "ortsample", "Sample Thing", "sample").await;
1327 make_discoverable_item(&mut h, "ortvideo", "Video Thing", "video").await;
1328
1329 let resp = h
1330 .client
1331 .get("/discover?mode=items&item_type=audio&item_type=sample")
1332 .await;
1333 assert_eq!(resp.status, 200, "{}", resp.status);
1334 assert!(resp.text.contains("Audio Thing"));
1335 assert!(resp.text.contains("Sample Thing"));
1336 assert!(
1337 !resp.text.contains("Video Thing"),
1338 "unselected type must not match"
1339 );
1340 }
1341
1342 /// Different facets AND together, so the two selections intersect.
1343 #[tokio::test]
1344 async fn separate_facets_are_anded_together() {
1345 let mut h = TestHarness::new().await;
1346 let (_, match_both) = make_discoverable_item(&mut h, "andboth", "Matches Both", "audio").await;
1347 let (_, wrong_type) = make_discoverable_item(&mut h, "andtype", "Wrong Type", "video").await;
1348 let (_, wrong_tag) = make_discoverable_item(&mut h, "andtag", "Wrong Tag", "audio").await;
1349 tag_item(&h, &match_both, "audio.genre.electronic").await;
1350 tag_item(&h, &wrong_type, "audio.genre.electronic").await;
1351 tag_item(&h, &wrong_tag, "audio.genre.folk").await;
1352
1353 let resp = h
1354 .client
1355 .get("/discover?mode=items&item_type=audio&tag=audio.genre.electronic")
1356 .await;
1357 assert_eq!(resp.status, 200, "{}", resp.status);
1358 assert!(resp.text.contains("Matches Both"));
1359 assert!(
1360 !resp.text.contains("Wrong Type"),
1361 "tag matched but type did not: facets must AND"
1362 );
1363 assert!(
1364 !resp.text.contains("Wrong Tag"),
1365 "type matched but tag did not: facets must AND"
1366 );
1367 }
1368
1369 /// A multi-select survives the round-trip through the form's hidden inputs.
1370 ///
1371 /// `hx-include=".discover-filter"` rebuilds the next request from these inputs,
1372 /// so if several selected tags collapsed into one delimited value (or into the
1373 /// first value only) the second interaction would silently drop the filter.
1374 /// One input per value, no delimiter convention.
1375 #[tokio::test]
1376 async fn multi_select_round_trips_through_the_form_inputs() {
1377 let mut h = TestHarness::new().await;
1378 let (_, item) = make_discoverable_item(&mut h, "roundtrip", "Round Trip", "audio").await;
1379 tag_item(&h, &item, "audio.genre.electronic").await;
1380
1381 let resp = h
1382 .client
1383 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.mood.dark")
1384 .await;
1385 assert_eq!(resp.status, 200, "{}", resp.status);
1386
1387 let tag_inputs = resp.text.matches(r#"name="tag""#).count();
1388 assert_eq!(
1389 tag_inputs, 2,
1390 "both selected tags must be carried as separate hidden inputs, got {tag_inputs}"
1391 );
1392 assert!(resp.text.contains(r#"value="audio.genre.electronic""#));
1393 assert!(resp.text.contains(r#"value="audio.mood.dark""#));
1394 assert!(
1395 !resp.text.contains("audio.genre.electronic,audio.mood.dark"),
1396 "selections must not be joined into one delimited value"
1397 );
1398 }
1399
1400 /// A blank facet value is not a filter.
1401 ///
1402 /// `hx-include` ships every filter input on every request, so an untouched
1403 /// control arrives as `tag=`. That must mean "unfiltered", not "match the empty
1404 /// tag" (which would return nothing).
1405 #[tokio::test]
1406 async fn blank_repeated_params_do_not_filter() {
1407 let mut h = TestHarness::new().await;
1408 make_discoverable_item(&mut h, "blankp", "Still Visible", "audio").await;
1409
1410 let resp = h.client.get("/discover?mode=items&tag=&item_type=").await;
1411 assert_eq!(resp.status, 200, "{}", resp.status);
1412 assert!(
1413 resp.text.contains("Still Visible"),
1414 "blank filter values must not narrow the result set"
1415 );
1416 }
1417
1418 /// Tag counts obey the same visibility predicate as the results.
1419 ///
1420 /// `get_tag_counts` had no `users` join at all, so it never applied
1421 /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it
1422 /// count items that discover would never display.
1423 #[tokio::test]
1424 async fn tag_counts_exclude_sandbox_and_unscanned_items() {
1425 let mut h = TestHarness::new().await;
1426 let (sandbox_user, sandboxed) =
1427 make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await;
1428 let (_, quarantined) =
1429 make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await;
1430 let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await;
1431 for id in [&sandboxed, &quarantined, &visible] {
1432 tag_item(&h, id, "audio.genre.electronic").await;
1433 }
1434 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
1435 .bind(&sandbox_user)
1436 .execute(&h.db)
1437 .await
1438 .expect("sandbox the user");
1439 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
1440 .bind(&quarantined)
1441 .execute(&h.db)
1442 .await
1443 .expect("quarantine the item");
1444
1445 let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(&[]))
1446 .await
1447 .expect("tag counts");
1448 let leaf = counts
1449 .iter()
1450 .find(|c| c.tag_slug == "audio.genre.electronic")
1451 .expect("leaf tag counted");
1452
1453 assert_eq!(
1454 leaf.count, 1,
1455 "only the visible item may count; sandbox + quarantined must be excluded (was 3)"
1456 );
1457 }
1458