Skip to main content

max / makenotwork

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