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.
203 lines
6.6 KiB
Go
203 lines
6.6 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)
|
|
}
|
|
}
|
|
|
|
// Phase 7: the counts query (FacetCountsUnderFilters) carries the
|
|
// same filter blocks — copied mechanically from the search query —
|
|
// so the panel's under-filter counts and the grid's rows can never
|
|
// disagree about what a filter means. Reflect the generated params
|
|
// struct the same way.
|
|
countParamsType := reflect.TypeOf(database.FacetCountsUnderFiltersParams{})
|
|
countTags := map[string]bool{}
|
|
for i := 0; i < countParamsType.NumField(); i++ {
|
|
tag := strings.Split(countParamsType.Field(i).Tag.Get("db"), ",")[0]
|
|
countTags[tag] = true
|
|
}
|
|
for _, want := range []string{"user_id", "library_id", "limit"} {
|
|
if !countTags[want] {
|
|
t.Errorf("FacetCountsUnderFilters missing %s param", want)
|
|
}
|
|
}
|
|
for _, f := range facetRegistry {
|
|
wire := f.WireParam()
|
|
if f.Kind == FacetRange {
|
|
if !countTags["year_min"] || !countTags["year_max"] {
|
|
t.Error("year range must bind year_min/year_max in FacetCountsUnderFilters")
|
|
}
|
|
continue
|
|
}
|
|
if !countTags[wire] {
|
|
t.Errorf("registry facet %q missing from FacetCountsUnderFilters (each arm binds every OTHER facet's block, so every facet's param must exist)", 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)
|
|
}
|
|
}
|