Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
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(seedCtx(), 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(seedCtx(), 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(seedCtx(), 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(seedCtx(), 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(seedCtx(), 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(seedCtx(), 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)
|
|
}
|