feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)

The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.

- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
  listable facets in a single round trip: each arm omits its OWN
  facet's filter block and carries every other registered facet's
  block (copied mechanically from SearchMediaItemsUnified) — classic
  faceted semantics: with filters active each facet counts under the
  others; with none, blocks are neutral and the counts are the v1
  global-per-library ones. One query serves both cases. The 14
  per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
  ({facets: {param: [{value, display?, count}]}}), filters bound via
  the shared ParseMediaSearchParams, library_id optional (the
  All-Libraries view, family lens still enforced); facet=X keeps the
  pinned single-facet shape (now routed through the same query with
  neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
  value lists — registry order, Batch D library-type scoping
  (FacetsForLibraryType: manga_type on manga, reading_direction on
  manga+comics, narrator on audiobooks; All view = union), prettified
  genre labels + language display names, active values marked. Family
  lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
  apply with the CURRENT filter query string; a value click toggles
  that facet's comma-joined filter (hidden input where the drawer has
  no field — same as deep links) and re-applies the form; chips and
  URL stay in sync. Values are server-rendered (rendering doctrine);
  bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
  implementation (services' display enrichment shares it; templ wraps
  it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
  registry facet's param, the bools as pgtype.Bool.

Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
John O'Keefe committed 2026-10-20 08:24:36 -04:00
1 parent 6152137156
commit f52bb6f073
18 files changed
+3001 -911

No files matched your search

+258
View File
@@ -310,3 +310,261 @@ func mergeParams(base, extra url.Values) url.Values {
}
return out
}
// facetFixtureBooks is the under-filter counts fixture: two fantasy
// books (Pinecrest / Quartzmill) and one history book (Pinecrest).
// Publisher names are trigram-disjoint on purpose — the scalar-fuzzy
// filter is word_similarity, and near-identical names ("Ace Books" vs
// "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper()
ctx := context.Background()
books := []struct {
title string
genre string
publisher string
}{
{prefix + " Alpha", "fantasy", "Pinecrest"},
{prefix + " Beta", "fantasy", "Quartzmill"},
{prefix + " Gamma", "history", "Pinecrest"},
}
for _, b := range books {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: b.title,
Authors: []string{prefix + " Author"},
Genre: pgtype.Text{String: b.genre, Valid: true},
Publishers: []string{b.publisher},
Language: pgtype.Text{String: "en", Valid: true},
FilePath: fmt.Sprintf("/tmp/facet-counts/%s-%d.epub", b.title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
}
// facetCountValue is one decoded facet value+count.
type facetCountValue struct {
Value string `json:"value"`
Count float64 `json:"count"`
}
// facetCountsGet runs the all-facets endpoint (no facet param) and
// decodes {"facets": {param: [{value, count}, …]}}.
func facetCountsGet(t *testing.T, setup *TestServerSetup, params url.Values) (int, map[string][]facetCountValue) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return resp.StatusCode, nil
}
var body struct {
Facets map[string][]facetCountValue `json:"facets"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body.Facets
}
// requireFacetCount asserts one value's count within a facet.
func requireFacetCount(t *testing.T, facet []facetCountValue, value string, want float64) {
t.Helper()
for _, v := range facet {
if v.Value == value {
require.Equal(t, want, v.Count, "facet value %q count", value)
return
}
}
require.Failf(t, "facet value missing", "%q not in facet", value)
}
// TestFacetCountsUnderFilters (faceted plan Phase 7): the all-facets
// shape computes each facet's counts UNDER the other active filters —
// a facet's own block is omitted from its arm (classic faceted
// semantics: the genre list stays complete while genre filters the
// rest), multi-select is OR-within, and the family lens holds on the
// new shape too.
func TestFacetCountsUnderFilters(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err)
libName := fmt.Sprintf("Test Facet Counts %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet under-filter counts test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
hiddenLib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Facet Counts Hidden %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "hidden for the regular user", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, hiddenLib.ID) })
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenLib.ID, userID)
require.NoError(t, err)
facetFixtureBooks(t, setup, library.ID, "Counts")
// A book in the HIDDEN library: distinctive values that must never
// surface for the regular user.
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: hiddenLib.ID,
Title: "Counts Hidden Book",
Authors: []string{"Counts Secret Author"},
Genre: pgtype.Text{String: "cryptobotany", Valid: true},
Publishers: []string{"Shadowpress"},
FilePath: fmt.Sprintf("/tmp/facet-counts/hidden-%d.epub", time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
// No filters: the v1 global-per-library counts.
status, counts := facetCountsGet(t, setup, url.Values{"library_id": {libUUID.String()}})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
requireFacetCount(t, counts["genre"], "history", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// Under genre=fantasy: the genre facet STILL lists everything with
// unfiltered counts (its own block is omitted from its arm), while
// the publisher facet counts only within fantasy.
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"fantasy"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
requireFacetCount(t, counts["genre"], "history", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// OR-within: both genres → Pinecrest regains Alpha (fantasy) and
// Gamma (history).
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"fantasy,history"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// AND-across: genre=history + publisher=Pinecrest → the genre facet
// is unfiltered by ITSELF (its own block is omitted) but still
// counts under the active publisher filter: both Pinecrest books
// (Alpha fantasy, Gamma history) surface as 1 each, Beta (Quartzmill)
// is filtered away. The publisher facet sees only history books, so
// Pinecrest counts Gamma alone.
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"history"},
"publisher_filter": {"Pinecrest"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
// All-Libraries view (no library_id): the visible library's values
// are present, the hidden library's are absent everywhere.
status, counts = facetCountsGet(t, setup, url.Values{})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
for _, v := range counts["genre"] {
require.NotEqual(t, "cryptobotany", v.Value, "hidden-library genre must be absent")
}
for _, v := range counts["publisher"] {
require.NotEqual(t, "Shadowpress", v.Value, "hidden-library publisher must be absent")
}
// Family lens: an explicit HIDDEN library_id on the new shape is a
// 403 (AGENTS standing rule — never a silent pass).
status, _ = facetCountsGet(t, setup, url.Values{"library_id": {hiddenUUID.String()}})
require.Equal(t, http.StatusForbidden, status)
// The pinned single-facet shape is untouched by the extension.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?facet=genre&library_id="+libUUID.String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
var single struct {
Facet string `json:"facet"`
Values []facetCountValue `json:"values"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&single))
require.Equal(t, "genre", single.Facet)
requireFacetCount(t, single.Values, "fantasy", 2)
requireFacetCount(t, single.Values, "history", 1)
}
// TestFacetPanelPartial covers the drawer's SSR partial
// (GET /media-items/facet-panel): server-rendered registry-ordered
// sections, library-type scoping (Batch D), active-value marking, and
// the family lens on the HTML surface.
func TestFacetPanelPartial(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err)
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Facet Panel %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "facet panel partial test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
facetFixtureBooks(t, setup, library.ID, "Panel")
// Baseline: genre + publisher sections render with prettified
// genre labels and counts; type-restricted facets stay off the
// ebooks view (Batch D).
code, html := ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String(), setup.RegularToken)
require.Equal(t, http.StatusOK, code)
require.Contains(t, html, `data-param="genre_filter"`)
require.Contains(t, html, `data-param="publisher_filter"`)
require.Contains(t, html, "Fantasy", "genre values render prettified")
require.Contains(t, html, `data-value="fantasy"`)
require.NotContains(t, html, `data-param="manga_type_filter"`, "manga_type is manga-only (Batch D)")
require.NotContains(t, html, `data-param="narrator_filter"`, "narrator is audiobooks-only (Batch D)")
// Under genre=fantasy: the fantasy button is marked active and the
// history value is STILL listed (its own block is omitted), proving
// the partial's counts come from the under-filter query.
code, html = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String()+"&genre_filter=fantasy", setup.RegularToken)
require.Equal(t, http.StatusOK, code)
require.Contains(t, html, "facet-value-active")
require.Contains(t, html, `data-value="history"`, "a facet's own filter never removes its values")
// Family lens on the HTML surface: an out-of-set library_id is 403.
code, _ = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+uuid.New().String(), setup.RegularToken)
require.Equal(t, http.StatusForbidden, code)
}