From 57bc0e9dd4670b631230e573eda00512dc8bbcc5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Nov 2026 13:44:23 -0500 Subject: [PATCH] feat(collections): the rules-Apply path seeds the reversed default too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation seed (d56031f3) covered born-smart collections; a STATIC collection that becomes smart through the rules editor's Apply landed without any curated order — its dropdown stayed "Feed order" while every other podcast smart collection opened reversed. Same decision, same guardrails (2026-11-01 user approved closing this gap): a PUT carrying an episodes-scope tree seeds chain_order=reverse only when neither the request NOR the stored view_settings names one — a deliberate native (client-sent or saved) is never clobbered, and books-scope trees seed nothing. Pins: apply-seeds-when-unordered, respects-stored-native, respects-client-native (TestSmartCollectionBornReversed now covers both seed paths); changelogged in contract_pins. --- cmd/server/tests/podcast_test.go | 37 ++++++++++++++++++++++++++++++++ docs/contract_pins.md | 11 ++++++++++ internal/handlers/collections.go | 21 ++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 451eccc2..c90325c3 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -2414,6 +2414,43 @@ func TestSmartCollectionBornReversed(t *testing.T) { assert.NotContains(t, string(body), `"chain_order"`, "static collections get no chain_order seed") }) + + // The creation seed's twin (2026-11-01): APPLYING an episodes tree + // to a collection with no curated order seeds reverse — a static + // collection becoming smart lands with the same default a + // born-smart one gets. Explicit values (client-sent or stored) are + // never clobbered. + t.Run("apply_path_seeds_when_unordered", func(t *testing.T) { + id := create(t, `{"name":"test BornRev Apply `+t.Name()+`"}`) + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id, + `{"name":"test BornRev Apply `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + _, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "") + assert.Contains(t, string(body), `"chain_order":"reverse"`, + "applying an episodes tree to an unordered collection seeds reverse") + }) + + t.Run("apply_path_respects_stored_native", func(t *testing.T) { + id := create(t, `{"name":"test BornRev KeepNative `+t.Name()+`","view_settings":{"chain_order":"native"}}`) + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id, + `{"name":"test BornRev KeepNative `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + _, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "") + assert.Contains(t, string(body), `"chain_order":"native"`, + "a stored explicit chain_order is never clobbered by the apply seed") + assert.NotContains(t, string(body), `"chain_order":"reverse"`) + }) + + t.Run("apply_path_respects_client_native", func(t *testing.T) { + id := create(t, `{"name":"test BornRev ClientNative `+t.Name()+`"}`) + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id, + `{"name":"test BornRev ClientNative `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]},"view_settings":{"chain_order":"native"}}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + _, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "") + assert.Contains(t, string(body), `"chain_order":"native"`, + "the client's explicit chain_order wins over the apply seed") + assert.NotContains(t, string(body), `"chain_order":"reverse"`) + }) } // ---- label endpoint: names id-kind values for client-held trees ---- diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 2c740a09..d1f64d97 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -703,3 +703,14 @@ resolves through the endpoint and rebuilds the chips bar when labels land. Pinned (TestFilterLabelsEndpoint): a zero-episode feed resolves its title, comma-joined ids resolve together, an unsubscribed feed id stays unnamed (RLS), non-id fields are 400. + +## 2026-11-01 — the rules-Apply path seeds the reversed default too + +PUT /api/collections/:id carrying an EPISODES-scope filter_tree seeds +`chain_order = "reverse"` when neither the request nor the stored +view_settings names one — the creation seed's twin: a static collection +becoming smart lands with the same default a born-smart one gets. +Explicit values (client-sent or stored — e.g. a deliberate native) +are never clobbered. Pinned (TestSmartCollectionBornReversed): +apply-seeds-when-unordered, respects stored native, respects client +native. diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index f324ac46..a9e82b4e 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -607,6 +607,27 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error { if treeErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) } + // Applying a tree to a collection with NO curated listening order + // seeds the reversed default (2026-11-01 — the creation seed's twin): + // a static collection becoming smart lands with the same default a + // born-smart one gets. Triple-guarded: the client's explicit + // chain_order wins, a STORED chain_order is never clobbered, and + // only episodes-scope trees seed (books have no episode chain). + if treeJSON != nil && (req.ViewSettings == nil || req.ViewSettings["chain_order"] == nil) { + if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil && + t.FilterScope() == services.ScopeEpisodes { + if existing, gerr := h.collectionService.GetCollection(c.Request().Context(), collectionID); gerr == nil { + var stored map[string]interface{} + _ = json.Unmarshal(existing.ViewSettings, &stored) + if stored == nil || stored["chain_order"] == nil { + if req.ViewSettings == nil { + req.ViewSettings = map[string]interface{}{} + } + req.ViewSettings["chain_order"] = "reverse" + } + } + } + } collection, err := h.collectionService.UpdateCollection( c.Request().Context(), collectionID,