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:
1 parent
c448fa438f
commit
65f257f377
12 files changed
+533
-80
No files matched your search
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user