Files
bookhoard/cmd/server/tests/facets_integration_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

313 lines
12 KiB
Go

package main
// Facet registry Batch A integration tests (faceted-navigation plan,
// Part 1 Phase 1): the nine new SearchMediaItemsUnified narg blocks —
// scalar-fuzzy (story_arc, imprint, edition), exact enums (age_rating,
// format_group, manga_type, reading_direction), and presence-bound
// bools (explicit, abridged) — filter correctly against a real DB.
// The registry↔SQL drift guard (unit level) lives in
// internal/services/facet_registry_test.go.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// facetSearch runs the unified search endpoint with arbitrary query
// params and returns (status, decoded array-or-object).
func facetSearch(t *testing.T, setup *TestServerSetup, params url.Values) (int, []map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+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 == http.StatusNotFound {
// The endpoint's no-results contract: 404 + error envelope.
return resp.StatusCode, nil
}
require.Equal(t, http.StatusOK, resp.StatusCode)
var rows []map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
return resp.StatusCode, rows
}
func facetTitles(t *testing.T, rows []map[string]interface{}) []string {
t.Helper()
var out []string
for _, r := range rows {
title, _ := r["title"].(string)
out = append(out, title)
}
return out
}
func TestFacetRegistryParamsFilter(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 Params Library %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet params 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) })
createItem := func(title string, mutate func(*database.CreateMediaItemParams)) {
t.Helper()
p := database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Param Author"},
FilePath: fmt.Sprintf("/tmp/facet-params/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
}
if mutate != nil {
mutate(&p)
}
_, err := setup.DB.CreateMediaItem(ctx, p)
require.NoError(t, err, "create %s", title)
}
createItem("Facet Alpha", func(p *database.CreateMediaItemParams) {
p.StoryArc = pgtype.Text{String: "civil war", Valid: true}
p.Imprint = pgtype.Text{String: "Vertigo", Valid: true}
p.Edition = pgtype.Text{String: "First Edition", Valid: true}
p.AgeRating = pgtype.Text{String: "Teen", Valid: true}
p.MangaType = pgtype.Text{String: "yes", Valid: true}
p.Explicit = true
})
createItem("Facet Beta", func(p *database.CreateMediaItemParams) {
p.StoryArc = pgtype.Text{String: "infinity gauntlet", Valid: true}
p.AgeRating = pgtype.Text{String: "Mature", Valid: true}
p.ReadingDirection = pgtype.Text{String: "rtl", Valid: true}
p.Abridged = true
})
base := url.Values{"library_id": []string{libUUID.String()}}
titles := func(params url.Values) []string {
status, rows := facetSearch(t, setup, mergeParams(base, params))
require.Equal(t, http.StatusOK, status)
return facetTitles(t, rows)
}
// Unfiltered: both items, the additive params are default-neutral.
got := titles(url.Values{})
assert.Len(t, got, 2, "unfiltered search returns both items")
// Scalar-fuzzy: single value...
got = titles(url.Values{"story_arc_filter": {"civil war"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "story_arc scalar-fuzzy")
got = titles(url.Values{"imprint_filter": {"Vertigo"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "imprint scalar-fuzzy")
got = titles(url.Values{"edition_filter": {"First Edition"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "edition scalar-fuzzy")
// ...and comma = OR-within (multi-select).
got = titles(url.Values{"story_arc_filter": {"civil war,infinity gauntlet"}})
assert.Len(t, got, 2, "comma is OR-within the facet")
// Exact enums: single + multi.
got = titles(url.Values{"age_rating_filter": {"Teen"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "age_rating exact")
got = titles(url.Values{"age_rating_filter": {"Teen,Mature"}})
assert.Len(t, got, 2, "age_rating multi-select")
got = titles(url.Values{"format_group_filter": {"reflowable"}})
assert.Len(t, got, 2, "format_group exact (create default)")
status, _ := facetSearch(t, setup, mergeParams(base, url.Values{"format_group_filter": {"audiobook"}}))
assert.Equal(t, http.StatusNotFound, status, "wrong format_group matches nothing")
got = titles(url.Values{"reading_direction_filter": {"rtl"}})
assert.Equal(t, []string{"Facet Beta"}, got, "reading_direction exact")
got = titles(url.Values{"manga_type_filter": {"yes"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "manga_type exact (works cross-type by data)")
// Presence-bound bools: absent = no filter, value = exact.
got = titles(url.Values{"explicit_filter": {"true"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "explicit=true")
got = titles(url.Values{"explicit_filter": {"false"}})
assert.Equal(t, []string{"Facet Beta"}, got, "explicit=false")
got = titles(url.Values{"abridged_filter": {"true"}})
assert.Equal(t, []string{"Facet Beta"}, got, "abridged=true")
// AND-across-facets: story_arc + age_rating intersect to Alpha.
got = titles(url.Values{"story_arc_filter": {"civil war"}, "age_rating_filter": {"Teen"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "AND-across-facets")
// Language UX (Batch B): a display name normalizes through the
// ingest map — "English" matches the row stored as "en".
createItem("Facet Gamma", func(p *database.CreateMediaItemParams) {
p.Language = pgtype.Text{String: "en", Valid: true}
})
got = titles(url.Values{"language_filter": {"English"}})
assert.Equal(t, []string{"Facet Gamma"}, got, "display name matches ISO-stored rows")
got = titles(url.Values{"language_filter": {"en"}})
assert.Equal(t, []string{"Facet Gamma"}, got, "raw code still matches")
}
// TestFacetsEndpoint covers GET /api/media-items/facets (Batch C): the
// registry-driven value+count listing, language display names, and the
// family-lens gates (hidden library → 403 on BOTH the facets and search
// endpoints; bool/range facets → 400 because they render as toggles).
func TestFacetsEndpoint(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 Listing Library %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet listing 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) })
// Two items share the genre; one carries the language + narrator.
for _, title := range []string{"Facet List One", "Facet List Two"} {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Listing Author"},
Genre: pgtype.Text{String: "fantasy", Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
FilePath: fmt.Sprintf("/tmp/facet-listing/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Facet List Three",
Authors: []string{"Facet Listing Author"},
Genre: pgtype.Text{String: "horror", Valid: true},
Narrators: []string{"Facet Listing Narrator"},
FilePath: fmt.Sprintf("/tmp/facet-listing/three-%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)
getFacets := func(params url.Values) (int, map[string]interface{}) {
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 map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body
}
// Counts correct + ordered by count desc.
status, body := getFacets(url.Values{
"facet": {"genre"},
"library_id": {libUUID.String()},
})
require.Equal(t, http.StatusOK, status)
assert.Equal(t, "genre", body["facet"])
values, _ := body["values"].([]interface{})
require.Len(t, values, 2)
first, _ := values[0].(map[string]interface{})
second, _ := values[1].(map[string]interface{})
assert.Equal(t, "fantasy", first["value"])
assert.Equal(t, float64(2), first["count"], "two items share the genre")
assert.Equal(t, "horror", second["value"])
assert.Equal(t, float64(1), second["count"])
// Language values carry display names (server owns display).
status, body = getFacets(url.Values{
"facet": {"language"},
"library_id": {libUUID.String()},
})
require.Equal(t, http.StatusOK, status)
values, _ = body["values"].([]interface{})
require.Len(t, values, 1)
lang, _ := values[0].(map[string]interface{})
assert.Equal(t, "en", lang["value"], "the raw code rides the param")
assert.Equal(t, "English", lang["display"], "the name is for display")
// Bool facets render as toggles, not lists → 400.
status, _ = getFacets(url.Values{
"facet": {"explicit"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusBadRequest, status)
// Unknown facet → 400.
status, _ = getFacets(url.Values{
"facet": {"woodcut"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusBadRequest, status)
// Family-lens: a library explicitly hidden from the regular user is
// a 403 on the facets endpoint AND on the search endpoint (an
// explicit library_id outside the visible set is never silently
// empty — AGENTS.md → Library visibility).
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
LibraryID: library.ID,
IsVisible: false,
})
require.NoError(t, err)
status, _ = getFacets(url.Values{
"facet": {"genre"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusForbidden, status, "hidden library → 403")
searchReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?library_id="+libUUID.String(), nil)
searchReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
searchResp, err := http.DefaultClient.Do(searchReq)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, searchResp.Body)
_ = searchResp.Body.Close()
assert.Equal(t, http.StatusForbidden, searchResp.StatusCode, "search with hidden library_id → 403")
}
func mergeParams(base, extra url.Values) url.Values {
out := url.Values{}
for k, v := range base {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}