fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field

The first real-browser create of an Episodes-scope collection failed
with "invalid request": the modal's hx-post submits
application/x-www-form-urlencoded, and the form value for filter_tree
had to bind into CreateCollectionRequest.FilterTree (any) — echo's
form binder errors on any-typed fields instead of setting the string,
so every form carrying a tree 400'd before validation even ran. JSON
bodies (the save-filter bridge, the editor's Apply) were unaffected,
which is why no API-level or Go test caught it: only a browser posting
the real form shape exercised the transport.

Fix: the form variant binds a dedicated string field
(FilterTreeForm, form:"filter_tree") on both request structs while
FilterTree keeps the JSON tag; resolveTreePayload picks whichever
transport arrived and both ride the same parseRequestFilterTree
validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded
seed → 201 → scope round-trips).
This commit is contained in:
John O'Keefe committed 2026-10-31 13:52:40 -04:00
1 parent cc0e9d7f91
commit 536fb4794c
2 files changed
+56 -6

No files matched your search

+29
View File
@@ -2150,3 +2150,32 @@ func TestEpisodeCollectionsEmptySeedMatchesNothing(t *testing.T) {
"an empty-seed episodes collection matches nothing — a composer that degraded to the bare scope head would report the whole library here")
assert.Empty(t, out.Books)
}
// The create modal posts the seed as a FORM (urlencoded hx-post), and
// echo's binder cannot set an any-typed struct field from a form
// string — the first in-browser create 400'd "invalid request"
// (2026-10-31). The form variant binds a dedicated string field and
// rides the same parse path; this pins the form transport.
func TestEpisodeCollectionsFormSeedCreate(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
marker := fmt.Sprintf("formseed%d", time.Now().UnixNano()%100000)
form := url.Values{}
form.Set("collection_type", "smart")
form.Set("name", "test FormSeed "+marker)
form.Set("filter_tree", `{"scope":"episodes","mode":"all","children":[]}`)
code, body := podcastAuthedForm(t, setup, regToken, "POST", "/api/collections", form)
require.Equal(t, http.StatusCreated, code, "the FORM-encoded seed must bind and parse: %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, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips")
}