feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.
- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
listable facets in a single round trip: each arm omits its OWN
facet's filter block and carries every other registered facet's
block (copied mechanically from SearchMediaItemsUnified) — classic
faceted semantics: with filters active each facet counts under the
others; with none, blocks are neutral and the counts are the v1
global-per-library ones. One query serves both cases. The 14
per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
({facets: {param: [{value, display?, count}]}}), filters bound via
the shared ParseMediaSearchParams, library_id optional (the
All-Libraries view, family lens still enforced); facet=X keeps the
pinned single-facet shape (now routed through the same query with
neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
value lists — registry order, Batch D library-type scoping
(FacetsForLibraryType: manga_type on manga, reading_direction on
manga+comics, narrator on audiobooks; All view = union), prettified
genre labels + language display names, active values marked. Family
lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
apply with the CURRENT filter query string; a value click toggles
that facet's comma-joined filter (hidden input where the drawer has
no field — same as deep links) and re-applies the form; chips and
URL stay in sync. Values are server-rendered (rendering doctrine);
bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
implementation (services' display enrichment shares it; templ wraps
it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
registry facet's param, the bools as pgtype.Bool.
Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
1 parent
6152137156
commit
f52bb6f073
18 files changed
+3001
-911
No files matched your search
@@ -310,3 +310,261 @@ func mergeParams(base, extra url.Values) url.Values {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
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},
|
||||
})
|
||||
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) })
|
||||
|
||||
facetFixtureBooks(t, setup, library.ID, "Panel")
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user