Skip to main content

max / makenotwork

58.7 KB · 1599 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 (the search box's described candidate list)
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.
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 /// The route answers about the value it was given. It answered a JSON array
375 /// until `e3615a0d`; what it answers now is the described suggestion list, and
376 /// what is being asserted either way is that a typed value comes back as
377 /// something to offer.
378 #[tokio::test]
379 async fn the_suggestions_endpoint_answers_about_what_was_typed() {
380 let mut h = TestHarness::new().await;
381 make_discoverable_item(&mut h, "suggcreator", "Suggestion Probe", "digital").await;
382
383 // By the creator's name: the route offers tags, projects and creators, and
384 // the harness names the creator but not the project it makes.
385 let resp = h.client.get("/discover/suggestions?q=suggcreator").await;
386 assert_eq!(
387 resp.status, 200,
388 "GET suggestions: {} {}",
389 resp.status, resp.text
390 );
391 assert!(
392 resp.text.contains(r#"role="option""#),
393 "rows are options in the list the field owns: {}",
394 resp.text
395 );
396 assert!(
397 resp.text.contains("suggcreator"),
398 "the creator it was asked about is offered: {}",
399 resp.text
400 );
401 }
402
403 /// N8's second site. Each candidate is a page, so picking one replaces the
404 /// document: the row is a link the browser follows and htmx is not involved.
405 /// An `hx-get` here would land a whole page inside the suggestion row, which
406 /// is the failure `00ee7af5` was ruled on and the reason this waited.
407 #[tokio::test]
408 async fn a_suggested_page_is_a_link_the_browser_follows() {
409 let mut h = TestHarness::new().await;
410 make_discoverable_item(&mut h, "navcreator", "Navigating Probe", "digital").await;
411
412 let resp = h.client.get("/discover/suggestions?q=navcreator").await;
413 assert_eq!(resp.status, 200, "{}", resp.text);
414 assert!(
415 resp.text.contains("<a class=\"form-suggestion\""),
416 "a pick that navigates is an anchor: {}",
417 resp.text
418 );
419 assert!(
420 resp.text.contains("href=\"/u/navcreator\""),
421 "and it carries the page it goes to: {}",
422 resp.text
423 );
424 assert!(
425 !resp.text.contains("hx-get"),
426 "no htmx on a navigation: {}",
427 resp.text
428 );
429 // What kind of page it is, which is what tells two rows reading alike
430 // apart, and it is its own element rather than part of the label.
431 assert!(
432 resp.text.contains("form-suggestion-detail"),
433 "{}",
434 resp.text
435 );
436 }
437
438 /// Below the floor the box does not ask at all, and asked with nothing the
439 /// route answers nothing rather than an empty document: the answer replaces
440 /// the list the field owns.
441 #[tokio::test]
442 async fn an_empty_question_offers_nothing() {
443 let mut h = TestHarness::new().await;
444
445 let resp = h.client.get("/discover/suggestions?q=%20").await;
446 assert_eq!(resp.status, 200, "{}", resp.text);
447 assert!(resp.text.trim().is_empty(), "{}", resp.text);
448 }
449
450 #[tokio::test]
451 async fn empty_search_query_returns_all_listed_items() {
452 let mut h = TestHarness::new().await;
453 make_discoverable_item(&mut h, "emptyq1", "First Empty Q", "digital").await;
454 h.client.post_form("/logout", "").await;
455 make_discoverable_item(&mut h, "emptyq2", "Second Empty Q", "digital").await;
456
457 // q= (just spaces) should be treated as "no filter", the route strips
458 // whitespace-only q values before applying the search filter.
459 let resp = h.client.get("/discover?mode=items&q=%20%20").await;
460 assert_eq!(resp.status, 200, "{}", resp.text);
461 assert!(
462 resp.text.contains("First Empty Q"),
463 "Whitespace q should show all items"
464 );
465 assert!(resp.text.contains("Second Empty Q"));
466 }
467
468 #[tokio::test]
469 async fn tag_tree_endpoint_renders() {
470 let mut h = TestHarness::new().await;
471 // No items needed, the tag tree should render even when empty so the
472 // filter UI is always available.
473 let resp = h.client.get("/discover/tags").await;
474 assert_eq!(resp.status, 200, "GET /discover/tags: {}", resp.status);
475 }
476
477 // Facet-count correctness
478 //
479 // The sidebar counts are a separate query family from the result set, and they
480 // had drifted from it in three ways. These pin each one. Note that every other
481 // discover test in this file passed while all three were live: nothing here
482 // exercised a facet count, so the bugs were invisible to the suite.
483
484 /// Attach `slug` to `item_id`, creating the tag and its dot-path ancestry.
485 async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) {
486 let mut parent: Option<uuid::Uuid> = None;
487 let segments: Vec<&str> = slug.split('.').collect();
488 for depth in 1..=segments.len() {
489 let path = segments[..depth].join(".");
490 let name = segments[depth - 1];
491 let id: uuid::Uuid = sqlx::query_scalar(
492 "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \
493 ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id",
494 )
495 .bind(name)
496 .bind(&path)
497 .bind(parent)
498 .fetch_one(&h.db)
499 .await
500 .expect("upsert tag");
501 parent = Some(id);
502 }
503 sqlx::query(
504 "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING",
505 )
506 .bind(item_id)
507 .bind(parent.expect("leaf tag id"))
508 .execute(&h.db)
509 .await
510 .expect("attach tag");
511 }
512
513 fn filters_for(tags: &[String]) -> makenotwork::db::discover::DiscoverFilters<'_> {
514 makenotwork::db::discover::DiscoverFilters {
515 search: None,
516 item_types: &[],
517 tags,
518 min_price: None,
519 max_price: None,
520 sort_by: None,
521 ai_tier: None,
522 }
523 }
524
525 /// A tag filter matches the whole subtree, not just direct children.
526 ///
527 /// The result query has always matched descendants via `tags.path LIKE
528 /// 'prefix.%'`, but the facet counts used `parent_id = (SELECT id ... )`, which
529 /// stops at one level. An item tagged `audio.genre.electronic` therefore
530 /// appeared in the results for `?tag=audio` while contributing 0 to the
531 /// sidebar counts beside them.
532 #[tokio::test]
533 async fn facet_counts_match_the_whole_tag_subtree() {
534 let mut h = TestHarness::new().await;
535 let (_, item_id) = make_discoverable_item(&mut h, "facetdepth", "Deep Tagged", "audio").await;
536 tag_item(&h, &item_id, "audio.genre.electronic").await;
537
538 // `audio` is two levels above the item's tag.
539 let counts = makenotwork::db::discover::get_item_type_counts(
540 &h.db,
541 &filters_for(&["audio".to_string()]),
542 )
543 .await
544 .expect("item type counts");
545 let total: i64 = counts.iter().map(|c| c.count).sum();
546
547 assert_eq!(
548 total, 1,
549 "grandchild tag audio.genre.electronic must count under ?tag=audio \
550 (was 0 while the facets used parent_id instead of path)"
551 );
552 }
553
554 /// The AI-tier filter constrains the facet counts.
555 ///
556 /// `ai_tier` was applied to the result set but to none of the four facet
557 /// queries, so selecting "Handmade only" narrowed the listing while the sidebar
558 /// kept reporting counts for the unfiltered catalog.
559 /// Facet counts and results share `ITEM_SEARCH_CLAUSE`, that parity was the
560 /// point of commit a668df6f. Widening the clause to match descriptions must
561 /// therefore widen the counts by the same amount, or the sidebar would report a
562 /// zero next to a facet the results list is populating.
563 #[tokio::test]
564 async fn facet_counts_widen_with_description_matches() {
565 let mut h = TestHarness::new().await;
566 let (_, item_id) =
567 make_discoverable_item(&mut h, "facetdesc", "Nothing Relevant", "audio").await;
568 sqlx::query("UPDATE items SET description = 'a hand-built theremin kit' WHERE id = $1::uuid")
569 .bind(&item_id)
570 .execute(&h.db)
571 .await
572 .expect("set description");
573
574 let mut filters = filters_for(&[]);
575 let term = String::from("theremin");
576 filters.search = Some(&term);
577 let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
578 .await
579 .expect("item type counts");
580 let total: i64 = counts.iter().map(|c| c.count).sum();
581
582 assert_eq!(
583 total, 1,
584 "facet counts must count a description-only match, matching the results query"
585 );
586 }
587
588 #[tokio::test]
589 async fn facet_counts_apply_the_ai_tier_filter() {
590 let mut h = TestHarness::new().await;
591 let (_, handmade) = make_discoverable_item(&mut h, "aihand", "Handmade One", "audio").await;
592 let (_, assisted) = make_discoverable_item(&mut h, "aiasst", "Assisted One", "audio").await;
593 sqlx::query("UPDATE items SET ai_tier = 'handmade' WHERE id = $1::uuid")
594 .bind(&handmade)
595 .execute(&h.db)
596 .await
597 .expect("set handmade");
598 sqlx::query("UPDATE items SET ai_tier = 'assisted' WHERE id = $1::uuid")
599 .bind(&assisted)
600 .execute(&h.db)
601 .await
602 .expect("set assisted");
603
604 let mut filters = filters_for(&[]);
605 filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly);
606 let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
607 .await
608 .expect("item type counts");
609 let total: i64 = counts.iter().map(|c| c.count).sum();
610
611 assert_eq!(
612 total, 1,
613 "handmade_only must exclude the assisted item from the facet counts (was 2)"
614 );
615 }
616
617 /// The price filter survives a second interaction.
618 ///
619 /// The number inputs carry `class="discover-filter"`, so `hx-include` resends
620 /// them on every subsequent request. Without a rendered `value`, they come back
621 /// empty, `empty_string_as_none` maps that to "unfiltered", and the price filter
622 /// silently disappears the moment the user touches any other control.
623 #[tokio::test]
624 async fn price_filter_round_trips_into_its_inputs() {
625 let mut h = TestHarness::new().await;
626 make_discoverable_item(&mut h, "priceround", "Priced Thing", "audio").await;
627
628 let resp = h
629 .client
630 .get("/discover?mode=items&min_price=25&max_price=75.50")
631 .await;
632 assert_eq!(resp.status, 200, "{}", resp.status);
633 assert!(
634 resp.text.contains(r#"id="min-price""#),
635 "price inputs must render"
636 );
637 // Dollars in, dollars back out. The param was cents until 2026-08-04, which
638 // is how `20` came to mean twenty cents (loose-wire g2-16).
639 assert!(
640 resp.text.contains(r#"value="25""#),
641 "min_price must round-trip into its input or hx-include drops it"
642 );
643 assert!(
644 resp.text.contains(r#"value="75.50""#),
645 "max_price must round-trip into its input or hx-include drops it"
646 );
647 }
648
649 // Tag following.
650
651 /// The drill-down offers a follow control on every rung.
652 ///
653 /// Following is hierarchical, like the tag filter beside it, so every rung is
654 /// followable. Exact-match following (`follows.target_id` joined
655 /// `item_tags.tag_id` with no descendant expansion) matches nothing on a
656 /// category, since items carry depth-3+ leaves only.
657 #[tokio::test]
658 async fn follow_control_appears_on_every_rung() {
659 let mut h = TestHarness::new().await;
660 h.signup("followui", "followui@example.com", "password123")
661 .await;
662
663 // Root: depth-1 categories, navigational rather than assignable. Followable
664 // now that a follow on a branch covers its subtree.
665 let root = h.client.get("/discover?mode=items").await;
666 assert_eq!(root.status, 200, "{}", root.status);
667 assert!(
668 root.text.contains("tag-follow-btn"),
669 "a category follow now covers its whole subtree, so offer the control"
670 );
671 assert!(
672 root.text.contains("/api/follow/tag/"),
673 "the control must address the tag follow endpoint"
674 );
675
676 // Drilled to leaves: the control is still there.
677 let leaves = h
678 .client
679 .get("/discover?mode=items&browse=audio.genre")
680 .await;
681 assert_eq!(leaves.status, 200, "{}", leaves.status);
682 assert!(
683 leaves.text.contains("tag-follow-btn"),
684 "assignable leaves should still offer a follow control"
685 );
686 }
687
688 /// Anonymous visitors get no follow control, and cost no follow query.
689 #[tokio::test]
690 async fn follow_control_is_hidden_from_anonymous_visitors() {
691 let mut h = TestHarness::new().await;
692 let resp = h
693 .client
694 .get("/discover?mode=items&browse=audio.genre")
695 .await;
696 assert_eq!(resp.status, 200, "{}", resp.status);
697 assert!(
698 !resp.text.contains("tag-follow-btn"),
699 "a signed-out visitor has nothing to follow with"
700 );
701 }
702
703 /// Following a tag round-trips into the rendered control.
704 #[tokio::test]
705 async fn following_a_tag_is_reflected_in_the_sidebar() {
706 let mut h = TestHarness::new().await;
707 h.signup("followrt", "followrt@example.com", "password123")
708 .await;
709
710 let tag_id: uuid::Uuid =
711 sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'")
712 .fetch_one(&h.db)
713 .await
714 .expect("seeded leaf tag exists");
715
716 let before = h
717 .client
718 .get("/discover?mode=items&browse=audio.genre")
719 .await;
720 assert!(before.text.contains(">Follow<"), "should start unfollowed");
721
722 let resp = h
723 .client
724 .post_form(&format!("/api/follow/tag/{tag_id}"), "")
725 .await;
726 assert_eq!(resp.status, 200, "follow failed: {}", resp.status);
727
728 let after = h
729 .client
730 .get("/discover?mode=items&browse=audio.genre")
731 .await;
732 assert!(
733 after.text.contains(">Following<"),
734 "the sidebar must reflect the follow it just recorded"
735 );
736 }
737
738 // State survives the out-of-band swap
739 //
740 // All three were found by driving the page in a browser, not by the suite. The
741 // sidebar is replaced wholesale on every filter change, so anything carrying
742 // state has to live inside it; anything left outside silently stops tracking.
743
744 /// The state carriers live inside the swapped element.
745 ///
746 /// They used to sit in the page form, which the out-of-band swap never
747 /// replaces. A tag selected from one drill rung was dropped as soon as the
748 /// drill navigated away from its checkbox, because the hidden input that should
749 /// have carried it was never re-rendered.
750 #[tokio::test]
751 async fn state_carriers_live_inside_the_swapped_sidebar() {
752 let mut h = TestHarness::new().await;
753 let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await;
754 tag_item(&h, &item, "audio.genre.electronic").await;
755
756 // Browsing elsewhere while a tag is selected: the tag has no checkbox on
757 // screen, so a hidden input must carry it, and it must be in the sidebar.
758 let resp = h
759 .client
760 .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing")
761 .await;
762 assert_eq!(resp.status, 200, "{}", resp.status);
763
764 let sidebar_start = resp
765 .text
766 .find(r#"id="discover-sidebar""#)
767 .expect("sidebar renders");
768 let sidebar_end = resp.text[sidebar_start..]
769 .find("</aside>")
770 .expect("sidebar closes")
771 + sidebar_start;
772 let sidebar = &resp.text[sidebar_start..sidebar_end];
773
774 assert!(
775 sidebar.contains(r#"name="tag" value="audio.genre.electronic""#),
776 "the off-screen tag must be carried by a hidden input inside the sidebar"
777 );
778 assert!(
779 sidebar.contains(r#"name="browse""#),
780 "the drill cursor must be carried inside the sidebar too"
781 );
782 }
783
784 /// The drill cursor survives a filter interaction.
785 ///
786 /// Without a `browse` input in the `.discover-filter` set, every filter click
787 /// rebuilt the request without a cursor and the sidebar snapped back to the tag
788 /// roots, making the drill-down unusable for picking more than one tag.
789 #[tokio::test]
790 async fn drill_cursor_is_carried_as_a_filter_input() {
791 let mut h = TestHarness::new().await;
792 let resp = h
793 .client
794 .get("/discover?mode=items&browse=audio.genre")
795 .await;
796 assert_eq!(resp.status, 200, "{}", resp.status);
797 assert!(
798 resp.text.contains(r#"name="browse" value="audio.genre""#),
799 "the cursor must round-trip as a filter input, not only as a URL param"
800 );
801 }
802
803 /// The results partial refreshes the total count out of band.
804 ///
805 /// `#total-count` sits in the page form, outside `#results-container`, so a
806 /// plain swap left it reading the previous filter's total: the header said
807 /// "13 items" beside a list showing 3.
808 #[tokio::test]
809 async fn results_partial_refreshes_the_total_count() {
810 let mut h = TestHarness::new().await;
811 let resp = h.client.htmx_get("/discover/results?mode=items").await;
812 assert_eq!(resp.status, 200, "{}", resp.status);
813 assert!(
814 resp.text.contains(r#"id="total-count" hx-swap-oob="true""#),
815 "the total count must be swapped out of band or it goes stale"
816 );
817 }
818
819 /// The page and the out-of-band partial must render the same count text.
820 ///
821 /// Both emit `#total-count`, and the partial's copy replaces the page's on every
822 /// filter interaction. If they word it differently the label silently rewrites
823 /// itself on the first swap, which is the same class of bug as the count going
824 /// stale (it used to sit outside `#results-container` and read "13 items" beside
825 /// a list of 3). One helper builds the string for both; this checks nothing has
826 /// reintroduced a second source.
827 #[tokio::test]
828 async fn page_and_partial_word_the_count_identically() {
829 let mut h = TestHarness::new().await;
830
831 for query in ["mode=items", "mode=items&q=guitar", "mode=projects"] {
832 let page = h.client.get(&format!("/discover?{query}")).await;
833 let partial = h
834 .client
835 .htmx_get(&format!("/discover/results?{query}"))
836 .await;
837 assert_eq!(page.status, 200, "{}", page.status);
838 assert_eq!(partial.status, 200, "{}", partial.status);
839
840 let from_page = total_count_text(&page.text);
841 let from_partial = total_count_text(&partial.text);
842 assert_eq!(from_page, from_partial, "count copy disagrees for ?{query}");
843 }
844 }
845
846 /// Searching counts "results", browsing counts the thing.
847 ///
848 /// Membership is any typed word, so a large number under a search is mostly
849 /// partial matches. "N items" would assert N things matched; the number only
850 /// ever described the list.
851 #[tokio::test]
852 async fn search_counts_results_and_browse_counts_items() {
853 let mut h = TestHarness::new().await;
854
855 let browsing = h.client.get("/discover?mode=items").await;
856 let browsing_text = total_count_text(&browsing.text);
857 assert!(
858 browsing_text.ends_with("items") || browsing_text.ends_with("item"),
859 "browsing should count items, got {browsing_text:?}"
860 );
861
862 let searching = h.client.get("/discover?mode=items&q=guitar").await;
863 let searching_text = total_count_text(&searching.text);
864 assert!(
865 searching_text.ends_with("results") || searching_text.ends_with("result"),
866 "searching should count results, got {searching_text:?}"
867 );
868
869 // A whitespace-only term is browsing as far as the query is concerned, so
870 // the copy has to agree with it rather than reading the raw `?q=`.
871 let blank = h.client.get("/discover?mode=items&q=%20%20").await;
872 let blank_text = total_count_text(&blank.text);
873 assert!(
874 blank_text.ends_with("items") || blank_text.ends_with("item"),
875 "a whitespace-only search is a browse, got {blank_text:?}"
876 );
877 }
878
879 /// Text inside the `#total-count` span, whitespace-normalized.
880 fn total_count_text(html: &str) -> String {
881 let after_id = html
882 .split_once(r#"id="total-count""#)
883 .expect("response has no #total-count")
884 .1;
885 let inner = after_id
886 .split_once('>')
887 .expect("#total-count span is unterminated")
888 .1
889 .split_once("</span>")
890 .expect("#total-count span is unclosed")
891 .0;
892 inner.split_whitespace().collect::<Vec<_>>().join(" ")
893 }
894
895 // Markup/stylesheet contract.
896
897 /// Sidebar markup only uses class hooks the stylesheet defines.
898 ///
899 /// A class with no rule is invisible in review and silently unstyled in
900 /// production. `.visually-hidden` shipped this way: it does not exist in this
901 /// codebase (the utility is `.sr-only`), so the "Find a tag" label rendered as
902 /// visible body text until someone read the stylesheet.
903 #[tokio::test]
904 async fn sidebar_uses_only_defined_style_hooks() {
905 let css = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/static/style.css"))
906 .expect("read style.css");
907
908 // Every class the rebuilt sidebar introduces. Kept explicit rather than
909 // scraped from the template so adding a hook is a deliberate act.
910 for class in [
911 "tag-spine",
912 "tag-chips",
913 "tag-chip",
914 "tag-chip-label",
915 "tag-chip-remove",
916 "tag-chip-clear",
917 "tag-combobox",
918 "tag-crumbs",
919 "tag-drill-select",
920 "tag-drill-into",
921 "tag-drill-chevron",
922 "filter-refine",
923 "filter-fieldset",
924 "filter-check",
925 "price-buckets",
926 "price-bucket",
927 "sr-only",
928 // Search-result match reporting: the tier heading and the per-row
929 // "Matched 3 of 5 words" note. Unstyled, .results-tier-break renders as
930 // an unmarked row in the middle of the list, which reads as a result
931 // rather than as the boundary it is.
932 "results-tier-break",
933 "results-tier-label",
934 "results-tier-hint",
935 "row-match-note",
936 ] {
937 assert!(
938 css.contains(&format!(".{class}")),
939 "class {class} is used by discover but has no rule in style.css"
940 );
941 }
942
943 // The grid is `repeat(auto-fill, minmax(280px, 1fr))`, so the tier heading
944 // must span every column or it takes one card's slot and the two tiers
945 // interleave on screen while being correctly ordered in the DOM.
946 assert!(
947 css.contains("grid-column: 1 / -1"),
948 ".results-tier-break must span the grid, or the tiers interleave in grid view"
949 );
950
951 // The utility this codebase does not have. Guarding the name directly
952 // because reaching for it is the natural mistake.
953 assert!(
954 !css.contains(".visually-hidden"),
955 "use .sr-only; .visually-hidden is not this codebase's utility"
956 );
957 }
958
959 // Form/handler name contract.
960
961 /// Every filter parameter the handler reads is reachable from the markup.
962 ///
963 /// A form's control names are an untested contract with its handler. A rename
964 /// pass once rewrote `name="has_source"` to `name="sidebar.has_source"` and the
965 /// whole suite passed with the filter inert.
966 ///
967 /// The list below is hardcoded. `query_param_contract` in the handler is the
968 /// exhaustive destructure of `DiscoverQuery`, but it lives behind two private
969 /// module boundaries and this is a separate crate, so the two can drift: adding
970 /// a query parameter there does NOT fail anything here.
971 #[tokio::test]
972 async fn every_filter_param_has_a_control_in_the_markup() {
973 let mut h = TestHarness::new().await;
974 // Seed both modes so every facet renders something.
975 let setup = h
976 .create_creator_with_item("namecontract", "audio", 1000)
977 .await;
978 sqlx::query(
979 "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \
980 deleted_at = NULL WHERE id = $1::uuid",
981 )
982 .bind(&setup.item_id)
983 .execute(&h.db)
984 .await
985 .expect("publish item");
986 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
987 .bind(&setup.project_id)
988 .execute(&h.db)
989 .await
990 .expect("publish project");
991
992 // Items mode carries q, item_type, min_price, max_price, sort, mode, ai_tier.
993 let items = h.client.get("/discover?mode=items").await;
994 assert_eq!(items.status, 200, "{}", items.status);
995 for name in [
996 "q",
997 "item_type",
998 "min_price",
999 "max_price",
1000 "sort",
1001 "mode",
1002 "ai_tier",
1003 ] {
1004 assert!(
1005 items.text.contains(&format!(r#"name="{name}""#)),
1006 "items mode must expose a control named {name}"
1007 );
1008 }
1009
1010 // `tag` is deliberately absent at the root cursor: the rungs there are
1011 // depth-1 type roots, which cannot be assigned to an item and so render as
1012 // navigation rather than checkboxes. It appears once the cursor reaches a
1013 // level with assignable leaves (or via the typeahead, or as a hidden input
1014 // once something is selected).
1015 assert!(
1016 !items.text.contains(r#"name="tag""#),
1017 "the root rung has no assignable tags, so it should offer no tag control"
1018 );
1019 let drilled = h
1020 .client
1021 .get("/discover?mode=items&browse=audio.genre")
1022 .await;
1023 assert_eq!(drilled.status, 200, "{}", drilled.status);
1024 assert!(
1025 drilled.text.contains(r#"name="tag""#),
1026 "drilling to a leaf level must expose a control named tag"
1027 );
1028
1029 // Projects mode carries category and has_source instead of the item facets.
1030 let projects = h.client.get("/discover?mode=projects").await;
1031 assert_eq!(projects.status, 200, "{}", projects.status);
1032 for name in ["q", "category", "has_source", "sort", "mode"] {
1033 assert!(
1034 projects.text.contains(&format!(r#"name="{name}""#)),
1035 "projects mode must expose a control named {name}"
1036 );
1037 }
1038
1039 // No control may submit under a template field path, which is what the
1040 // sidebar-extraction rename produced.
1041 for body in [&items.text, &projects.text] {
1042 assert!(
1043 !body.contains(r#"name="sidebar."#),
1044 "a form control is submitting under a template field path"
1045 );
1046 }
1047 }
1048
1049 // Projects mode facets.
1050
1051 /// The projects-mode filters submit under the names the handler reads.
1052 ///
1053 /// A rename refactor once rewrote `name="has_source"` to `name="sidebar.has_source"`
1054 /// and nothing failed: no test asserted the wire names, so the source filter was
1055 /// simply inert. Names are the contract between the form and `DiscoverQuery`.
1056 #[tokio::test]
1057 async fn projects_mode_filters_use_the_query_param_names() {
1058 let mut h = TestHarness::new().await;
1059
1060 let resp = h.client.get("/discover?mode=projects").await;
1061 assert_eq!(resp.status, 200, "{}", resp.status);
1062 assert!(
1063 resp.text.contains(r#"name="has_source""#),
1064 "the source filter must submit as has_source, the name DiscoverQuery reads"
1065 );
1066 assert!(
1067 resp.text.contains(r#"name="category""#),
1068 "the category facet must submit as category"
1069 );
1070 assert!(
1071 !resp.text.contains(r#"name="sidebar."#),
1072 "no form control may submit under a template field path"
1073 );
1074 }
1075
1076 /// Projects mode has no non-native controls left.
1077 #[tokio::test]
1078 async fn projects_mode_category_facet_is_a_native_control() {
1079 let mut h = TestHarness::new().await;
1080
1081 let resp = h.client.get("/discover?mode=projects").await;
1082 assert_eq!(resp.status, 200, "{}", resp.status);
1083 assert!(
1084 resp.text.contains(r#"type="radio" name="category""#),
1085 "category should be radios: it is single-valued server-side"
1086 );
1087 assert!(
1088 !resp.text.contains("filter-btn"),
1089 "no .filter-btn should remain; it needed JS to mirror its value into a hidden input"
1090 );
1091 }
1092
1093 /// The source filter actually narrows the project list.
1094 #[tokio::test]
1095 async fn has_source_filter_narrows_projects() {
1096 let mut h = TestHarness::new().await;
1097 let setup = h.create_creator_with_item("hassrc", "digital", 1000).await;
1098 sqlx::query(
1099 "UPDATE projects SET is_public = true, title = 'Sourced Project' WHERE id = $1::uuid",
1100 )
1101 .bind(&setup.project_id)
1102 .execute(&h.db)
1103 .await
1104 .expect("publish project");
1105
1106 // No git repo attached, so the filter must exclude it.
1107 let filtered = h.client.get("/discover?mode=projects&has_source=1").await;
1108 assert_eq!(filtered.status, 200, "{}", filtered.status);
1109 assert!(
1110 !filtered.text.contains("Sourced Project"),
1111 "a project with no git repo must not survive has_source=1"
1112 );
1113
1114 // Unfiltered, it shows.
1115 let all = h.client.get("/discover?mode=projects").await;
1116 assert!(
1117 all.text.contains("Sourced Project"),
1118 "the project should be listed without the filter"
1119 );
1120 }
1121
1122 // Out-of-band sidebar refresh.
1123
1124 /// The results partial carries the sidebar for an out-of-band swap.
1125 ///
1126 /// Faceted browsing is steered by the counts, so a sidebar that only refreshes
1127 /// on a full page load is showing numbers for a filter the user has already
1128 /// moved past.
1129 #[tokio::test]
1130 async fn results_partial_carries_the_sidebar_out_of_band() {
1131 let mut h = TestHarness::new().await;
1132 make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await;
1133
1134 let resp = h.client.htmx_get("/discover/results?mode=items").await;
1135 assert_eq!(resp.status, 200, "{}", resp.status);
1136 assert!(
1137 resp.text
1138 .contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#),
1139 "results must carry an OOB swap for the sidebar"
1140 );
1141 assert!(
1142 resp.text.contains(r#"id="discover-sidebar""#),
1143 "the OOB payload must include the sidebar element itself"
1144 );
1145 }
1146
1147 /// The out-of-band sidebar reflects the filter that produced the results.
1148 #[tokio::test]
1149 async fn oob_sidebar_counts_track_the_active_filter() {
1150 let mut h = TestHarness::new().await;
1151 make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await;
1152 make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await;
1153
1154 // Unfiltered: both types present, so the audio checkbox is not checked.
1155 let all = h.client.htmx_get("/discover/results?mode=items").await;
1156 assert!(
1157 all.text.contains(r#"id="typesel-audio""#),
1158 "audio facet should render"
1159 );
1160 assert!(
1161 all.text.contains(r#"id="typesel-video""#),
1162 "video facet should render"
1163 );
1164
1165 // Filtered to audio: the audio control comes back checked in the OOB payload.
1166 let audio = h
1167 .client
1168 .htmx_get("/discover/results?mode=items&item_type=audio")
1169 .await;
1170 assert!(
1171 audio.text.contains("checked"),
1172 "the OOB sidebar must reflect the active selection, not the previous one"
1173 );
1174 }
1175
1176 /// Every sidebar control carries a stable id.
1177 ///
1178 /// htmx restores focus after a swap only to an element that has one. Without
1179 /// ids, replacing the sidebar on every filter change would drop a keyboard
1180 /// user back to the document body each time they tick a box.
1181 #[tokio::test]
1182 async fn sidebar_controls_have_stable_ids_for_focus_restoration() {
1183 let mut h = TestHarness::new().await;
1184 make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await;
1185
1186 let resp = h.client.get("/discover?mode=items").await;
1187 assert_eq!(resp.status, 200, "{}", resp.status);
1188 assert!(
1189 resp.text.contains(r#"id="typesel-audio""#),
1190 "type checkboxes need ids"
1191 );
1192 assert!(
1193 resp.text.contains(r#"id="aitier-"#),
1194 "ai tier radios need ids"
1195 );
1196 // The typeahead box is described (N8), and it is addressed by the field's
1197 // own name, so focus restoration has an id to find it back by.
1198 assert!(
1199 resp.text.contains(r#"id="tag-search""#),
1200 "the typeahead box needs an id for focus restoration"
1201 );
1202 // And it keeps what was typed. The sidebar rides along out of band on every
1203 // filter change, so without this, three letters of a tag plus a tick on an
1204 // unrelated facet emptied the box. Said by the description now
1205 // (`Field::keeping_value`, quasicoherent `a135f898`) rather than by an
1206 // attribute written into the template, which is where it was before the
1207 // port dropped it.
1208 assert!(
1209 resp.text.contains("hx-preserve"),
1210 "the typeahead box loses a half-typed tag on every facet tick"
1211 );
1212 }
1213
1214 // Tag spine: drill-down and typeahead.
1215
1216 /// Drill-down counts roll up the whole subtree, not direct assignments.
1217 ///
1218 /// Items may only carry depth-3+ leaves, so a category's direct count is always
1219 /// zero. If the drill-down counted direct assignments every category would read
1220 /// 0 and the sidebar would be useless.
1221 #[tokio::test]
1222 async fn drill_down_counts_roll_up_the_subtree() {
1223 let mut h = TestHarness::new().await;
1224 let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await;
1225 tag_item(&h, &item, "audio.genre.electronic").await;
1226
1227 // At the root, the `audio` type must report the leaf two levels below it.
1228 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1229 .await
1230 .expect("root drill rows");
1231 let audio = roots
1232 .iter()
1233 .find(|r| r.tag_slug == "audio")
1234 .expect("audio root present");
1235 assert_eq!(audio.count, 1, "root count must roll up the whole subtree");
1236 assert!(!audio.assignable, "depth-1 type roots are not assignable");
1237 assert!(audio.has_children, "audio has categories beneath it");
1238 }
1239
1240 /// A category with no matching items is still listed, with a zero.
1241 ///
1242 /// Dropping empty rungs would make the taxonomy's shape flicker as filters
1243 /// change; showing a 0 tells the user the branch exists and is empty.
1244 #[tokio::test]
1245 async fn drill_down_keeps_empty_children_as_zero() {
1246 let h = TestHarness::new().await;
1247 // No items at all.
1248 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1249 .await
1250 .expect("root drill rows");
1251 assert!(
1252 !roots.is_empty(),
1253 "type roots must render even with an empty catalog"
1254 );
1255 assert!(
1256 roots.iter().all(|r| r.count == 0),
1257 "an empty catalog means every rung reads zero, not missing"
1258 );
1259 }
1260
1261 /// Drilling in returns the cursor's immediate children only.
1262 #[tokio::test]
1263 async fn drill_down_returns_immediate_children_of_the_cursor() {
1264 let h = TestHarness::new().await;
1265 let rows =
1266 makenotwork::db::tags::tag_children_with_counts(&h.db, Some("audio"), &filters_for(&[]))
1267 .await
1268 .expect("audio children");
1269 assert!(!rows.is_empty(), "audio has categories");
1270 assert!(
1271 rows.iter()
1272 .all(|r| r.tag_slug.starts_with("audio.") && r.tag_slug.matches('.').count() == 1),
1273 "only depth-2 children of audio, got {:?}",
1274 rows.iter().map(|r| &r.tag_slug).collect::<Vec<_>>()
1275 );
1276 }
1277
1278 /// The typeahead finds a leaf from a partial name, at any depth.
1279 ///
1280 /// The typed value arrives under the described field's own name, which is what
1281 /// a consult sends it under. `q` on this route is the search box's value riding
1282 /// along as one of the filters a pick carries forward.
1283 #[tokio::test]
1284 async fn tag_typeahead_finds_a_leaf_by_partial_name() {
1285 let mut h = TestHarness::new().await;
1286 let resp = h
1287 .client
1288 .get("/discover/tag-suggest?tag-search=electr")
1289 .await;
1290 assert_eq!(resp.status, 200, "{}", resp.status);
1291 assert!(
1292 resp.text.contains("audio.genre.electronic"),
1293 "prefix search must reach a depth-3 leaf, got {}",
1294 resp.text
1295 );
1296 }
1297
1298 /// The typeahead never offers a tag that cannot filter.
1299 #[tokio::test]
1300 async fn tag_typeahead_omits_unassignable_categories() {
1301 let mut h = TestHarness::new().await;
1302 // "audio" and "audio.genre" both prefix-match, but neither is assignable.
1303 let resp = h
1304 .client
1305 .get("/discover/tag-suggest?tag-search=audio.genre")
1306 .await;
1307 assert_eq!(resp.status, 200, "{}", resp.status);
1308 // Asked of what a pick would add, not of what a row reads: every row here
1309 // draws `audio.genre` as its second line, which is the parent path doing
1310 // its job rather than a category being offered.
1311 assert!(
1312 !resp.text.contains("tag=audio.genre\"") && !resp.text.contains("tag=audio.genre&"),
1313 "a depth-2 category must not be offered as a filter: {}",
1314 resp.text
1315 );
1316 }
1317
1318 /// N8. The dropdown is the described suggestion list, so the route answers the
1319 /// markup a renderer drew rather than JSON a hand-written renderer parsed.
1320 #[tokio::test]
1321 async fn the_typeahead_answers_the_described_list() {
1322 let mut h = TestHarness::new().await;
1323 let resp = h
1324 .client
1325 .get("/discover/tag-suggest?tag-search=electr")
1326 .await;
1327 assert_eq!(resp.status, 200, "{}", resp.status);
1328 assert!(
1329 resp.text.contains(r#"role="option""#),
1330 "rows are options in the list the field owns: {}",
1331 resp.text
1332 );
1333 // The parent path is the second line, which is what tells four rows reading
1334 // "Format" apart.
1335 assert!(
1336 resp.text.contains("form-suggestion-detail"),
1337 "a candidate carries its second line: {}",
1338 resp.text
1339 );
1340 }
1341
1342 /// Picking a tag is the call the drill-down checkbox makes, under the filters
1343 /// the box sent with the question. Before N8 this was a `choose()` in
1344 /// `page-discover.js` that built a hidden input by hand.
1345 #[tokio::test]
1346 async fn a_suggested_tag_carries_the_filters_it_is_being_added_to() {
1347 let mut h = TestHarness::new().await;
1348 let resp = h
1349 .client
1350 .get("/discover/tag-suggest?tag-search=electr&mode=items&sort=newest")
1351 .await;
1352 assert_eq!(resp.status, 200, "{}", resp.status);
1353 assert!(
1354 resp.text.contains("hx-get=\"/discover/results"),
1355 "a pick calls the results route: {}",
1356 resp.text
1357 );
1358 assert!(
1359 resp.text.contains("mode=items") && resp.text.contains("sort=newest"),
1360 "and carries the screen it was offered under: {}",
1361 resp.text
1362 );
1363 assert!(
1364 resp.text.contains("tag=audio.genre.electronic"),
1365 "and the tag it adds: {}",
1366 resp.text
1367 );
1368 }
1369
1370 /// Nothing but the filters travels. A caller appending its own parameters to
1371 /// the question does not get them echoed into every candidate's address.
1372 #[tokio::test]
1373 async fn the_typeahead_carries_only_the_filters_it_says_it_does() {
1374 let mut h = TestHarness::new().await;
1375 let resp = h
1376 .client
1377 .get("/discover/tag-suggest?tag-search=electr&smuggled=yes")
1378 .await;
1379 assert_eq!(resp.status, 200, "{}", resp.status);
1380 assert!(!resp.text.contains("smuggled"), "{}", resp.text);
1381 }
1382
1383 // Faceted multi-select: OR within a facet, AND across facets.
1384
1385 /// Repeated `tag=` params are OR'd, and each still matches its own subtree.
1386 #[tokio::test]
1387 async fn repeated_tag_params_are_ored_within_the_facet() {
1388 let mut h = TestHarness::new().await;
1389 let (_, electronic) =
1390 make_discoverable_item(&mut h, "orelec", "Electronic Pick", "audio").await;
1391 let (_, ambient) = make_discoverable_item(&mut h, "oramb", "Ambient Pick", "audio").await;
1392 let (_, folk) = make_discoverable_item(&mut h, "orfolk", "Folk Pick", "audio").await;
1393 tag_item(&h, &electronic, "audio.genre.electronic").await;
1394 tag_item(&h, &ambient, "audio.genre.ambient").await;
1395 tag_item(&h, &folk, "audio.genre.folk").await;
1396
1397 let resp = h
1398 .client
1399 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.genre.ambient")
1400 .await;
1401 assert_eq!(resp.status, 200, "{}", resp.status);
1402 assert!(
1403 resp.text.contains("Electronic Pick"),
1404 "first selected tag must match"
1405 );
1406 assert!(
1407 resp.text.contains("Ambient Pick"),
1408 "second selected tag must match (OR, not AND)"
1409 );
1410 assert!(
1411 !resp.text.contains("Folk Pick"),
1412 "an unselected tag must not match"
1413 );
1414 }
1415
1416 /// A tag link that carries no `mode` filters items rather than landing on the
1417 /// unfiltered project list.
1418 ///
1419 /// Six templates emit `/discover?tag={slug}` with no mode (item, project, both
1420 /// players, and the two reader views), so this is every tag click from a
1421 /// content page. Projects mode has no tag dimension at all — `discover_projects`
1422 /// takes no tag argument and the sidebar only builds tag filters in items mode
1423 /// — so the URL used to claim a filter the query could not apply, and the page
1424 /// answered with every project, which reads as "this tag matches everything".
1425 #[tokio::test]
1426 async fn a_tag_link_without_a_mode_filters_items() {
1427 let mut h = TestHarness::new().await;
1428 let (_, electronic) =
1429 make_discoverable_item(&mut h, "bareelec", "Electronic Pick", "audio").await;
1430 let (_, folk) = make_discoverable_item(&mut h, "barefolk", "Folk Pick", "audio").await;
1431 tag_item(&h, &electronic, "audio.genre.electronic").await;
1432 tag_item(&h, &folk, "audio.genre.folk").await;
1433
1434 let resp = h.client.get("/discover?tag=audio.genre.electronic").await;
1435 assert_eq!(resp.status, 200);
1436 assert!(
1437 resp.text.contains("Electronic Pick"),
1438 "a bare tag link must return the tagged item",
1439 );
1440 assert!(
1441 !resp.text.contains("Folk Pick"),
1442 "a bare tag link must still exclude an unselected tag",
1443 );
1444 }
1445
1446 /// And the default is otherwise unchanged: no tag, no mode, still projects.
1447 /// Pinned because the tag rule above is a second default rather than a
1448 /// replacement, and the two are easy to collapse into one by accident.
1449 #[tokio::test]
1450 async fn discover_without_a_tag_still_defaults_to_projects() {
1451 let mut h = TestHarness::new().await;
1452 make_discoverable_item(&mut h, "defmode", "Mode Default Pick", "audio").await;
1453
1454 let resp = h.client.get("/discover").await;
1455 assert_eq!(resp.status, 200);
1456 assert!(
1457 resp.text.contains("project-row"),
1458 "the bare landing view is the project list",
1459 );
1460 }
1461
1462 /// Repeated `item_type=` params are OR'd.
1463 #[tokio::test]
1464 async fn repeated_item_type_params_are_ored_within_the_facet() {
1465 let mut h = TestHarness::new().await;
1466 make_discoverable_item(&mut h, "ortaudio", "Audio Thing", "audio").await;
1467 make_discoverable_item(&mut h, "ortsample", "Sample Thing", "sample").await;
1468 make_discoverable_item(&mut h, "ortvideo", "Video Thing", "video").await;
1469
1470 let resp = h
1471 .client
1472 .get("/discover?mode=items&item_type=audio&item_type=sample")
1473 .await;
1474 assert_eq!(resp.status, 200, "{}", resp.status);
1475 assert!(resp.text.contains("Audio Thing"));
1476 assert!(resp.text.contains("Sample Thing"));
1477 assert!(
1478 !resp.text.contains("Video Thing"),
1479 "unselected type must not match"
1480 );
1481 }
1482
1483 /// Different facets AND together, so the two selections intersect.
1484 #[tokio::test]
1485 async fn separate_facets_are_anded_together() {
1486 let mut h = TestHarness::new().await;
1487 let (_, match_both) = make_discoverable_item(&mut h, "andboth", "Matches Both", "audio").await;
1488 let (_, wrong_type) = make_discoverable_item(&mut h, "andtype", "Wrong Type", "video").await;
1489 let (_, wrong_tag) = make_discoverable_item(&mut h, "andtag", "Wrong Tag", "audio").await;
1490 tag_item(&h, &match_both, "audio.genre.electronic").await;
1491 tag_item(&h, &wrong_type, "audio.genre.electronic").await;
1492 tag_item(&h, &wrong_tag, "audio.genre.folk").await;
1493
1494 let resp = h
1495 .client
1496 .get("/discover?mode=items&item_type=audio&tag=audio.genre.electronic")
1497 .await;
1498 assert_eq!(resp.status, 200, "{}", resp.status);
1499 assert!(resp.text.contains("Matches Both"));
1500 assert!(
1501 !resp.text.contains("Wrong Type"),
1502 "tag matched but type did not: facets must AND"
1503 );
1504 assert!(
1505 !resp.text.contains("Wrong Tag"),
1506 "type matched but tag did not: facets must AND"
1507 );
1508 }
1509
1510 /// A multi-select survives the round-trip through the form's hidden inputs.
1511 ///
1512 /// `hx-include=".discover-filter"` rebuilds the next request from these inputs,
1513 /// so if several selected tags collapsed into one delimited value (or into the
1514 /// first value only) the second interaction would silently drop the filter.
1515 /// One input per value, no delimiter convention.
1516 #[tokio::test]
1517 async fn multi_select_round_trips_through_the_form_inputs() {
1518 let mut h = TestHarness::new().await;
1519 let (_, item) = make_discoverable_item(&mut h, "roundtrip", "Round Trip", "audio").await;
1520 tag_item(&h, &item, "audio.genre.electronic").await;
1521
1522 let resp = h
1523 .client
1524 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.mood.dark")
1525 .await;
1526 assert_eq!(resp.status, 200, "{}", resp.status);
1527
1528 let tag_inputs = resp.text.matches(r#"name="tag""#).count();
1529 assert_eq!(
1530 tag_inputs, 2,
1531 "both selected tags must be carried as separate hidden inputs, got {tag_inputs}"
1532 );
1533 assert!(resp.text.contains(r#"value="audio.genre.electronic""#));
1534 assert!(resp.text.contains(r#"value="audio.mood.dark""#));
1535 assert!(
1536 !resp.text.contains("audio.genre.electronic,audio.mood.dark"),
1537 "selections must not be joined into one delimited value"
1538 );
1539 }
1540
1541 /// A blank facet value is not a filter.
1542 ///
1543 /// `hx-include` ships every filter input on every request, so an untouched
1544 /// control arrives as `tag=`. That must mean "unfiltered", not "match the empty
1545 /// tag" (which would return nothing).
1546 #[tokio::test]
1547 async fn blank_repeated_params_do_not_filter() {
1548 let mut h = TestHarness::new().await;
1549 make_discoverable_item(&mut h, "blankp", "Still Visible", "audio").await;
1550
1551 let resp = h.client.get("/discover?mode=items&tag=&item_type=").await;
1552 assert_eq!(resp.status, 200, "{}", resp.status);
1553 assert!(
1554 resp.text.contains("Still Visible"),
1555 "blank filter values must not narrow the result set"
1556 );
1557 }
1558
1559 /// Tag counts obey the same visibility predicate as the results.
1560 ///
1561 /// `get_tag_counts` had no `users` join at all, so it never applied
1562 /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it
1563 /// count items that discover would never display.
1564 #[tokio::test]
1565 async fn tag_counts_exclude_sandbox_and_unscanned_items() {
1566 let mut h = TestHarness::new().await;
1567 let (sandbox_user, sandboxed) =
1568 make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await;
1569 let (_, quarantined) =
1570 make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await;
1571 let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await;
1572 for id in [&sandboxed, &quarantined, &visible] {
1573 tag_item(&h, id, "audio.genre.electronic").await;
1574 }
1575 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
1576 .bind(&sandbox_user)
1577 .execute(&h.db)
1578 .await
1579 .expect("sandbox the user");
1580 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
1581 .bind(&quarantined)
1582 .execute(&h.db)
1583 .await
1584 .expect("quarantine the item");
1585
1586 let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(&[]))
1587 .await
1588 .expect("tag counts");
1589 let leaf = counts
1590 .iter()
1591 .find(|c| c.tag_slug == "audio.genre.electronic")
1592 .expect("leaf tag counted");
1593
1594 assert_eq!(
1595 leaf.count, 1,
1596 "only the visible item may count; sandbox + quarantined must be excluded (was 3)"
1597 );
1598 }
1599