fix(filter-builder): id labels resolve at render — refresh keeps names

Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
This commit is contained in:
John O'Keefe committed 2026-11-01 10:44:48 -05:00
1 parent c448fa438f
commit 65f257f377
12 files changed
+533 -80

No files matched your search

+126
View File
@@ -2208,3 +2208,129 @@ func TestEpisodeCollectionsFormSeedCreate(t *testing.T) {
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips")
}
// ── SSR id-label resolution (2026-11-01) ─────────────────────────────
// Pages rendering a filter tree embed the id→name map at render time
// (data-id-labels). The pre-fix resolution path — the media-constrained
// suggest facet — could only label ids whose media matches the tree's
// SIBLING rules: live repro "feed in [A,B] AND title contains X"
// labeled one feed of three. The resolver reads the entity tables: a
// feed with ZERO episodes labels fine; a hidden library does not.
func TestRulesPageEmbedsIdLabels(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test Labels Podcasts")
marker := fmt.Sprintf("labelsembed%d", time.Now().UnixNano()%100000)
// Feed A: subscribed, one downloaded episode matching the sibling
// rule. Feed B: subscribed, ZERO episodes — no media row exists, so
// the facet path structurally could never label it.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "Labels Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "Labels Feed B " + marker,
})
require.NoError(t, err)
aEp := upsertTestEpisode(t, setup, feedA, marker+"-a", "Labels A Ep "+marker, 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id = $1`, aEp.ID)
subscribeRaw(t, setup, regUUID, feedA.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
feedBID := uuid.UUID(feedB.ID.Bytes).String()
feedBTitle := "Labels Feed B " + marker
feedATitle := "Labels Feed A " + marker
// The user's exact shape: feed in [A,B] AND title contains "…".
tree := map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID + "," + feedBID}},
{"field": "title", "op": "contains", "values": []string{"Labels A Ep"}},
},
}
treeJSON, _ := json.Marshal(tree)
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Labels Coll `+marker+`","filter_tree":`+string(treeJSON)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
t.Run("rules_page_labels_zero_episode_feed", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
html := string(pageBody)
require.Contains(t, html, "data-id-labels", "the embed attribute renders")
// Feed B's title can ONLY come from the labels embed — the feed
// has no media, so nothing else on the page can name it.
assert.Contains(t, html, feedBTitle,
"a zero-episode feed resolves its label at render")
assert.Contains(t, html, feedATitle)
})
// Books-scope: a URL-carried qf library rule embeds the library's
// name — until the family lens hides that library, at which point
// the resolver must NOT name it (the facet's visibility shape).
// Library creation is admin-only; the regular user still SEES the
// library (default-ALLOW visibility) until the subtest hides it.
booksLibID := createTestLibrary(t, setup.Server, setup.Token, "test Labels Books Lib "+marker)
libUUID := uuid.MustParse(booksLibID)
qf := url.QueryEscape(`{"scope":"books","mode":"all","children":[` +
`{"field":"library","op":"is","values":["` + booksLibID + `"]}]}`)
bookshelf := func() string {
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf?qf="+qf, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
return string(pageBody)
}
t.Run("bookshelf_labels_url_qf_library", func(t *testing.T) {
html := bookshelf()
require.Contains(t, html, "data-id-labels")
assert.Contains(t, html, "test Labels Books Lib "+marker,
"the qf library rule's id resolves to its name at render")
})
t.Run("hidden_library_not_labeled", func(t *testing.T) {
seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
t.Cleanup(func() {
seedExec(t, setup.DBPool,
`DELETE FROM library_visibility WHERE library_id = $1 AND user_id = $2`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
})
html := bookshelf()
assert.NotContains(t, html, "test Labels Books Lib "+marker,
"a family-lens-hidden library is not named, even by explicit id")
})
}