feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)

The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:22:17 -04:00
1 parent eb68672390
commit 4f71eda6be
16 files changed
+518 -1422

No files matched your search

+94 -139
View File
@@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI
return result
}
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
t.Helper()
payload := map[string]interface{}{
"field": field,
"operator": operator,
"value": value,
}
if enabled != nil {
payload["enabled"] = *enabled
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
t.Helper()
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
rule := result["rule"].(map[string]interface{})
return rule["id"].(string)
}
// Data helpers -----------------------------------------------------------
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
t.Helper()
ctx := context.Background()
@@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) {
setup := setupTestServer(t)
adminID := getTestUserID(t, setup.DB)
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
// The unified engine (item 3.2): a collection created WITH a
// books-scope tree evaluates LIVE — stored links are ignored,
// only tree matches render.
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart SF Book One": "Science Fiction",
"Smart SF Book Two": "Science Fiction",
"Smart FANT Book": "Fantasy",
})
_ = bookIDs
// Rule evaluation ignores stored links: a static collection's own
// added item must NOT show up once the collection is smart.
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
// Add one hand-picked book (a Fantasy one that the smart rule
// will not match — so it must vanish from the render).
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Tree " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"])
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
memberTitles := smartLibraryTitleSet(t, smartDetail)
detail := smartGetCollection(t, setup, setup.Token, created.ID)
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
memberTitles := smartLibraryTitleSet(t, detail)
assert.True(t, memberTitles["Smart SF Book One"])
assert.True(t, memberTitles["Smart SF Book Two"])
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
})
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Diss Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
// Disable: membership drops to zero at once.
falseFlag := false
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
dReq.Header.Set("Content-Type", "application/json")
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
dResp, err := http.DefaultClient.Do(dReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, dResp.StatusCode)
_ = dResp.Body.Close()
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
})
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Guard Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Guard " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
@@ -238,52 +210,9 @@ func TestSmartCollections(t *testing.T) {
_ = removeResp.Body.Close()
})
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Back Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"])
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
delResp, err := http.DefaultClient.Do(delReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, delResp.StatusCode)
_ = delResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
// Membership mutations work again on the reverted static.
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
})
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
_ = resp.Body.Close()
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
// System collections may never carry rules.
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
// System collections may never carry rules/trees — the editor
// route refuses them (item 3.4: the page IS the editor).
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
require.NoError(t, err)
var systemID string
@@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) {
}
require.NotEmpty(t, systemID, "admin user should have system collections")
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
_ = sysResp.Body.Close()
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
"system collections never render the smart editor")
})
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart List Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart List " + uuid.NewString()[:8],
"filter_tree": tree,
})
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+setup.Token)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
_ = resp2.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
// Default list (the Android app's read): smart collections hidden.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)