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) } }