Skip to main content

max / makenotwork

58.7 KB · 1596 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 (ultra-fuzz Run 11 Perf SER-3).
51 async fn project_item_count(h: &TestHarness, project_id: &str) -> i32 {
52 sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1::uuid")
53 .bind(project_id)
54 .fetch_one(&h.db)
55 .await
56 .expect("read item_count")
57 }
58
59 #[tokio::test]
60 async fn project_item_count_tracks_active_item_lifecycle() {
61 let mut h = TestHarness::new().await;
62 let setup = h
63 .create_creator_with_item("counttrack", "audio", 1000)
64 .await;
65 let pid = setup.project_id.clone();
66
67 // Active: public + listed + not deleted.
68 sqlx::query(
69 "UPDATE items SET is_public = true, listed = true, deleted_at = NULL WHERE id = $1::uuid",
70 )
71 .bind(&setup.item_id)
72 .execute(&h.db)
73 .await
74 .expect("activate item");
75 assert_eq!(project_item_count(&h, &pid).await, 1, "active item counts");
76
77 // Unlist -> drops out of the active set.
78 sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
79 .bind(&setup.item_id)
80 .execute(&h.db)
81 .await
82 .expect("unlist");
83 assert_eq!(
84 project_item_count(&h, &pid).await,
85 0,
86 "unlisted item not counted"
87 );
88
89 // Relist -> back in.
90 sqlx::query("UPDATE items SET listed = true WHERE id = $1::uuid")
91 .bind(&setup.item_id)
92 .execute(&h.db)
93 .await
94 .expect("relist");
95 assert_eq!(
96 project_item_count(&h, &pid).await,
97 1,
98 "relisted item counts again"
99 );
100
101 // Soft-delete -> drops out.
102 sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
103 .bind(&setup.item_id)
104 .execute(&h.db)
105 .await
106 .expect("soft delete");
107 assert_eq!(
108 project_item_count(&h, &pid).await,
109 0,
110 "soft-deleted item not counted"
111 );
112 }
113
114 #[tokio::test]
115 async fn discover_page_renders_for_anonymous_visitor() {
116 let mut h = TestHarness::new().await;
117 let resp = h.client.get("/discover").await;
118 assert_eq!(
119 resp.status, 200,
120 "GET /discover: {} {}",
121 resp.status, resp.text
122 );
123 // Must contain the discover landmark, used by HTMX swaps + screen readers.
124 assert!(
125 resp.text.contains("discover") || resp.text.to_lowercase().contains("discover"),
126 "Discover page should contain 'discover' marker"
127 );
128 }
129
130 #[tokio::test]
131 async fn search_finds_published_item_by_title() {
132 let mut h = TestHarness::new().await;
133 let (_creator, _item) =
134 make_discoverable_item(&mut h, "creator1", "Searchable Widget", "digital").await;
135
136 // Default mode is "projects", items mode is opt-in via `?mode=items`.
137 let resp = h.client.get("/discover?mode=items&q=Searchable").await;
138 assert_eq!(resp.status, 200, "{}", resp.text);
139 assert!(
140 resp.text.contains("Searchable Widget"),
141 "Search by title should find the item; body did not contain it"
142 );
143 }
144
145 /// The relevance expression has always scored `similarity(description) * 0.5`,
146 /// but the match clause only looked at the title, an item whose description was
147 /// a perfect match ranked as if it matched and was then never returned.
148 #[tokio::test]
149 async fn search_finds_published_item_by_description() {
150 let mut h = TestHarness::new().await;
151 let (_creator, item_id) =
152 make_discoverable_item(&mut h, "desccreator", "Unrelated Title", "digital").await;
153 sqlx::query("UPDATE items SET description = 'A hand-built theremin kit' WHERE id = $1::uuid")
154 .bind(&item_id)
155 .execute(&h.db)
156 .await
157 .unwrap();
158
159 let resp = h.client.get("/discover?mode=items&q=theremin").await;
160 assert_eq!(resp.status, 200, "{}", resp.text);
161 assert!(
162 resp.text.contains("Unrelated Title"),
163 "Search should match on description, not title only"
164 );
165 }
166
167 /// The 0.5 weighting on description similarity is the reason the relevance
168 /// expression is written the way it is. Widening the match clause must not
169 /// flatten that ordering.
170 #[tokio::test]
171 async fn title_match_outranks_description_only_match() {
172 let mut h = TestHarness::new().await;
173 let (_, desc_item) =
174 make_discoverable_item(&mut h, "rankdesc", "Something Else", "digital").await;
175 sqlx::query(
176 "UPDATE items SET description = 'mentions theremin in passing' WHERE id = $1::uuid",
177 )
178 .bind(&desc_item)
179 .execute(&h.db)
180 .await
181 .unwrap();
182 let (_, title_item) = make_discoverable_item(&mut h, "ranktitle", "Theremin", "digital").await;
183 sqlx::query("UPDATE items SET description = 'no match here' WHERE id = $1::uuid")
184 .bind(&title_item)
185 .execute(&h.db)
186 .await
187 .unwrap();
188
189 // No explicit sort: a search term makes match_score DESC the default order.
190 let resp = h.client.get("/discover?mode=items&q=theremin").await;
191 assert_eq!(resp.status, 200, "{}", resp.text);
192 let title_pos = resp
193 .text
194 .find("Theremin")
195 .expect("title match should be returned");
196 let desc_pos = resp
197 .text
198 .find("Something Else")
199 .expect("description match should be returned");
200 assert!(
201 title_pos < desc_pos,
202 "Title match must outrank a description-only match (title at {title_pos}, description at {desc_pos})"
203 );
204 }
205
206 #[tokio::test]
207 async fn search_does_not_leak_draft_items() {
208 let mut h = TestHarness::new().await;
209 let setup = h
210 .create_creator_with_item("draftcreator", "audio", 1000)
211 .await;
212 // Both items.is_public and projects.is_public default to TRUE (see
213 // migrations/001_initial_schema.sql lines 71, 86), "draft" means the
214 // creator explicitly toggled `is_public=false`. Set it directly.
215 sqlx::query(
216 "UPDATE items SET title = 'Sneaky Draft Title', is_public = false WHERE id = $1::uuid",
217 )
218 .bind(&setup.item_id)
219 .execute(&h.db)
220 .await
221 .unwrap();
222
223 let resp = h.client.get("/discover?mode=items&q=Sneaky").await;
224 assert_eq!(resp.status, 200, "{}", resp.text);
225 assert!(
226 !resp.text.contains("Sneaky Draft Title"),
227 "Discover must not return draft (is_public=false) items"
228 );
229 }
230
231 #[tokio::test]
232 async fn search_excludes_quarantined_items() {
233 let mut h = TestHarness::new().await;
234 let (_, item_id) =
235 make_discoverable_item(&mut h, "quarcreator", "Quarantine Sentinel", "digital").await;
236
237 // Manually flip scan_status to quarantined, discover should drop it.
238 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
239 .bind(&item_id)
240 .execute(&h.db)
241 .await
242 .unwrap();
243
244 let resp = h.client.get("/discover?mode=items&q=Quarantine").await;
245 assert_eq!(resp.status, 200, "{}", resp.text);
246 assert!(
247 !resp.text.contains("Quarantine Sentinel"),
248 "Quarantined items must not surface in discover"
249 );
250 }
251
252 #[tokio::test]
253 async fn search_excludes_unlisted_items() {
254 let mut h = TestHarness::new().await;
255 let (_, item_id) =
256 make_discoverable_item(&mut h, "unlistedcreator", "Unlisted Marker", "digital").await;
257
258 // `listed = false` is the "public via direct URL but not in discover" mode.
259 sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid")
260 .bind(&item_id)
261 .execute(&h.db)
262 .await
263 .unwrap();
264
265 let resp = h.client.get("/discover?mode=items&q=Unlisted").await;
266 assert_eq!(resp.status, 200, "{}", resp.text);
267 assert!(
268 !resp.text.contains("Unlisted Marker"),
269 "listed=false items must not surface in discover"
270 );
271 }
272
273 #[tokio::test]
274 async fn search_excludes_sandbox_users() {
275 let mut h = TestHarness::new().await;
276 let (creator_id, _item) =
277 make_discoverable_item(&mut h, "sandboxcreator", "Sandbox Hidden", "digital").await;
278
279 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
280 .bind(&creator_id)
281 .execute(&h.db)
282 .await
283 .unwrap();
284
285 let resp = h.client.get("/discover?mode=items&q=Sandbox").await;
286 assert_eq!(resp.status, 200, "{}", resp.text);
287 assert!(
288 !resp.text.contains("Sandbox Hidden"),
289 "Sandbox users' items must not surface in discover"
290 );
291 }
292
293 #[tokio::test]
294 async fn search_excludes_soft_deleted_items() {
295 let mut h = TestHarness::new().await;
296 let (_, item_id) =
297 make_discoverable_item(&mut h, "delcreator", "Deleted Marker", "digital").await;
298
299 // Soft-delete keeps the row but sets deleted_at; discover must drop it.
300 sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid")
301 .bind(&item_id)
302 .execute(&h.db)
303 .await
304 .unwrap();
305
306 let resp = h.client.get("/discover?mode=items&q=Deleted").await;
307 assert_eq!(resp.status, 200, "{}", resp.text);
308 assert!(
309 !resp.text.contains("Deleted Marker"),
310 "Soft-deleted items must not surface in discover"
311 );
312 }
313
314 #[tokio::test]
315 async fn item_type_filter_narrows_results() {
316 let mut h = TestHarness::new().await;
317 make_discoverable_item(&mut h, "audiocreator", "AudioOnly Title", "audio").await;
318 h.client.post_form("/logout", "").await;
319 make_discoverable_item(&mut h, "softcreator", "SoftwareOnly Title", "digital").await;
320
321 // Filter to audio only, software item must be absent.
322 let resp = h.client.get("/discover?mode=items&item_type=audio").await;
323 assert_eq!(resp.status, 200, "{}", resp.text);
324 assert!(
325 resp.text.contains("AudioOnly Title"),
326 "Audio item should appear"
327 );
328 assert!(
329 !resp.text.contains("SoftwareOnly Title"),
330 "Software item must NOT appear under item_type=audio"
331 );
332 }
333
334 #[tokio::test]
335 async fn projects_mode_lists_projects_not_items() {
336 let mut h = TestHarness::new().await;
337 let setup = h
338 .create_creator_with_item("projmode", "digital", 1000)
339 .await;
340 // Rename project to a distinctive title.
341 h.client
342 .put_json(
343 &format!("/api/projects/{}", setup.project_id),
344 r#"{"title":"Discover Project Mode","is_public":true}"#,
345 )
346 .await;
347 h.publish_project_and_item(&setup.project_id, &setup.item_id)
348 .await;
349
350 let resp = h.client.get("/discover?mode=projects").await;
351 assert_eq!(resp.status, 200, "{}", resp.text);
352 assert!(
353 resp.text.contains("Discover Project Mode"),
354 "Project should appear in projects mode"
355 );
356 }
357
358 #[tokio::test]
359 async fn results_partial_is_htmx_swappable() {
360 let mut h = TestHarness::new().await;
361 make_discoverable_item(&mut h, "partialcreator", "Partial Visible", "digital").await;
362
363 // /discover/results returns the inner partial used by HTMX filter swaps.
364 // It must NOT include the full page chrome (header, footer, <html>).
365 let resp = h.client.htmx_get("/discover/results?mode=items").await;
366 assert_eq!(resp.status, 200, "GET /discover/results: {}", resp.status);
367 assert!(resp.text.contains("Partial Visible"));
368 assert!(
369 !resp.text.contains("<html") && !resp.text.contains("<!DOCTYPE"),
370 "Results partial must not include full-page chrome"
371 );
372 }
373
374 /// 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 /// Inverted 2026-07-20. Following used to be exact-match, `follows.target_id`
654 /// joined `item_tags.tag_id` directly, with no descendant expansion, and since
655 /// items carry depth-3+ leaves only, a follow on a category matched nothing
656 /// forever. The control was therefore withheld on navigational rungs, even
657 /// though the tag filter beside it did expand to descendants. Following is now
658 /// hierarchical too, so the two agree and every rung is followable.
659 #[tokio::test]
660 async fn follow_control_appears_on_every_rung() {
661 let mut h = TestHarness::new().await;
662 h.signup("followui", "followui@example.com", "password123")
663 .await;
664
665 // Root: depth-1 categories, navigational rather than assignable. Followable
666 // now that a follow on a branch covers its subtree.
667 let root = h.client.get("/discover?mode=items").await;
668 assert_eq!(root.status, 200, "{}", root.status);
669 assert!(
670 root.text.contains("tag-follow-btn"),
671 "a category follow now covers its whole subtree, so offer the control"
672 );
673 assert!(
674 root.text.contains("/api/follow/tag/"),
675 "the control must address the tag follow endpoint"
676 );
677
678 // Drilled to leaves: the control is still there.
679 let leaves = h
680 .client
681 .get("/discover?mode=items&browse=audio.genre")
682 .await;
683 assert_eq!(leaves.status, 200, "{}", leaves.status);
684 assert!(
685 leaves.text.contains("tag-follow-btn"),
686 "assignable leaves should still offer a follow control"
687 );
688 }
689
690 /// Anonymous visitors get no follow control, and cost no follow query.
691 #[tokio::test]
692 async fn follow_control_is_hidden_from_anonymous_visitors() {
693 let mut h = TestHarness::new().await;
694 let resp = h
695 .client
696 .get("/discover?mode=items&browse=audio.genre")
697 .await;
698 assert_eq!(resp.status, 200, "{}", resp.status);
699 assert!(
700 !resp.text.contains("tag-follow-btn"),
701 "a signed-out visitor has nothing to follow with"
702 );
703 }
704
705 /// Following a tag round-trips into the rendered control.
706 #[tokio::test]
707 async fn following_a_tag_is_reflected_in_the_sidebar() {
708 let mut h = TestHarness::new().await;
709 h.signup("followrt", "followrt@example.com", "password123")
710 .await;
711
712 let tag_id: uuid::Uuid =
713 sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'")
714 .fetch_one(&h.db)
715 .await
716 .expect("seeded leaf tag exists");
717
718 let before = h
719 .client
720 .get("/discover?mode=items&browse=audio.genre")
721 .await;
722 assert!(before.text.contains(">Follow<"), "should start unfollowed");
723
724 let resp = h
725 .client
726 .post_form(&format!("/api/follow/tag/{tag_id}"), "")
727 .await;
728 assert_eq!(resp.status, 200, "follow failed: {}", resp.status);
729
730 let after = h
731 .client
732 .get("/discover?mode=items&browse=audio.genre")
733 .await;
734 assert!(
735 after.text.contains(">Following<"),
736 "the sidebar must reflect the follow it just recorded"
737 );
738 }
739
740 // State survives the out-of-band swap
741 //
742 // All three were found by driving the page in a browser, not by the suite. The
743 // sidebar is replaced wholesale on every filter change, so anything carrying
744 // state has to live inside it; anything left outside silently stops tracking.
745
746 /// The state carriers live inside the swapped element.
747 ///
748 /// They used to sit in the page form, which the out-of-band swap never
749 /// replaces. A tag selected from one drill rung was dropped as soon as the
750 /// drill navigated away from its checkbox, because the hidden input that should
751 /// have carried it was never re-rendered.
752 #[tokio::test]
753 async fn state_carriers_live_inside_the_swapped_sidebar() {
754 let mut h = TestHarness::new().await;
755 let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await;
756 tag_item(&h, &item, "audio.genre.electronic").await;
757
758 // Browsing elsewhere while a tag is selected: the tag has no checkbox on
759 // screen, so a hidden input must carry it, and it must be in the sidebar.
760 let resp = h
761 .client
762 .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing")
763 .await;
764 assert_eq!(resp.status, 200, "{}", resp.status);
765
766 let sidebar_start = resp
767 .text
768 .find(r#"id="discover-sidebar""#)
769 .expect("sidebar renders");
770 let sidebar_end = resp.text[sidebar_start..]
771 .find("</aside>")
772 .expect("sidebar closes")
773 + sidebar_start;
774 let sidebar = &resp.text[sidebar_start..sidebar_end];
775
776 assert!(
777 sidebar.contains(r#"name="tag" value="audio.genre.electronic""#),
778 "the off-screen tag must be carried by a hidden input inside the sidebar"
779 );
780 assert!(
781 sidebar.contains(r#"name="browse""#),
782 "the drill cursor must be carried inside the sidebar too"
783 );
784 }
785
786 /// The drill cursor survives a filter interaction.
787 ///
788 /// Without a `browse` input in the `.discover-filter` set, every filter click
789 /// rebuilt the request without a cursor and the sidebar snapped back to the tag
790 /// roots, making the drill-down unusable for picking more than one tag.
791 #[tokio::test]
792 async fn drill_cursor_is_carried_as_a_filter_input() {
793 let mut h = TestHarness::new().await;
794 let resp = h
795 .client
796 .get("/discover?mode=items&browse=audio.genre")
797 .await;
798 assert_eq!(resp.status, 200, "{}", resp.status);
799 assert!(
800 resp.text.contains(r#"name="browse" value="audio.genre""#),
801 "the cursor must round-trip as a filter input, not only as a URL param"
802 );
803 }
804
805 /// The results partial refreshes the total count out of band.
806 ///
807 /// `#total-count` sits in the page form, outside `#results-container`, so a
808 /// plain swap left it reading the previous filter's total: the header said
809 /// "13 items" beside a list showing 3.
810 #[tokio::test]
811 async fn results_partial_refreshes_the_total_count() {
812 let mut h = TestHarness::new().await;
813 let resp = h.client.htmx_get("/discover/results?mode=items").await;
814 assert_eq!(resp.status, 200, "{}", resp.status);
815 assert!(
816 resp.text.contains(r#"id="total-count" hx-swap-oob="true""#),
817 "the total count must be swapped out of band or it goes stale"
818 );
819 }
820
821 /// The page and the out-of-band partial must render the same count text.
822 ///
823 /// Both emit `#total-count`, and the partial's copy replaces the page's on every
824 /// filter interaction. If they word it differently the label silently rewrites
825 /// itself on the first swap, which is the same class of bug as the count going
826 /// stale (it used to sit outside `#results-container` and read "13 items" beside
827 /// a list of 3). One helper builds the string for both; this checks nothing has
828 /// reintroduced a second source.
829 #[tokio::test]
830 async fn page_and_partial_word_the_count_identically() {
831 let mut h = TestHarness::new().await;
832
833 for query in ["mode=items", "mode=items&q=guitar", "mode=projects"] {
834 let page = h.client.get(&format!("/discover?{query}")).await;
835 let partial = h
836 .client
837 .htmx_get(&format!("/discover/results?{query}"))
838 .await;
839 assert_eq!(page.status, 200, "{}", page.status);
840 assert_eq!(partial.status, 200, "{}", partial.status);
841
842 let from_page = total_count_text(&page.text);
843 let from_partial = total_count_text(&partial.text);
844 assert_eq!(from_page, from_partial, "count copy disagrees for ?{query}");
845 }
846 }
847
848 /// Searching counts "results", browsing counts the thing.
849 ///
850 /// Membership is any typed word, so a large number under a search is mostly
851 /// partial matches. "N items" would assert N things matched; the number only
852 /// ever described the list.
853 #[tokio::test]
854 async fn search_counts_results_and_browse_counts_items() {
855 let mut h = TestHarness::new().await;
856
857 let browsing = h.client.get("/discover?mode=items").await;
858 let browsing_text = total_count_text(&browsing.text);
859 assert!(
860 browsing_text.ends_with("items") || browsing_text.ends_with("item"),
861 "browsing should count items, got {browsing_text:?}"
862 );
863
864 let searching = h.client.get("/discover?mode=items&q=guitar").await;
865 let searching_text = total_count_text(&searching.text);
866 assert!(
867 searching_text.ends_with("results") || searching_text.ends_with("result"),
868 "searching should count results, got {searching_text:?}"
869 );
870
871 // A whitespace-only term is browsing as far as the query is concerned, so
872 // the copy has to agree with it rather than reading the raw `?q=`.
873 let blank = h.client.get("/discover?mode=items&q=%20%20").await;
874 let blank_text = total_count_text(&blank.text);
875 assert!(
876 blank_text.ends_with("items") || blank_text.ends_with("item"),
877 "a whitespace-only search is a browse, got {blank_text:?}"
878 );
879 }
880
881 /// Text inside the `#total-count` span, whitespace-normalized.
882 fn total_count_text(html: &str) -> String {
883 let after_id = html
884 .split_once(r#"id="total-count""#)
885 .expect("response has no #total-count")
886 .1;
887 let inner = after_id
888 .split_once('>')
889 .expect("#total-count span is unterminated")
890 .1
891 .split_once("</span>")
892 .expect("#total-count span is unclosed")
893 .0;
894 inner.split_whitespace().collect::<Vec<_>>().join(" ")
895 }
896
897 // Markup/stylesheet contract.
898
899 /// Sidebar markup only uses class hooks the stylesheet defines.
900 ///
901 /// A class with no rule is invisible in review and silently unstyled in
902 /// production. `.visually-hidden` shipped this way: it does not exist in this
903 /// codebase (the utility is `.sr-only`), so the "Find a tag" label rendered as
904 /// visible body text until someone read the stylesheet.
905 #[tokio::test]
906 async fn sidebar_uses_only_defined_style_hooks() {
907 let css = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/static/style.css"))
908 .expect("read style.css");
909
910 // Every class the rebuilt sidebar introduces. Kept explicit rather than
911 // scraped from the template so adding a hook is a deliberate act.
912 for class in [
913 "tag-spine",
914 "tag-chips",
915 "tag-chip",
916 "tag-chip-label",
917 "tag-chip-remove",
918 "tag-chip-clear",
919 "tag-combobox",
920 "tag-crumbs",
921 "tag-drill-select",
922 "tag-drill-into",
923 "tag-drill-chevron",
924 "filter-refine",
925 "filter-fieldset",
926 "filter-check",
927 "price-buckets",
928 "price-bucket",
929 "sr-only",
930 // Search-result match reporting: the tier heading and the per-row
931 // "Matched 3 of 5 words" note. Unstyled, .results-tier-break renders as
932 // an unmarked row in the middle of the list, which reads as a result
933 // rather than as the boundary it is.
934 "results-tier-break",
935 "results-tier-label",
936 "results-tier-hint",
937 "row-match-note",
938 ] {
939 assert!(
940 css.contains(&format!(".{class}")),
941 "class {class} is used by discover but has no rule in style.css"
942 );
943 }
944
945 // The grid is `repeat(auto-fill, minmax(280px, 1fr))`, so the tier heading
946 // must span every column or it takes one card's slot and the two tiers
947 // interleave on screen while being correctly ordered in the DOM.
948 assert!(
949 css.contains("grid-column: 1 / -1"),
950 ".results-tier-break must span the grid, or the tiers interleave in grid view"
951 );
952
953 // The utility this codebase does not have. Guarding the name directly
954 // because reaching for it is the natural mistake.
955 assert!(
956 !css.contains(".visually-hidden"),
957 "use .sr-only; .visually-hidden is not this codebase's utility"
958 );
959 }
960
961 // Form/handler name contract.
962
963 /// Every filter parameter the handler reads is reachable from the markup.
964 ///
965 /// A form's control names are an untested contract with its handler. A rename
966 /// pass once rewrote `name="has_source"` to `name="sidebar.has_source"` and the
967 /// whole suite passed with the filter inert.
968 ///
969 /// The list below is hardcoded. `query_param_contract` in the handler is the
970 /// exhaustive destructure of `DiscoverQuery`, but it lives behind two private
971 /// module boundaries and this is a separate crate, so the two can drift: adding
972 /// a query parameter there does NOT fail anything here.
973 #[tokio::test]
974 async fn every_filter_param_has_a_control_in_the_markup() {
975 let mut h = TestHarness::new().await;
976 // Seed both modes so every facet renders something.
977 let setup = h
978 .create_creator_with_item("namecontract", "audio", 1000)
979 .await;
980 sqlx::query(
981 "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \
982 deleted_at = NULL WHERE id = $1::uuid",
983 )
984 .bind(&setup.item_id)
985 .execute(&h.db)
986 .await
987 .expect("publish item");
988 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
989 .bind(&setup.project_id)
990 .execute(&h.db)
991 .await
992 .expect("publish project");
993
994 // Items mode carries q, item_type, min_price, max_price, sort, mode, ai_tier.
995 let items = h.client.get("/discover?mode=items").await;
996 assert_eq!(items.status, 200, "{}", items.status);
997 for name in [
998 "q",
999 "item_type",
1000 "min_price",
1001 "max_price",
1002 "sort",
1003 "mode",
1004 "ai_tier",
1005 ] {
1006 assert!(
1007 items.text.contains(&format!(r#"name="{name}""#)),
1008 "items mode must expose a control named {name}"
1009 );
1010 }
1011
1012 // `tag` is deliberately absent at the root cursor: the rungs there are
1013 // depth-1 type roots, which cannot be assigned to an item and so render as
1014 // navigation rather than checkboxes. It appears once the cursor reaches a
1015 // level with assignable leaves (or via the typeahead, or as a hidden input
1016 // once something is selected).
1017 assert!(
1018 !items.text.contains(r#"name="tag""#),
1019 "the root rung has no assignable tags, so it should offer no tag control"
1020 );
1021 let drilled = h
1022 .client
1023 .get("/discover?mode=items&browse=audio.genre")
1024 .await;
1025 assert_eq!(drilled.status, 200, "{}", drilled.status);
1026 assert!(
1027 drilled.text.contains(r#"name="tag""#),
1028 "drilling to a leaf level must expose a control named tag"
1029 );
1030
1031 // Projects mode carries category and has_source instead of the item facets.
1032 let projects = h.client.get("/discover?mode=projects").await;
1033 assert_eq!(projects.status, 200, "{}", projects.status);
1034 for name in ["q", "category", "has_source", "sort", "mode"] {
1035 assert!(
1036 projects.text.contains(&format!(r#"name="{name}""#)),
1037 "projects mode must expose a control named {name}"
1038 );
1039 }
1040
1041 // No control may submit under a template field path, which is what the
1042 // sidebar-extraction rename produced.
1043 for body in [&items.text, &projects.text] {
1044 assert!(
1045 !body.contains(r#"name="sidebar."#),
1046 "a form control is submitting under a template field path"
1047 );
1048 }
1049 }
1050
1051 // Projects mode facets.
1052
1053 /// The projects-mode filters submit under the names the handler reads.
1054 ///
1055 /// A rename refactor once rewrote `name="has_source"` to `name="sidebar.has_source"`
1056 /// and nothing failed: no test asserted the wire names, so the source filter was
1057 /// simply inert. Names are the contract between the form and `DiscoverQuery`.
1058 #[tokio::test]
1059 async fn projects_mode_filters_use_the_query_param_names() {
1060 let mut h = TestHarness::new().await;
1061
1062 let resp = h.client.get("/discover?mode=projects").await;
1063 assert_eq!(resp.status, 200, "{}", resp.status);
1064 assert!(
1065 resp.text.contains(r#"name="has_source""#),
1066 "the source filter must submit as has_source, the name DiscoverQuery reads"
1067 );
1068 assert!(
1069 resp.text.contains(r#"name="category""#),
1070 "the category facet must submit as category"
1071 );
1072 assert!(
1073 !resp.text.contains(r#"name="sidebar."#),
1074 "no form control may submit under a template field path"
1075 );
1076 }
1077
1078 /// Projects mode has no non-native controls left.
1079 #[tokio::test]
1080 async fn projects_mode_category_facet_is_a_native_control() {
1081 let mut h = TestHarness::new().await;
1082
1083 let resp = h.client.get("/discover?mode=projects").await;
1084 assert_eq!(resp.status, 200, "{}", resp.status);
1085 assert!(
1086 resp.text.contains(r#"type="radio" name="category""#),
1087 "category should be radios: it is single-valued server-side"
1088 );
1089 assert!(
1090 !resp.text.contains("filter-btn"),
1091 "no .filter-btn should remain; it needed JS to mirror its value into a hidden input"
1092 );
1093 }
1094
1095 /// The source filter actually narrows the project list.
1096 #[tokio::test]
1097 async fn has_source_filter_narrows_projects() {
1098 let mut h = TestHarness::new().await;
1099 let setup = h.create_creator_with_item("hassrc", "digital", 1000).await;
1100 sqlx::query(
1101 "UPDATE projects SET is_public = true, title = 'Sourced Project' WHERE id = $1::uuid",
1102 )
1103 .bind(&setup.project_id)
1104 .execute(&h.db)
1105 .await
1106 .expect("publish project");
1107
1108 // No git repo attached, so the filter must exclude it.
1109 let filtered = h.client.get("/discover?mode=projects&has_source=1").await;
1110 assert_eq!(filtered.status, 200, "{}", filtered.status);
1111 assert!(
1112 !filtered.text.contains("Sourced Project"),
1113 "a project with no git repo must not survive has_source=1"
1114 );
1115
1116 // Unfiltered, it shows.
1117 let all = h.client.get("/discover?mode=projects").await;
1118 assert!(
1119 all.text.contains("Sourced Project"),
1120 "the project should be listed without the filter"
1121 );
1122 }
1123
1124 // Out-of-band sidebar refresh.
1125
1126 /// The results partial carries the sidebar for an out-of-band swap.
1127 ///
1128 /// Faceted browsing is steered by the counts, so a sidebar that only refreshes
1129 /// on a full page load is showing numbers for a filter the user has already
1130 /// moved past.
1131 #[tokio::test]
1132 async fn results_partial_carries_the_sidebar_out_of_band() {
1133 let mut h = TestHarness::new().await;
1134 make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await;
1135
1136 let resp = h.client.htmx_get("/discover/results?mode=items").await;
1137 assert_eq!(resp.status, 200, "{}", resp.status);
1138 assert!(
1139 resp.text
1140 .contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#),
1141 "results must carry an OOB swap for the sidebar"
1142 );
1143 assert!(
1144 resp.text.contains(r#"id="discover-sidebar""#),
1145 "the OOB payload must include the sidebar element itself"
1146 );
1147 }
1148
1149 /// The out-of-band sidebar reflects the filter that produced the results.
1150 #[tokio::test]
1151 async fn oob_sidebar_counts_track_the_active_filter() {
1152 let mut h = TestHarness::new().await;
1153 make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await;
1154 make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await;
1155
1156 // Unfiltered: both types present, so the audio checkbox is not checked.
1157 let all = h.client.htmx_get("/discover/results?mode=items").await;
1158 assert!(
1159 all.text.contains(r#"id="typesel-audio""#),
1160 "audio facet should render"
1161 );
1162 assert!(
1163 all.text.contains(r#"id="typesel-video""#),
1164 "video facet should render"
1165 );
1166
1167 // Filtered to audio: the audio control comes back checked in the OOB payload.
1168 let audio = h
1169 .client
1170 .htmx_get("/discover/results?mode=items&item_type=audio")
1171 .await;
1172 assert!(
1173 audio.text.contains("checked"),
1174 "the OOB sidebar must reflect the active selection, not the previous one"
1175 );
1176 }
1177
1178 /// Every sidebar control carries a stable id.
1179 ///
1180 /// htmx restores focus after a swap only to an element that has one. Without
1181 /// ids, replacing the sidebar on every filter change would drop a keyboard
1182 /// user back to the document body each time they tick a box.
1183 #[tokio::test]
1184 async fn sidebar_controls_have_stable_ids_for_focus_restoration() {
1185 let mut h = TestHarness::new().await;
1186 make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await;
1187
1188 let resp = h.client.get("/discover?mode=items").await;
1189 assert_eq!(resp.status, 200, "{}", resp.status);
1190 assert!(
1191 resp.text.contains(r#"id="typesel-audio""#),
1192 "type checkboxes need ids"
1193 );
1194 assert!(
1195 resp.text.contains(r#"id="aitier-"#),
1196 "ai tier radios need ids"
1197 );
1198 // The typeahead box is described (N8), and it is addressed by the field's
1199 // own name, so focus restoration still has an id to find it back by.
1200 //
1201 // What it no longer carries is `hx-preserve`: nothing in the vocabulary
1202 // says "this control's value is the reader's, keep it through a swap", so a
1203 // sidebar swap while a tag name is half-typed now clears the box. Filed
1204 // against quasicoherent rather than kept by leaving the box hand-written.
1205 assert!(
1206 resp.text.contains(r#"id="tag-search""#),
1207 "the typeahead box needs an id for focus restoration"
1208 );
1209 }
1210
1211 // Tag spine: drill-down and typeahead.
1212
1213 /// Drill-down counts roll up the whole subtree, not direct assignments.
1214 ///
1215 /// Items may only carry depth-3+ leaves, so a category's direct count is always
1216 /// zero. If the drill-down counted direct assignments every category would read
1217 /// 0 and the sidebar would be useless.
1218 #[tokio::test]
1219 async fn drill_down_counts_roll_up_the_subtree() {
1220 let mut h = TestHarness::new().await;
1221 let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await;
1222 tag_item(&h, &item, "audio.genre.electronic").await;
1223
1224 // At the root, the `audio` type must report the leaf two levels below it.
1225 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1226 .await
1227 .expect("root drill rows");
1228 let audio = roots
1229 .iter()
1230 .find(|r| r.tag_slug == "audio")
1231 .expect("audio root present");
1232 assert_eq!(audio.count, 1, "root count must roll up the whole subtree");
1233 assert!(!audio.assignable, "depth-1 type roots are not assignable");
1234 assert!(audio.has_children, "audio has categories beneath it");
1235 }
1236
1237 /// A category with no matching items is still listed, with a zero.
1238 ///
1239 /// Dropping empty rungs would make the taxonomy's shape flicker as filters
1240 /// change; showing a 0 tells the user the branch exists and is empty.
1241 #[tokio::test]
1242 async fn drill_down_keeps_empty_children_as_zero() {
1243 let h = TestHarness::new().await;
1244 // No items at all.
1245 let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
1246 .await
1247 .expect("root drill rows");
1248 assert!(
1249 !roots.is_empty(),
1250 "type roots must render even with an empty catalog"
1251 );
1252 assert!(
1253 roots.iter().all(|r| r.count == 0),
1254 "an empty catalog means every rung reads zero, not missing"
1255 );
1256 }
1257
1258 /// Drilling in returns the cursor's immediate children only.
1259 #[tokio::test]
1260 async fn drill_down_returns_immediate_children_of_the_cursor() {
1261 let h = TestHarness::new().await;
1262 let rows =
1263 makenotwork::db::tags::tag_children_with_counts(&h.db, Some("audio"), &filters_for(&[]))
1264 .await
1265 .expect("audio children");
1266 assert!(!rows.is_empty(), "audio has categories");
1267 assert!(
1268 rows.iter()
1269 .all(|r| r.tag_slug.starts_with("audio.") && r.tag_slug.matches('.').count() == 1),
1270 "only depth-2 children of audio, got {:?}",
1271 rows.iter().map(|r| &r.tag_slug).collect::<Vec<_>>()
1272 );
1273 }
1274
1275 /// The typeahead finds a leaf from a partial name, at any depth.
1276 ///
1277 /// The typed value arrives under the described field's own name, which is what
1278 /// a consult sends it under. `q` on this route is the search box's value riding
1279 /// along as one of the filters a pick carries forward.
1280 #[tokio::test]
1281 async fn tag_typeahead_finds_a_leaf_by_partial_name() {
1282 let mut h = TestHarness::new().await;
1283 let resp = h
1284 .client
1285 .get("/discover/tag-suggest?tag-search=electr")
1286 .await;
1287 assert_eq!(resp.status, 200, "{}", resp.status);
1288 assert!(
1289 resp.text.contains("audio.genre.electronic"),
1290 "prefix search must reach a depth-3 leaf, got {}",
1291 resp.text
1292 );
1293 }
1294
1295 /// The typeahead never offers a tag that cannot filter.
1296 #[tokio::test]
1297 async fn tag_typeahead_omits_unassignable_categories() {
1298 let mut h = TestHarness::new().await;
1299 // "audio" and "audio.genre" both prefix-match, but neither is assignable.
1300 let resp = h
1301 .client
1302 .get("/discover/tag-suggest?tag-search=audio.genre")
1303 .await;
1304 assert_eq!(resp.status, 200, "{}", resp.status);
1305 // Asked of what a pick would add, not of what a row reads: every row here
1306 // draws `audio.genre` as its second line, which is the parent path doing
1307 // its job rather than a category being offered.
1308 assert!(
1309 !resp.text.contains("tag=audio.genre\"") && !resp.text.contains("tag=audio.genre&"),
1310 "a depth-2 category must not be offered as a filter: {}",
1311 resp.text
1312 );
1313 }
1314
1315 /// N8. The dropdown is the described suggestion list, so the route answers the
1316 /// markup a renderer drew rather than JSON a hand-written renderer parsed.
1317 #[tokio::test]
1318 async fn the_typeahead_answers_the_described_list() {
1319 let mut h = TestHarness::new().await;
1320 let resp = h
1321 .client
1322 .get("/discover/tag-suggest?tag-search=electr")
1323 .await;
1324 assert_eq!(resp.status, 200, "{}", resp.status);
1325 assert!(
1326 resp.text.contains(r#"role="option""#),
1327 "rows are options in the list the field owns: {}",
1328 resp.text
1329 );
1330 // The parent path is the second line, which is what tells four rows reading
1331 // "Format" apart.
1332 assert!(
1333 resp.text.contains("form-suggestion-detail"),
1334 "a candidate carries its second line: {}",
1335 resp.text
1336 );
1337 }
1338
1339 /// Picking a tag is the call the drill-down checkbox makes, under the filters
1340 /// the box sent with the question. Before N8 this was a `choose()` in
1341 /// `page-discover.js` that built a hidden input by hand.
1342 #[tokio::test]
1343 async fn a_suggested_tag_carries_the_filters_it_is_being_added_to() {
1344 let mut h = TestHarness::new().await;
1345 let resp = h
1346 .client
1347 .get("/discover/tag-suggest?tag-search=electr&mode=items&sort=newest")
1348 .await;
1349 assert_eq!(resp.status, 200, "{}", resp.status);
1350 assert!(
1351 resp.text.contains("hx-get=\"/discover/results"),
1352 "a pick calls the results route: {}",
1353 resp.text
1354 );
1355 assert!(
1356 resp.text.contains("mode=items") && resp.text.contains("sort=newest"),
1357 "and carries the screen it was offered under: {}",
1358 resp.text
1359 );
1360 assert!(
1361 resp.text.contains("tag=audio.genre.electronic"),
1362 "and the tag it adds: {}",
1363 resp.text
1364 );
1365 }
1366
1367 /// Nothing but the filters travels. A caller appending its own parameters to
1368 /// the question does not get them echoed into every candidate's address.
1369 #[tokio::test]
1370 async fn the_typeahead_carries_only_the_filters_it_says_it_does() {
1371 let mut h = TestHarness::new().await;
1372 let resp = h
1373 .client
1374 .get("/discover/tag-suggest?tag-search=electr&smuggled=yes")
1375 .await;
1376 assert_eq!(resp.status, 200, "{}", resp.status);
1377 assert!(!resp.text.contains("smuggled"), "{}", resp.text);
1378 }
1379
1380 // Faceted multi-select: OR within a facet, AND across facets.
1381
1382 /// Repeated `tag=` params are OR'd, and each still matches its own subtree.
1383 #[tokio::test]
1384 async fn repeated_tag_params_are_ored_within_the_facet() {
1385 let mut h = TestHarness::new().await;
1386 let (_, electronic) =
1387 make_discoverable_item(&mut h, "orelec", "Electronic Pick", "audio").await;
1388 let (_, ambient) = make_discoverable_item(&mut h, "oramb", "Ambient Pick", "audio").await;
1389 let (_, folk) = make_discoverable_item(&mut h, "orfolk", "Folk Pick", "audio").await;
1390 tag_item(&h, &electronic, "audio.genre.electronic").await;
1391 tag_item(&h, &ambient, "audio.genre.ambient").await;
1392 tag_item(&h, &folk, "audio.genre.folk").await;
1393
1394 let resp = h
1395 .client
1396 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.genre.ambient")
1397 .await;
1398 assert_eq!(resp.status, 200, "{}", resp.status);
1399 assert!(
1400 resp.text.contains("Electronic Pick"),
1401 "first selected tag must match"
1402 );
1403 assert!(
1404 resp.text.contains("Ambient Pick"),
1405 "second selected tag must match (OR, not AND)"
1406 );
1407 assert!(
1408 !resp.text.contains("Folk Pick"),
1409 "an unselected tag must not match"
1410 );
1411 }
1412
1413 /// A tag link that carries no `mode` filters items rather than landing on the
1414 /// unfiltered project list.
1415 ///
1416 /// Six templates emit `/discover?tag={slug}` with no mode (item, project, both
1417 /// players, and the two reader views), so this is every tag click from a
1418 /// content page. Projects mode has no tag dimension at all — `discover_projects`
1419 /// takes no tag argument and the sidebar only builds tag filters in items mode
1420 /// — so the URL used to claim a filter the query could not apply, and the page
1421 /// answered with every project, which reads as "this tag matches everything".
1422 #[tokio::test]
1423 async fn a_tag_link_without_a_mode_filters_items() {
1424 let mut h = TestHarness::new().await;
1425 let (_, electronic) =
1426 make_discoverable_item(&mut h, "bareelec", "Electronic Pick", "audio").await;
1427 let (_, folk) = make_discoverable_item(&mut h, "barefolk", "Folk Pick", "audio").await;
1428 tag_item(&h, &electronic, "audio.genre.electronic").await;
1429 tag_item(&h, &folk, "audio.genre.folk").await;
1430
1431 let resp = h.client.get("/discover?tag=audio.genre.electronic").await;
1432 assert_eq!(resp.status, 200);
1433 assert!(
1434 resp.text.contains("Electronic Pick"),
1435 "a bare tag link must return the tagged item",
1436 );
1437 assert!(
1438 !resp.text.contains("Folk Pick"),
1439 "a bare tag link must still exclude an unselected tag",
1440 );
1441 }
1442
1443 /// And the default is otherwise unchanged: no tag, no mode, still projects.
1444 /// Pinned because the tag rule above is a second default rather than a
1445 /// replacement, and the two are easy to collapse into one by accident.
1446 #[tokio::test]
1447 async fn discover_without_a_tag_still_defaults_to_projects() {
1448 let mut h = TestHarness::new().await;
1449 make_discoverable_item(&mut h, "defmode", "Mode Default Pick", "audio").await;
1450
1451 let resp = h.client.get("/discover").await;
1452 assert_eq!(resp.status, 200);
1453 assert!(
1454 resp.text.contains("project-row"),
1455 "the bare landing view is the project list",
1456 );
1457 }
1458
1459 /// Repeated `item_type=` params are OR'd.
1460 #[tokio::test]
1461 async fn repeated_item_type_params_are_ored_within_the_facet() {
1462 let mut h = TestHarness::new().await;
1463 make_discoverable_item(&mut h, "ortaudio", "Audio Thing", "audio").await;
1464 make_discoverable_item(&mut h, "ortsample", "Sample Thing", "sample").await;
1465 make_discoverable_item(&mut h, "ortvideo", "Video Thing", "video").await;
1466
1467 let resp = h
1468 .client
1469 .get("/discover?mode=items&item_type=audio&item_type=sample")
1470 .await;
1471 assert_eq!(resp.status, 200, "{}", resp.status);
1472 assert!(resp.text.contains("Audio Thing"));
1473 assert!(resp.text.contains("Sample Thing"));
1474 assert!(
1475 !resp.text.contains("Video Thing"),
1476 "unselected type must not match"
1477 );
1478 }
1479
1480 /// Different facets AND together, so the two selections intersect.
1481 #[tokio::test]
1482 async fn separate_facets_are_anded_together() {
1483 let mut h = TestHarness::new().await;
1484 let (_, match_both) = make_discoverable_item(&mut h, "andboth", "Matches Both", "audio").await;
1485 let (_, wrong_type) = make_discoverable_item(&mut h, "andtype", "Wrong Type", "video").await;
1486 let (_, wrong_tag) = make_discoverable_item(&mut h, "andtag", "Wrong Tag", "audio").await;
1487 tag_item(&h, &match_both, "audio.genre.electronic").await;
1488 tag_item(&h, &wrong_type, "audio.genre.electronic").await;
1489 tag_item(&h, &wrong_tag, "audio.genre.folk").await;
1490
1491 let resp = h
1492 .client
1493 .get("/discover?mode=items&item_type=audio&tag=audio.genre.electronic")
1494 .await;
1495 assert_eq!(resp.status, 200, "{}", resp.status);
1496 assert!(resp.text.contains("Matches Both"));
1497 assert!(
1498 !resp.text.contains("Wrong Type"),
1499 "tag matched but type did not: facets must AND"
1500 );
1501 assert!(
1502 !resp.text.contains("Wrong Tag"),
1503 "type matched but tag did not: facets must AND"
1504 );
1505 }
1506
1507 /// A multi-select survives the round-trip through the form's hidden inputs.
1508 ///
1509 /// `hx-include=".discover-filter"` rebuilds the next request from these inputs,
1510 /// so if several selected tags collapsed into one delimited value (or into the
1511 /// first value only) the second interaction would silently drop the filter.
1512 /// One input per value, no delimiter convention.
1513 #[tokio::test]
1514 async fn multi_select_round_trips_through_the_form_inputs() {
1515 let mut h = TestHarness::new().await;
1516 let (_, item) = make_discoverable_item(&mut h, "roundtrip", "Round Trip", "audio").await;
1517 tag_item(&h, &item, "audio.genre.electronic").await;
1518
1519 let resp = h
1520 .client
1521 .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.mood.dark")
1522 .await;
1523 assert_eq!(resp.status, 200, "{}", resp.status);
1524
1525 let tag_inputs = resp.text.matches(r#"name="tag""#).count();
1526 assert_eq!(
1527 tag_inputs, 2,
1528 "both selected tags must be carried as separate hidden inputs, got {tag_inputs}"
1529 );
1530 assert!(resp.text.contains(r#"value="audio.genre.electronic""#));
1531 assert!(resp.text.contains(r#"value="audio.mood.dark""#));
1532 assert!(
1533 !resp.text.contains("audio.genre.electronic,audio.mood.dark"),
1534 "selections must not be joined into one delimited value"
1535 );
1536 }
1537
1538 /// A blank facet value is not a filter.
1539 ///
1540 /// `hx-include` ships every filter input on every request, so an untouched
1541 /// control arrives as `tag=`. That must mean "unfiltered", not "match the empty
1542 /// tag" (which would return nothing).
1543 #[tokio::test]
1544 async fn blank_repeated_params_do_not_filter() {
1545 let mut h = TestHarness::new().await;
1546 make_discoverable_item(&mut h, "blankp", "Still Visible", "audio").await;
1547
1548 let resp = h.client.get("/discover?mode=items&tag=&item_type=").await;
1549 assert_eq!(resp.status, 200, "{}", resp.status);
1550 assert!(
1551 resp.text.contains("Still Visible"),
1552 "blank filter values must not narrow the result set"
1553 );
1554 }
1555
1556 /// Tag counts obey the same visibility predicate as the results.
1557 ///
1558 /// `get_tag_counts` had no `users` join at all, so it never applied
1559 /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it
1560 /// count items that discover would never display.
1561 #[tokio::test]
1562 async fn tag_counts_exclude_sandbox_and_unscanned_items() {
1563 let mut h = TestHarness::new().await;
1564 let (sandbox_user, sandboxed) =
1565 make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await;
1566 let (_, quarantined) =
1567 make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await;
1568 let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await;
1569 for id in [&sandboxed, &quarantined, &visible] {
1570 tag_item(&h, id, "audio.genre.electronic").await;
1571 }
1572 sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
1573 .bind(&sandbox_user)
1574 .execute(&h.db)
1575 .await
1576 .expect("sandbox the user");
1577 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
1578 .bind(&quarantined)
1579 .execute(&h.db)
1580 .await
1581 .expect("quarantine the item");
1582
1583 let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(&[]))
1584 .await
1585 .expect("tag counts");
1586 let leaf = counts
1587 .iter()
1588 .find(|c| c.tag_slug == "audio.genre.electronic")
1589 .expect("leaf tag counted");
1590
1591 assert_eq!(
1592 leaf.count, 1,
1593 "only the visible item may count; sandbox + quarantined must be excluded (was 3)"
1594 );
1595 }
1596