feat(dashboard): custom-section builder saves again — the flagged tree migration

Archaeology, prompted by the user asking when it broke: the builder's
save NEVER worked. The page was born (2026-02-19, a92bf99a) posting
auto_assign_rules as a JSON-encoded STRING into an endpoint that has
bound []Rule since its creation (2026-01-31, 6495cc2c), plus fields
(manual_book_ids, match_type, library_id, show_on_dashboard) the
endpoint never accepted. The preview button hit a real endpoint, so
the page looked alive while Save always failed at request binding.
Verified live before this change: the page's exact payload 400'd
"invalid request" against the unmodified server.

The fix adapts the builder's payload at write time — the legacy rules
vocabulary converts to a filter tree (RulesToFilterTree restored from
the retirement commit as the single write path, unit test restored
with it):

- match_type seeds the converted tree's root mode (any default).
- manual_book_ids attach to the created collection after create
  (unparsable ids skipped — the AddBooks leniency).
- show_on_dashboard persists (new create field).
- Converted trees validate under ParseFilterTree: an unhonorable rule
  is a 400 at write naming the problem, never a stored 500 at render.

Dashboard sections: smart user sections now evaluate their tree LIVE —
GetUserCollectionItems merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed a REAL production wiring bug: every production
NewDashboardService call omitted the search service, so tree
evaluation through the dashboard could never have worked (the test
harness wired it correctly — the gap was production-only and latent,
because nothing in the dashboard evaluated trees before this
migration). The dashboard handler and both inline sites now receive
the search service, and evaluation failures log instead of silently
emptying a section.

Supersedes the same-day loud-400 stance for the builder's payload
(contract_pins migration entry); custom-section-builder.md documents
the save contract; the create_collection API doc gains the adapted
fields. New TestCustomSectionBuilderSave/BadRule pin the payload →
tree → dashboard-render path end to end. Full container suite green
(155s).
This commit is contained in:
John O'Keefe committed 2026-10-31 19:40:43 -04:00
1 parent a86bdac87e
commit 7c7e282e79
11 files changed
+445 -36

No files matched your search

@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// The custom-section builder's save, end to end (2026-10-31 tree
// migration). The page posts the legacy rules vocabulary — rules as a
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
// written, 2026-02-19; the save never once bound successfully before
// the migration) — plus match_type, hand-picked book ids, and the
// dashboard pin. The server converts the rules to a filter tree at
// write time, attaches the manual picks, and the dashboard section
// renders stored membership + the tree's live matches, deduped.
func TestCustomSectionBuilderSave(t *testing.T) {
setup := setupTestServer(t)
adminToken := setup.Token
regToken := setup.RegularToken
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
libUUID, err := uuid.Parse(libID)
require.NoError(t, err)
// Two items the title rule matches, one it doesn't. Alpha is ALSO
// hand-picked — the section must render it once, not twice.
makeItem := func(title string) uuid.UUID {
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"CSB Author"},
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
FileSize: pgtype.Int8{Int64: 128, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr)
out, perr := uuid.FromBytes(id.ID.Bytes[:])
require.NoError(t, perr)
return out
}
alpha := makeItem("CSBRULE Alpha")
_ = makeItem("CSBRULE Beta")
manual := makeItem("CSB Manual Pick")
require.NotZero(t, alpha)
// The EXACT payload shape the builder page posts.
payload := fmt.Sprintf(`{
"library_id": %q,
"name": "test CSB Section %s",
"icon": "📚",
"description": "built by the custom-section builder",
"show_on_dashboard": true,
"auto_assign_rules": %[3]s,
"manual_book_ids": [%q, %q],
"match_type": "all"
}`, libID, marker,
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
alpha.String(), manual.String())
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
collID := coll.ID
require.NotEmpty(t, collID)
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
})
// The converted tree round-trips: root mode from match_type, the
// rule as a qf child, and the collection IS smart with no
// collection_type sent.
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var detail struct {
IsSmart bool `json:"is_smart"`
FilterTree struct {
Mode string `json:"mode"`
Children []struct {
Field string `json:"field"`
Op string `json:"op"`
Values []string `json:"values"`
} `json:"children"`
} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal([]byte(body), &detail))
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
require.Len(t, detail.FilterTree.Children, 1)
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
// The dashboard section renders stored membership + live matches,
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
code, body = podcastAuthed(t, setup, regToken, "GET",
"/api/dashboard/sections?library_id="+libID, "")
require.Equal(t, http.StatusOK, code, "%s", string(body))
var dash struct {
Sections []struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(body), &dash))
var section *struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
for i := range dash.Sections {
if dash.Sections[i].CollectionID == collID {
section = &dash.Sections[i]
break
}
}
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
titles := map[string]int{}
for _, it := range section.Items {
titles[it.Title]++
}
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
}
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
// rule the engine cannot honor is a 400 that names the field, never a
// stored 500 at render time.
func TestCustomSectionBuilderBadRule(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
assert.Contains(t, string(body), "section rule")
}