feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)
- 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.
This commit is contained in:
1 parent
f52bb6f073
commit
ebb7f4a5b0
14 files changed
+192
-643
No files matched your search
@@ -518,11 +518,14 @@ func TestFacetCountsUnderFilters(t *testing.T) {
|
||||
requireFacetCount(t, single.Values, "history", 1)
|
||||
}
|
||||
|
||||
// TestFacetPanelPartial covers the drawer's SSR partial
|
||||
// (GET /media-items/facet-panel): server-rendered registry-ordered
|
||||
// sections, library-type scoping (Batch D), active-value marking, and
|
||||
// the family lens on the HTML surface.
|
||||
func TestFacetPanelPartial(t *testing.T) {
|
||||
// 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() })
|
||||
|
||||
@@ -531,40 +534,108 @@ func TestFacetPanelPartial(t *testing.T) {
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Facet Panel %d", time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "facet panel partial test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
|
||||
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)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
|
||||
// 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)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
facetFixtureBooks(t, setup, library.ID, "Panel")
|
||||
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")
|
||||
|
||||
// Baseline: genre + publisher sections render with prettified
|
||||
// genre labels and counts; type-restricted facets stay off the
|
||||
// ebooks view (Batch D).
|
||||
code, html := ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String(), setup.RegularToken)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Contains(t, html, `data-param="genre_filter"`)
|
||||
require.Contains(t, html, `data-param="publisher_filter"`)
|
||||
require.Contains(t, html, "Fantasy", "genre values render prettified")
|
||||
require.Contains(t, html, `data-value="fantasy"`)
|
||||
require.NotContains(t, html, `data-param="manga_type_filter"`, "manga_type is manga-only (Batch D)")
|
||||
require.NotContains(t, html, `data-param="narrator_filter"`, "narrator is audiobooks-only (Batch D)")
|
||||
|
||||
// Under genre=fantasy: the fantasy button is marked active and the
|
||||
// history value is STILL listed (its own block is omitted), proving
|
||||
// the partial's counts come from the under-filter query.
|
||||
code, html = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String()+"&genre_filter=fantasy", setup.RegularToken)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.Contains(t, html, "facet-value-active")
|
||||
require.Contains(t, html, `data-value="history"`, "a facet's own filter never removes its values")
|
||||
|
||||
// Family lens on the HTML surface: an out-of-set library_id is 403.
|
||||
code, _ = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+uuid.New().String(), setup.RegularToken)
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user