Skip to main content

max / makenotwork

Fix discover facet count correctness The sidebar counts are a separate query family from the result set and had drifted from it in four ways, all of which reported numbers the results would not honor: - Tag descendants: the three facet queries matched direct children only via parent_id, while results match the whole subtree via tags.path LIKE. An item tagged audio.genre.electronic appeared in results for ?tag=audio but contributed 0 to the counts beside them. - Under-applied filters: ai_tier reached no facet count at all, and the price bounds reached neither the tag counts nor the ai_tier counts. - Facet cache key: FacetKey omitted ai_tier. Harmless while no facet applied the tier filter, a live wrong-answer cache the moment one did. - get_tag_counts had no users join (so no is_sandbox), no scan_status, and matched description in search where results match title only. Give all four facets a shared append_facet_filters(.., FacetAxis, ..) over the full DiscoverFilters, omitting exactly one axis, plus one ITEM_VISIBILITY_WHERE constant. Each facet's own selection is still excluded, since a facet's counts answer "what would I get if I picked this value"; every other filter now applies. There were five hand-rolled copies of this logic, which is how the parity drifted in the first place. Add regression tests for the subtree match, the ai_tier cross-filter, and the tag-count visibility predicate. All 24 pre-existing discover tests passed with every one of these bugs live: nothing exercised a facet count. Also repair four IntegrationsConfig test fixtures left behind by f49bd61f, which broke the test and clippy --all-targets builds outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:12 UTC
Commit: a668df6fd262cc74ff779d112321ae66f97b86e1
Parent: 453d984
8 files changed, +347 insertions, -153 deletions
@@ -977,6 +977,7 @@ mod tests {
977 977 wam_url: None,
978 978 internal_shared_secret: None,
979 979 cli_service_token: None,
980 + alerts_ingest_token: None,
980 981 },
981 982 };
982 983 assert!(require_admin(&user, &config).is_ok());
@@ -1057,6 +1058,7 @@ mod tests {
1057 1058 wam_url: None,
1058 1059 internal_shared_secret: None,
1059 1060 cli_service_token: None,
1061 + alerts_ingest_token: None,
1060 1062 },
1061 1063 };
1062 1064 assert!(require_admin(&user, &config).is_err());
@@ -51,7 +51,7 @@ const MAX_SEARCH_LEN: usize = 200;
51 51
52 52 /// Normalize a search term: trim whitespace, truncate to [`MAX_SEARCH_LEN`],
53 53 /// and return `None` if the result is empty.
54 - fn normalize_search(raw: Option<&str>) -> Option<String> {
54 + pub(crate) fn normalize_search(raw: Option<&str>) -> Option<String> {
55 55 let trimmed = raw?.trim();
56 56 if trimmed.is_empty() {
57 57 return None;
@@ -71,7 +71,7 @@ fn normalize_search(raw: Option<&str>) -> Option<String> {
71 71 }
72 72
73 73 /// Returns `true` when the search term is too short for trigram matching (1-2 chars).
74 - fn is_short_query(term: &str) -> bool {
74 + pub(crate) fn is_short_query(term: &str) -> bool {
75 75 term.trim().len() <= 2
76 76 }
77 77
@@ -139,6 +139,83 @@ macro_rules! bind_item_discover_filters {
139 139 };
140 140 }
141 141
142 + /// The visibility predicate every public item query must carry.
143 + ///
144 + /// Kept as one constant because the facet counts and the result set drifted apart
145 + /// once already: `get_tag_counts` was missing `scan_status` and `is_sandbox`
146 + /// entirely, so it counted items the results would never show.
147 + pub(crate) const ITEM_VISIBILITY_WHERE: &str = " WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE";
148 +
149 + /// Which facet is being counted, and therefore which filter to omit.
150 + ///
151 + /// A facet's counts answer "what would I get if I picked this value", so the
152 + /// facet's own current selection must not constrain them. Selecting
153 + /// `item_type=audio` and then applying that filter to the item-type counts
154 + /// would zero every other type and hide the 12 samples also available.
155 + /// Every *other* active filter still applies.
156 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
157 + pub enum FacetAxis {
158 + ItemType,
159 + Tag,
160 + Price,
161 + AiTier,
162 + }
163 +
164 + /// Append facet-count filter clauses, omitting `axis`'s own dimension.
165 + ///
166 + /// Uses the same bind layout as [`append_item_discover_filters`]
167 + /// ($1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag) so every
168 + /// discover query binds identically, and `ai_tier` is inlined as an
169 + /// enum-derived literal exactly as the result queries do.
170 + pub(crate) fn append_facet_filters(
171 + query: &mut String,
172 + filters: &DiscoverFilters<'_>,
173 + axis: FacetAxis,
174 + has_search: bool,
175 + short_query: bool,
176 + ) {
177 + if has_search {
178 + if short_query {
179 + query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
180 + } else {
181 + query.push_str(ITEM_SEARCH_CLAUSE);
182 + }
183 + }
184 + if axis != FacetAxis::ItemType && filters.item_type.is_some() {
185 + query.push_str(" AND i.item_type = $2");
186 + }
187 + if axis != FacetAxis::Price {
188 + if filters.min_price.is_some() {
189 + query.push_str(" AND i.price_cents >= $3");
190 + }
191 + if filters.max_price.is_some() {
192 + query.push_str(" AND i.price_cents <= $4");
193 + }
194 + }
195 + if axis != FacetAxis::Tag && filters.tag.is_some() {
196 + // Descendant match via the materialized dot-path, matching the result
197 + // query. This previously used `parent_id`, which matched direct children
198 + // only, so any tag with grandchildren counted differently than it filtered.
199 + query.push_str(
200 + r#" AND EXISTS (
201 + SELECT 1 FROM item_tags it2
202 + JOIN tags t2 ON t2.id = it2.tag_id
203 + WHERE it2.item_id = i.id
204 + AND (t2.slug = $5 OR t2.path LIKE $5 || '.%')
205 + )"#,
206 + );
207 + }
208 + if axis != FacetAxis::AiTier {
209 + match filters.ai_tier {
210 + Some(AiTierFilter::HandmadeOnly) => query.push_str(" AND i.ai_tier = 'handmade'"),
211 + Some(AiTierFilter::HumanLed) => {
212 + query.push_str(" AND i.ai_tier IN ('handmade', 'assisted')")
213 + }
214 + None => {}
215 + }
216 + }
217 + }
218 +
142 219 /// Search/browse public items with optional text search, item_type, tag, and price filters.
143 220 ///
144 221 /// Uses PostgreSQL `pg_trgm` for fuzzy text matching. The search strategy:
@@ -487,15 +564,15 @@ pub async fn count_discover_projects(
487 564 }
488 565
489 566 /// Get item type counts for discover page (items mode).
567 + ///
568 + /// Counts what each type *would* yield, so `filters.item_type` is omitted
569 + /// (see [`FacetAxis`]). Every other active filter applies.
490 570 #[tracing::instrument(skip_all)]
491 571 pub async fn get_item_type_counts(
492 572 pool: &PgPool,
493 - search: Option<&str>,
494 - tag: Option<&str>,
495 - min_price: Option<i32>,
496 - max_price: Option<i32>,
573 + filters: &DiscoverFilters<'_>,
497 574 ) -> Result<Vec<DbItemTypeCount>> {
498 - let search_term = normalize_search(search);
575 + let search_term = normalize_search(filters.search);
499 576 let has_search = search_term.is_some();
500 577 let short_query = search_term.as_deref().is_some_and(is_short_query);
501 578
@@ -505,46 +582,24 @@ pub async fn get_item_type_counts(
505 582 FROM items i
506 583 JOIN projects p ON i.project_id = p.id
507 584 JOIN users u ON p.user_id = u.id
508 - WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE
509 585 "#,
510 586 );
587 + query.push_str(ITEM_VISIBILITY_WHERE);
511 588
512 - if has_search {
513 - if short_query {
514 - query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
515 - } else {
516 - query.push_str(ITEM_SEARCH_CLAUSE);
517 - }
518 - }
519 -
520 - if tag.is_some() {
521 - query.push_str(
522 - r#" AND EXISTS (
523 - SELECT 1 FROM item_tags it2
524 - JOIN tags t2 ON t2.id = it2.tag_id
525 - WHERE it2.item_id = i.id
526 - AND (t2.slug = $2 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $2))
527 - )"#,
528 - );
529 - }
530 -
531 - if min_price.is_some() {
532 - query.push_str(" AND i.price_cents >= $3");
533 - }
534 -
535 - if max_price.is_some() {
536 - query.push_str(" AND i.price_cents <= $4");
537 - }
589 + append_facet_filters(
590 + &mut query,
591 + filters,
592 + FacetAxis::ItemType,
593 + has_search,
594 + short_query,
595 + );
538 596
539 597 query.push_str(" GROUP BY i.item_type ORDER BY count DESC");
540 598
541 - let counts = sqlx::query_as::<_, DbItemTypeCount>(&query)
542 - .bind(search_term.as_deref().unwrap_or(""))
543 - .bind(tag.unwrap_or(""))
544 - .bind(min_price.unwrap_or(0))
545 - .bind(max_price.unwrap_or(i32::MAX))
546 - .fetch_all(pool)
547 - .await?;
599 + let counts =
600 + bind_item_discover_filters!(sqlx::query_as::<_, DbItemTypeCount>(&query), filters, search_term.as_deref())
601 + .fetch_all(pool)
602 + .await?;
548 603
549 604 Ok(counts)
550 605 }
@@ -557,11 +612,9 @@ pub async fn get_item_type_counts(
557 612 #[tracing::instrument(skip_all)]
558 613 pub async fn get_price_range_counts(
559 614 pool: &PgPool,
560 - search: Option<&str>,
561 - item_type: Option<ItemType>,
562 - tag: Option<&str>,
615 + filters: &DiscoverFilters<'_>,
563 616 ) -> Result<DbPriceRangeCounts> {
564 - let search_term = normalize_search(search);
617 + let search_term = normalize_search(filters.search);
565 618 let has_search = search_term.is_some();
566 619 let short_query = search_term.as_deref().is_some_and(is_short_query);
567 620
@@ -576,32 +629,17 @@ pub async fn get_price_range_counts(
576 629 FROM items i
577 630 JOIN projects p ON i.project_id = p.id
578 631 JOIN users u ON p.user_id = u.id
579 - WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE
580 632 "#,
581 633 );
634 + query.push_str(ITEM_VISIBILITY_WHERE);
582 635
583 - if has_search {
584 - if short_query {
585 - query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
586 - } else {
587 - query.push_str(ITEM_SEARCH_CLAUSE);
588 - }
589 - }
590 -
591 - if item_type.is_some() {
592 - query.push_str(" AND i.item_type = $2");
593 - }
594 -
595 - if tag.is_some() {
596 - query.push_str(
597 - r#" AND EXISTS (
598 - SELECT 1 FROM item_tags it2
599 - JOIN tags t2 ON t2.id = it2.tag_id
600 - WHERE it2.item_id = i.id
601 - AND (t2.slug = $3 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $3))
602 - )"#,
603 - );
604 - }
636 + append_facet_filters(
637 + &mut query,
638 + filters,
639 + FacetAxis::Price,
640 + has_search,
641 + short_query,
642 + );
605 643
606 644 #[derive(FromRow)]
607 645 struct PriceRow {
@@ -612,12 +650,13 @@ pub async fn get_price_range_counts(
612 650 over_100: Option<i64>,
613 651 }
614 652
615 - let row = sqlx::query_as::<_, PriceRow>(&query)
616 - .bind(search_term.as_deref().unwrap_or(""))
617 - .bind(item_type.map(|t| t.to_string()).unwrap_or_default())
618 - .bind(tag.unwrap_or(""))
619 - .fetch_one(pool)
620 - .await?;
653 + let row = bind_item_discover_filters!(
654 + sqlx::query_as::<_, PriceRow>(&query),
655 + filters,
656 + search_term.as_deref()
657 + )
658 + .fetch_one(pool)
659 + .await?;
621 660
622 661 Ok(DbPriceRangeCounts {
623 662 free: row.free.unwrap_or(0),
@@ -632,11 +671,9 @@ pub async fn get_price_range_counts(
632 671 #[tracing::instrument(skip_all)]
633 672 pub async fn get_ai_tier_counts(
634 673 pool: &PgPool,
635 - search: Option<&str>,
636 - item_type: Option<ItemType>,
637 - tag: Option<&str>,
674 + filters: &DiscoverFilters<'_>,
638 675 ) -> Result<Vec<DbItemTypeCount>> {
639 - let search_term = normalize_search(search);
676 + let search_term = normalize_search(filters.search);
640 677 let has_search = search_term.is_some();
641 678 let short_query = search_term.as_deref().is_some_and(is_short_query);
642 679
@@ -646,41 +683,27 @@ pub async fn get_ai_tier_counts(
646 683 FROM items i
647 684 JOIN projects p ON i.project_id = p.id
648 685 JOIN users u ON p.user_id = u.id
649 - WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE
650 686 "#,
651 687 );
688 + query.push_str(ITEM_VISIBILITY_WHERE);
652 689
653 - if has_search {
654 - if short_query {
655 - query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
656 - } else {
657 - query.push_str(ITEM_SEARCH_CLAUSE);
658 - }
659 - }
660 -
661 - if item_type.is_some() {
662 - query.push_str(" AND i.item_type = $2");
663 - }
664 -
665 - if tag.is_some() {
666 - query.push_str(
667 - r#" AND EXISTS (
668 - SELECT 1 FROM item_tags it2
669 - JOIN tags t2 ON t2.id = it2.tag_id
670 - WHERE it2.item_id = i.id
671 - AND (t2.slug = $3 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $3))
672 - )"#,
673 - );
674 - }
690 + append_facet_filters(
691 + &mut query,
692 + filters,
693 + FacetAxis::AiTier,
694 + has_search,
695 + short_query,
696 + );
675 697
676 698 query.push_str(" GROUP BY i.ai_tier ORDER BY count DESC");
677 699
678 - let counts = sqlx::query_as::<_, DbItemTypeCount>(&query)
679 - .bind(search_term.as_deref().unwrap_or(""))
680 - .bind(item_type.map(|t| t.to_string()).unwrap_or_default())
681 - .bind(tag.unwrap_or(""))
682 - .fetch_all(pool)
683 - .await?;
700 + let counts = bind_item_discover_filters!(
701 + sqlx::query_as::<_, DbItemTypeCount>(&query),
702 + filters,
703 + search_term.as_deref()
704 + )
705 + .fetch_all(pool)
706 + .await?;
684 707
685 708 Ok(counts)
686 709 }
@@ -18,7 +18,7 @@ pub(crate) mod chapters;
18 18 pub(crate) mod item_sections;
19 19 pub(crate) mod project_sections;
20 20 pub mod transactions;
21 - pub(crate) mod discover;
21 + pub mod discover; // pub so the integration test crate can assert facet-count correctness directly
22 22 pub(crate) mod custom_links;
23 23 pub(crate) mod auth;
24 24 pub mod waitlist;
@@ -30,7 +30,7 @@ pub(crate) mod oauth;
30 30 pub(crate) mod promo_codes;
31 31 pub(crate) mod follows;
32 32 pub(crate) mod subscriptions;
33 - pub(crate) mod tags;
33 + pub mod tags; // pub so the integration test crate can assert facet-count correctness directly
34 34 pub(crate) mod categories;
35 35 pub mod sessions;
36 36 pub(crate) mod totp;
@@ -141,15 +141,27 @@ pub async fn set_primary_tag(pool: &PgPool, item_id: ItemId, tag_id: TagId) -> R
141 141 Ok(())
142 142 }
143 143
144 - /// Count items per tag for discover sidebar facets, filtered by current search and item_type.
144 + /// Count items per tag for the discover sidebar facets.
145 + ///
146 + /// Counts what each tag *would* yield, so `filters.tag` is omitted (see
147 + /// [`super::discover::FacetAxis`]). Every other active filter applies.
148 + ///
149 + /// This previously diverged from the result set in three ways, all of which
150 + /// let it count items discover would never show: it had no `users` join (so no
151 + /// `is_sandbox` filter), no `scan_status` predicate, and it matched
152 + /// `description` in search where the result query matches `title` only. It also
153 + /// ignored the price and AI-tier filters entirely. It now shares
154 + /// [`super::discover::append_facet_filters`] with the other three facets.
145 155 #[tracing::instrument(skip_all)]
146 156 pub async fn get_tag_counts(
147 157 pool: &PgPool,
148 - search: Option<&str>,
149 - item_type: Option<super::ItemType>,
158 + filters: &super::discover::DiscoverFilters<'_>,
150 159 ) -> Result<Vec<DbTagCount>> {
151 - let search_term = search.filter(|s| !s.trim().is_empty());
160 + use super::discover::{FacetAxis, ITEM_VISIBILITY_WHERE, append_facet_filters, normalize_search};
161 +
162 + let search_term = normalize_search(filters.search);
152 163 let has_search = search_term.is_some();
164 + let short_query = search_term.as_deref().is_some_and(super::discover::is_short_query);
153 165
154 166 let mut query = String::from(
155 167 r#"
@@ -158,30 +170,21 @@ pub async fn get_tag_counts(
158 170 JOIN tags t ON t.id = it.tag_id
159 171 JOIN items i ON i.id = it.item_id
160 172 JOIN projects p ON i.project_id = p.id
161 - WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true
173 + JOIN users u ON p.user_id = u.id
162 174 "#,
163 175 );
176 + query.push_str(ITEM_VISIBILITY_WHERE);
164 177
165 - if has_search {
166 - query.push_str(
167 - r#" AND (
168 - i.title % $1
169 - OR COALESCE(i.description, '') % $1
170 - OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
171 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
172 - )"#,
173 - );
174 - }
175 -
176 - if item_type.is_some() {
177 - query.push_str(" AND i.item_type = $2");
178 - }
178 + append_facet_filters(&mut query, filters, FacetAxis::Tag, has_search, short_query);
179 179
180 180 query.push_str(" GROUP BY t.id, t.name, t.slug ORDER BY count DESC");
181 181
182 182 let counts = sqlx::query_as::<_, DbTagCount>(&query)
183 - .bind(search_term.unwrap_or(""))
184 - .bind(item_type.map(|t| t.to_string()).unwrap_or_default())
183 + .bind(search_term.as_deref().unwrap_or(""))
184 + .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default())
185 + .bind(filters.min_price.unwrap_or(0))
186 + .bind(filters.max_price.unwrap_or(i32::MAX))
187 + .bind(filters.tag.unwrap_or(""))
185 188 .fetch_all(pool)
186 189 .await?;
187 190
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
15 15 use crate::{
16 16 auth::MaybeUserUnverified,
17 17 constants,
18 - db::{self, discover::DiscoverFilters, DiscoverSort, ItemType},
18 + db::{self, discover::DiscoverFilters, AiTierFilter, DiscoverSort, ItemType},
19 19 error::Result,
20 20 helpers::get_csrf_token,
21 21 templates::*,
@@ -34,7 +34,19 @@ type FacetBundle = (
34 34
35 35 /// Cache key: the filter inputs every facet query keys off. Same filters ->
36 36 /// identical viewer-independent counts, so they can be shared across viewers.
37 - type FacetKey = (Option<String>, Option<ItemType>, Option<String>, Option<i32>, Option<i32>);
37 + ///
38 + /// `ai_tier` is part of the key. It was omitted while no facet query applied the
39 + /// AI-tier filter, which made it harmless; the moment the facets started
40 + /// cross-applying it, leaving it out would have served one tier's counts to
41 + /// another for up to `FACET_CACHE_TTL`.
42 + type FacetKey = (
43 + Option<String>,
44 + Option<ItemType>,
45 + Option<String>,
46 + Option<i32>,
47 + Option<i32>,
48 + Option<AiTierFilter>,
49 + );
38 50
39 51 /// Short TTL for the facet memo. The discover facets are the hottest, most
40 52 /// cacheable aggregate on the busiest public page; recomputing five full-catalog
@@ -52,21 +64,14 @@ static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> =
52 64 /// On a miss the four queries still run (concurrently); the memo makes the common
53 65 /// case — repeated anonymous loads of the same filter view — a single map lookup
54 66 /// instead of five full-catalog aggregate scans holding five pool connections.
55 - #[allow(clippy::too_many_arguments)]
56 - async fn cached_facets(
57 - db: &PgPool,
58 - search: Option<&str>,
59 - item_type: Option<ItemType>,
60 - tag: Option<&str>,
61 - min_price: Option<i32>,
62 - max_price: Option<i32>,
63 - ) -> Result<FacetBundle> {
67 + async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) -> Result<FacetBundle> {
64 68 let key: FacetKey = (
65 - search.map(str::to_string),
66 - item_type,
67 - tag.map(str::to_string),
68 - min_price,
69 - max_price,
69 + filters.search.map(str::to_string),
70 + filters.item_type,
71 + filters.tag.map(str::to_string),
72 + filters.min_price,
73 + filters.max_price,
74 + filters.ai_tier,
70 75 );
71 76
72 77 let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
@@ -78,10 +83,10 @@ async fn cached_facets(
78 83 }
79 84
80 85 let bundle: FacetBundle = tokio::try_join!(
81 - db::discover::get_item_type_counts(db, search, tag, min_price, max_price),
82 - db::tags::get_tag_counts(db, search, item_type),
83 - db::discover::get_ai_tier_counts(db, search, item_type, tag),
84 - db::discover::get_price_range_counts(db, search, item_type, tag),
86 + db::discover::get_item_type_counts(db, filters),
87 + db::tags::get_tag_counts(db, filters),
88 + db::discover::get_ai_tier_counts(db, filters),
89 + db::discover::get_price_range_counts(db, filters),
85 90 )?;
86 91
87 92 if let Ok(mut guard) = cache.lock() {
@@ -553,9 +558,17 @@ pub(super) async fn discover(
553 558 // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is
554 559 // per-viewer, so it is computed fresh (and only when logged in).
555 560 let viewer_id = maybe_user.as_ref().map(|u| u.id);
556 - let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets(
557 - &db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price,
558 - ).await?;
561 + let facet_filters = DiscoverFilters {
562 + search: search_filter,
563 + item_type: item_type_filter,
564 + tag: tag_filter,
565 + min_price: query.min_price,
566 + max_price: query.max_price,
567 + sort_by: None,
568 + ai_tier: f.ai_tier,
569 + };
570 + let (type_counts, tag_counts, ai_counts, price_counts) =
571 + cached_facets(&db, &facet_filters).await?;
559 572 // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
560 573 // test follow-membership against just those rather than fetching the
561 574 // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1).
@@ -342,6 +342,7 @@ impl TestHarness {
342 342 wam_url: None,
343 343 internal_shared_secret: opts.internal_shared_secret.clone(),
344 344 cli_service_token: opts.cli_service_token.clone(),
345 + alerts_ingest_token: None,
345 346 },
346 347 };
347 348
@@ -92,6 +92,7 @@ pub async fn run(config: LoadConfig) {
92 92 wam_url: None,
93 93 internal_shared_secret: None,
94 94 cli_service_token: None,
95 + alerts_ingest_token: None,
95 96 },
96 97 };
97 98
@@ -303,3 +303,154 @@ async fn tag_tree_endpoint_renders() {
303 303 let resp = h.client.get("/discover/tags").await;
304 304 assert!(resp.status.is_success(), "GET /discover/tags: {}", resp.status);
305 305 }
306 +
307 + // ---------------------------------------------------------------------------
308 + // Facet-count correctness
309 + //
310 + // The sidebar counts are a separate query family from the result set, and they
311 + // had drifted from it in three ways. These pin each one. Note that every other
312 + // discover test in this file passed while all three were live: nothing here
313 + // exercised a facet count, so the bugs were invisible to the suite.
314 + // ---------------------------------------------------------------------------
315 +
316 + /// Attach `slug` to `item_id`, creating the tag and its dot-path ancestry.
317 + async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) {
318 + let mut parent: Option<uuid::Uuid> = None;
319 + let segments: Vec<&str> = slug.split('.').collect();
320 + for depth in 1..=segments.len() {
321 + let path = segments[..depth].join(".");
322 + let name = segments[depth - 1];
323 + let id: uuid::Uuid = sqlx::query_scalar(
324 + "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \
325 + ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id",
326 + )
327 + .bind(name)
328 + .bind(&path)
329 + .bind(parent)
330 + .fetch_one(&h.db)
331 + .await
332 + .expect("upsert tag");
333 + parent = Some(id);
334 + }
335 + sqlx::query(
336 + "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING",
337 + )
338 + .bind(item_id)
339 + .bind(parent.expect("leaf tag id"))
340 + .execute(&h.db)
341 + .await
342 + .expect("attach tag");
343 + }
344 +
345 + fn filters_for<'a>(tag: Option<&'a str>) -> makenotwork::db::discover::DiscoverFilters<'a> {
346 + makenotwork::db::discover::DiscoverFilters {
347 + search: None,
348 + item_type: None,
349 + tag,
350 + min_price: None,
351 + max_price: None,
352 + sort_by: None,
353 + ai_tier: None,
354 + }
355 + }
356 +
357 + /// A tag filter matches the whole subtree, not just direct children.
358 + ///
359 + /// The result query has always matched descendants via `tags.path LIKE
360 + /// 'prefix.%'`, but the facet counts used `parent_id = (SELECT id ... )`, which
361 + /// stops at one level. An item tagged `audio.genre.electronic` therefore
362 + /// appeared in the results for `?tag=audio` while contributing 0 to the
363 + /// sidebar counts beside them.
364 + #[tokio::test]
365 + async fn facet_counts_match_the_whole_tag_subtree() {
366 + let mut h = TestHarness::new().await;
367 + let (_, item_id) = make_discoverable_item(&mut h, "facetdepth", "Deep Tagged", "audio").await;
368 + tag_item(&h, &item_id, "audio.genre.electronic").await;
369 +
370 + // `audio` is two levels above the item's tag.
371 + let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters_for(Some("audio")))
372 + .await
373 + .expect("item type counts");
374 + let total: i64 = counts.iter().map(|c| c.count).sum();
375 +
376 + assert_eq!(
377 + total, 1,
378 + "grandchild tag audio.genre.electronic must count under ?tag=audio \
379 + (was 0 while the facets used parent_id instead of path)"
380 + );
381 + }
382 +
383 + /// The AI-tier filter constrains the facet counts.
384 + ///
385 + /// `ai_tier` was applied to the result set but to none of the four facet
386 + /// queries, so selecting "Handmade only" narrowed the listing while the sidebar
387 + /// kept reporting counts for the unfiltered catalog.
388 + #[tokio::test]
389 + async fn facet_counts_apply_the_ai_tier_filter() {
390 + let mut h = TestHarness::new().await;
391 + let (_, handmade) = make_discoverable_item(&mut h, "aihand", "Handmade One", "audio").await;
392 + let (_, assisted) = make_discoverable_item(&mut h, "aiasst", "Assisted One", "audio").await;
393 + sqlx::query("UPDATE items SET ai_tier = 'handmade' WHERE id = $1::uuid")
394 + .bind(&handmade)
395 + .execute(&h.db)
396 + .await
397 + .expect("set handmade");
398 + sqlx::query("UPDATE items SET ai_tier = 'assisted' WHERE id = $1::uuid")
399 + .bind(&assisted)
400 + .execute(&h.db)
401 + .await
402 + .expect("set assisted");
403 +
404 + let mut filters = filters_for(None);
405 + filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly);
406 + let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
407 + .await
408 + .expect("item type counts");
409 + let total: i64 = counts.iter().map(|c| c.count).sum();
410 +
411 + assert_eq!(
412 + total, 1,
413 + "handmade_only must exclude the assisted item from the facet counts (was 2)"
414 + );
415 + }
416 +
417 + /// Tag counts obey the same visibility predicate as the results.
418 + ///
419 + /// `get_tag_counts` had no `users` join at all, so it never applied
420 + /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it
421 + /// count items that discover would never display.
422 + #[tokio::test]
423 + async fn tag_counts_exclude_sandbox_and_unscanned_items() {
424 + let mut h = TestHarness::new().await;
425 + let (sandbox_user, sandboxed) =
426 + make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await;
427 + let (_, quarantined) =
428 + make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await;
429 + let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await;
430 + for id in [&sandboxed, &quarantined, &visible] {
431 + tag_item(&h, id, "audio.genre.electronic").await;
432 + }
433 + sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid")
434 + .bind(&sandbox_user)
435 + .execute(&h.db)
436 + .await
437 + .expect("sandbox the user");
438 + sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
439 + .bind(&quarantined)
440 + .execute(&h.db)
441 + .await
442 + .expect("quarantine the item");
443 +
444 + let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(None))
445 + .await
446 + .expect("tag counts");
447 + let leaf = counts
448 + .iter()
449 + .find(|c| c.tag_slug == "audio.genre.electronic")
450 + .expect("leaf tag counted");
451 +
452 + assert_eq!(
453 + leaf.count, 1,
454 + "only the visible item may count; sandbox + quarantined must be excluded (was 3)"
455 + );
456 + }