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