- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ, FacetCountsForView, drawer panel block, panel TS + .facet-* CSS. The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint, drift guard) — it becomes the Phase 9 builder's machinery. - Fix the All-Books bug: field-values autocomplete (author/genre/ tags/narrator/series/language) treats library_id as OPTIONAL — absent aggregates every VISIBLE library (family lens in-query); explicit out-of-set library_id is now 403, never a silent pass. The four scalar Search*Values queries get the nullable shape. - toast.ts: suggestion fetches are exempt from error toasts (console-only) — a failed background hint never interrupts. - TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll- Libraries pins the fix (200 + cross-library aggregation, family lens, 403). Contract-pins changelog entry added.
642 lines
26 KiB
Go
642 lines
26 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TestFieldValuesAllLibraries pins the All-Books suggestion path
|
|
// (filter-plan Phase 8): the field-values autocomplete treats
|
|
// library_id as OPTIONAL — absent aggregates every library VISIBLE to
|
|
// the caller (the per-user family lens is enforced inside the query),
|
|
// and an explicit out-of-set library_id is a 403. This is the
|
|
// regression net for the hard "library_id is required" 400 that broke
|
|
// typing in the filter drawer on the All-Books view.
|
|
func TestFieldValuesAllLibraries(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)
|
|
|
|
makeLib := func(name string) database.Libraries {
|
|
t.Helper()
|
|
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
|
Name: fmt.Sprintf("Test Field Values %s %d", name, time.Now().UnixNano()),
|
|
Description: pgtype.Text{String: "field values all-libraries test library", Valid: true},
|
|
LibraryTypeID: libType.ID,
|
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
|
return lib
|
|
}
|
|
visibleLib := makeLib("Visible")
|
|
hiddenLib := makeLib("Hidden")
|
|
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
|
|
require.NoError(t, err)
|
|
|
|
// One book per library with IDENTICAL distinctive values — the
|
|
// All-Books answer must aggregate across both libraries, and every
|
|
// value is trigram-disjoint from any real data ("Verdana…" nonsense).
|
|
for _, lib := range []struct {
|
|
id pgtype.UUID
|
|
name string
|
|
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
|
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: lib.id,
|
|
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
|
Authors: []string{"Verdana Scribe"},
|
|
Genre: pgtype.Text{String: "verdanafantasy", Valid: true},
|
|
Series: pgtype.Text{String: "Verdana Saga", Valid: true},
|
|
Language: pgtype.Text{String: "verdanish", Valid: true},
|
|
FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()),
|
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
fieldValuesGet := func(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.StatusOK {
|
|
return resp.StatusCode, nil
|
|
}
|
|
var body struct {
|
|
Results []map[string]interface{} `json:"results"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
return resp.StatusCode, body.Results
|
|
}
|
|
findValue := func(results []map[string]interface{}, value string) (found bool, count float64) {
|
|
t.Helper()
|
|
for _, r := range results {
|
|
if r["value"] == value {
|
|
found = true
|
|
count, _ = r["count"].(float64)
|
|
}
|
|
}
|
|
return found, count
|
|
}
|
|
|
|
// Phase A — NO library_id (the All-Books view): 200, and the same
|
|
// author surfaces ONCE with count 2 — values aggregate across every
|
|
// visible library. The old bug answered 400 here.
|
|
status, results := fieldValuesGet(url.Values{"author": {"Verdana"}})
|
|
require.Equal(t, http.StatusOK, status, "no library_id must be 200, not the old hard 400")
|
|
found, count := findValue(results, "Verdana Scribe")
|
|
require.True(t, found, "author value present on the All-Books path")
|
|
require.Equal(t, float64(2), count, "value aggregated across both visible libraries")
|
|
for field, value := range map[string]string{
|
|
"genre": "verdanafantasy", "series": "Verdana Saga", "language": "verdanish",
|
|
} {
|
|
status, results = fieldValuesGet(url.Values{field: {value}})
|
|
require.Equal(t, http.StatusOK, status, "%s autocomplete with no library_id", field)
|
|
found, count = findValue(results, value)
|
|
require.True(t, found, "%s value present on the All-Books path", field)
|
|
require.Equal(t, float64(2), count, "%s aggregated across both libraries", field)
|
|
}
|
|
|
|
// Phase B — family lens on the same All-Books path: hiding one
|
|
// library from this user halves the counts without any library_id
|
|
// in the request at all.
|
|
_, 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)
|
|
|
|
status, results = fieldValuesGet(url.Values{"author": {"Verdana"}})
|
|
require.Equal(t, http.StatusOK, status)
|
|
found, count = findValue(results, "Verdana Scribe")
|
|
require.True(t, found)
|
|
require.Equal(t, float64(1), count, "hidden library's books leave the All-Books suggestions")
|
|
|
|
// Phase C — an explicit out-of-set library_id is a 403, never a
|
|
// silent pass (AGENTS family-lens standing rule).
|
|
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {hiddenUUID.String()}})
|
|
require.Equal(t, http.StatusForbidden, status)
|
|
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {uuid.New().String()}})
|
|
require.Equal(t, http.StatusForbidden, status)
|
|
}
|