Files
bookhoard/internal/services/facet_registry_test.go
T
John O'Keefe 7e586df48a feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.

- Nine new filter params on SearchMediaItemsUnified: story_arc,
  imprint, edition (scalar-fuzzy), age_rating, format_group,
  manga_type, reading_direction (exact), explicit, abridged
  (presence-bound bools). Comma = OR-within multi-select on every
  text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
  genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
  pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
  > empty (unknown-only stays tags-only). Type-words (Manga, Comics,
  Graphic Novel) join the demote list. Both 'adventure' and 'action &
  adventure' are shelves — provider data uses both and the verified
  traces pin them.
- Language filter UX: comma elements normalize through the ingest
  language map, so display names ('English') match rows stored as ISO
  codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
  driven value+count listing (14 static per-column queries, family-lens
  join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
  403 on the search endpoints (was a silent empty set) — JSON branch
  and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
  search JSON endpoint and the HTMX grid partial; the HTML branch
  previously dropped publisher_filter and narrator_filter silently.

Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
2026-10-19 18:59:23 -04:00

174 lines
5.5 KiB
Go

package services
import (
"reflect"
"strings"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// The generated SQL constant for the unified search query. The
// drift-guard test asserts every registry entry's narg block exists in
// it — sqlc has no dynamic WHERE, so registry ↔ SQL agreement is
// enforced here instead (plan: "one registry entry + one narg block").
func TestFacetRegistryShape(t *testing.T) {
seen := map[string]bool{}
knownLibraryTypes := map[string]bool{
"ebooks": true, "comics": true, "manga": true, "audiobooks": true,
}
for _, f := range facetRegistry {
if f.Param == "" {
t.Fatalf("facet with empty Param: %+v", f)
}
wire := f.WireParam()
if seen[wire] {
t.Errorf("duplicate wire param %q", wire)
}
seen[wire] = true
if f.Column == "" {
t.Errorf("%s: empty Column", wire)
}
// Kind ↔ Match consistency.
switch f.Kind {
case FacetArray:
if f.Match != MatchPerElementFuzzy {
t.Errorf("%s: array facets are per-element fuzzy, got %s", wire, f.Match)
}
case FacetScalar:
if f.Match != MatchScalarFuzzy && f.Match != MatchExact {
t.Errorf("%s: scalar facets are scalar-fuzzy or exact, got %s", wire, f.Match)
}
case FacetBool:
if f.Match != MatchExact {
t.Errorf("%s: bool facets are exact, got %s", wire, f.Match)
}
case FacetRange:
if f.Match != MatchExact {
t.Errorf("%s: range facets are exact, got %s", wire, f.Match)
}
default:
t.Errorf("%s: unknown kind %q", wire, f.Kind)
}
for _, lt := range f.LibraryTypes {
if !knownLibraryTypes[lt] {
t.Errorf("%s: unknown library type %q (seed names: ebooks, comics, manga, audiobooks)", wire, lt)
}
}
}
}
func TestFacetRegistrySQLDrift(t *testing.T) {
// sqlc substitutes sqlc.narg(...) with ? placeholders in the emitted
// const, so the drift guard reflects over the GENERATED params
// struct instead: a narg block exists in the query iff the struct
// has its field. This checks the artifact the binary actually runs.
paramsType := reflect.TypeOf(database.SearchMediaItemsUnifiedParams{})
dbTags := map[string]reflect.Type{}
for i := 0; i < paramsType.NumField(); i++ {
tag := strings.Split(paramsType.Field(i).Tag.Get("db"), ",")[0]
dbTags[tag] = paramsType.Field(i).Type
}
boolType := reflect.TypeOf(pgtype.Bool{})
for _, f := range facetRegistry {
wire := f.WireParam()
switch f.Kind {
case FacetRange:
// The year range binds the year_min/year_max pair.
if _, ok := dbTags["year_min"]; !ok {
t.Error("year range facets must bind year_min in SearchMediaItemsUnified")
}
if _, ok := dbTags["year_max"]; !ok {
t.Error("year range facets must bind year_max in SearchMediaItemsUnified")
}
case FacetBool:
ft, ok := dbTags[wire]
if !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
continue
}
if ft != boolType {
t.Errorf("%s: bool facet must generate pgtype.Bool (the ::bool cast), got %s", wire, ft)
}
default:
if _, ok := dbTags[wire]; !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
}
}
}
// The Batch A additions specifically (pin the param names).
for _, wire := range []string{
"story_arc_filter", "imprint_filter", "edition_filter",
"age_rating_filter", "format_group_filter", "manga_type_filter",
"reading_direction_filter", "explicit_filter", "abridged_filter",
} {
if _, ok := dbTags[wire]; !ok {
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
}
}
}
func TestFacetsForLibraryType(t *testing.T) {
// Empty = All Libraries → the UNION of every facet (Batch D decision).
all := FacetsForLibraryType("")
if len(all) != len(facetRegistry) {
t.Fatalf("all-libraries union = %d facets, want %d", len(all), len(facetRegistry))
}
byWire := func(fs []Facet) map[string]bool {
m := map[string]bool{}
for _, f := range fs {
m[f.WireParam()] = true
}
return m
}
manga := byWire(FacetsForLibraryType("manga"))
if !manga["manga_type_filter"] || !manga["reading_direction_filter"] {
t.Error("manga view must include manga_type and reading_direction")
}
if manga["narrator_filter"] {
t.Error("narrator facet is audiobooks-only, must not render on manga")
}
if !manga["author_filter"] || !manga["genre_filter"] {
t.Error("unrestricted facets render on every library type")
}
audio := byWire(FacetsForLibraryType("audiobooks"))
if !audio["narrator_filter"] {
t.Error("audiobooks view must include narrator")
}
if audio["manga_type_filter"] || audio["reading_direction_filter"] {
t.Error("manga facets must not render on audiobooks")
}
comics := byWire(FacetsForLibraryType("comics"))
if !comics["reading_direction_filter"] {
t.Error("comics view must include reading_direction")
}
if comics["manga_type_filter"] {
t.Error("manga_type is manga-only, must not render on comics")
}
unknown := byWire(FacetsForLibraryType("woodcuts"))
if unknown["manga_type_filter"] || unknown["narrator_filter"] {
t.Error("restricted facets must not render for an unknown type")
}
if !unknown["author_filter"] {
t.Error("unrestricted facets still render for an unknown type")
}
}
func TestSplitFacetValues(t *testing.T) {
if got := SplitFacetValues(""); got != nil {
t.Errorf("empty → nil, got %v", got)
}
got := SplitFacetValues("Vertigo, DC Black Label ,Vertigo")
if len(got) != 3 || got[0] != "Vertigo" || got[1] != "DC Black Label" || got[2] != "Vertigo" {
t.Errorf("SplitFacetValues = %v, want [Vertigo DC Black Label Vertigo]", got)
}
}