Skip to main content

max / makenotwork

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