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") }