// Podcast integration tests (plan task 1.10): RLS surfaces (the // auditor — hidden-library + cross-user invisibility through every // podcast surface), add-feed flow with allow_member_feeds gating, // upsert-without-delete (shrunken feeds), edit segregation, search // scoping, and the download worker state machine (ffmpeg-generated // audio at runtime — no binary fixtures in the repo). // // Doctrine: shared dev DB — test libraries/feeds named "test*"; the // library delete at cleanup cascades feeds/episodes/subscriptions. package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "time" "bookhoard/internal/database" "bookhoard/internal/rls" "bookhoard/internal/services" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // podcastFixture builds a minimal RSS 2.0 doc with the given episodes // (guid/title pairs) — committable-text fixtures only, generated inline. func podcastFixture(channelTitle string, episodes map[string]string) string { items := "" for guid, title := range episodes { items += fmt.Sprintf(` %s %s sub of %s Description of %s 600 Mon, 26 Oct 2026 08:00:00 +0000 `, guid, title, guid, title, guid) } return ` ` + channelTitle + ` en-us Integration test feed Test Host serial` + items + ` ` } // swapFeedServer hosts feed XML over httptest TLS and wires the // fetcher's test client (the production client refuses loopback by // design — the SSRF boundary; restored on cleanup). func swapFeedServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() srv := httptest.NewTLSServer(handler) t.Cleanup(func() { services.SetPodcastHTTPClientForTests(nil) srv.Close() }) services.SetPodcastHTTPClientForTests(srv.Client()) return srv } // createPodcastsLibrary provisions a podcasts-type library with a // download root in a temp dir (test-prefixed name; cleanup cascades). // Returns the download root for file-layout assertions. func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (database.Libraries, string) { t.Helper() ctx := seedCtx() libType, err := setup.DB.GetLibraryTypeByName(context.Background(), "podcasts") require.NoError(t, err, "podcasts library type must be seeded") lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ Name: name, Description: pgtype.Text{String: "podcast integration test library", Valid: true}, LibraryTypeID: libType.ID, }) require.NoError(t, err) root := t.TempDir() // download root _, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ LibraryID: lib.ID, FolderPath: root, Purpose: services.FolderPurposePodcast, }) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) }) return lib, root } // upsertTestEpisode seeds one metadata-only episode row. func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems { t.Helper() ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{ LibraryID: feed.LibraryID, Title: title, Authors: []string{}, AuthorsSearch: []string{}, FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid), Tags: []string{}, SourceFeedID: feed.ID, EpisodeGuid: pgtype.Text{String: guid, Valid: true}, FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true}, EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true}, Hosts: []string{}, Guests: []string{}, }) require.NoError(t, err, "seed episode %q", title) return ep } // subscribeRaw inserts a subscription row (podcast_subscriptions is // ungoverned by design — the read policy's lookup table). func subscribeRaw(t *testing.T, setup *TestServerSetup, userID uuid.UUID, feedID pgtype.UUID) { t.Helper() _, err := setup.DBPool.Exec(context.Background(), `INSERT INTO podcast_subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, userID, feedID) require.NoError(t, err) } // adminSeedCtx declares the FIRST-ADMIN identity — the same one the // fetcher/worker use (invariant 12). The harness seed identity has // neither a subscription nor a users row, so podcast INSERT..RETURNING // (which evaluates the SELECT policy under FORCE) 42501s for it; real // admin writes are exactly what production pods run as. func adminSeedCtx(t *testing.T, setup *TestServerSetup) context.Context { t.Helper() admin, err := setup.DB.GetFirstAdmin(context.Background()) require.NoError(t, err, "podcast tests need an admin identity") return rls.WithUser(context.Background(), admin) } // adminCtxValue is the raw pgtype.UUID behind adminSeedCtx (for // ScopedDB.Exec calls that want the same identity). func adminCtxValue(t *testing.T, setup *TestServerSetup) pgtype.UUID { t.Helper() admin, err := setup.DB.GetFirstAdmin(context.Background()) require.NoError(t, err) return admin } func podcastAuthed(t *testing.T, setup *TestServerSetup, token, method, path, body string) (int, []byte) { t.Helper() var req *http.Request if body == "" { req, _ = http.NewRequest(method, setup.Server.URL+path, nil) } else { req, _ = http.NewRequest(method, setup.Server.URL+path, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) return resp.StatusCode, buf.Bytes() } // ---- the RLS auditor for podcast surfaces (plan task 1.10 / invariant 13) ---- func TestPodcastRLSSurfaces(t *testing.T) { setup := setupTestServer(t) regularID := getRegularUserID(t, setup.DB) marker := "PODRLS" + uuid.New().String()[:8] lib, _ := createPodcastsLibrary(t, setup, "test RLS Podcasts "+marker) feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/" + marker + ".xml", Title: "RLS Probe Feed " + marker, }) require.NoError(t, err) ep := upsertTestEpisode(t, setup, feed, "guid-1", marker+" Episode One", 1) adminToken := setup.Token regularToken := setup.RegularToken t.Run("non_subscriber_gets_404_everywhere", func(t *testing.T) { code, _ := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "") assert.Equal(t, http.StatusNotFound, code, "unsubscribed feed must be invisible (existence leak)") code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "") require.Equal(t, http.StatusOK, code) // empty via body check below in subscriber test's mirror code, _ = podcastAuthed(t, setup, regularToken, "GET", "/podcasts/"+uuid.UUID(feed.ID.Bytes).String(), "") assert.Equal(t, http.StatusNotFound, code, "detail page must 404 for non-subscribers") }) t.Run("episode_hidden_from_generic_media_reads", func(t *testing.T) { code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/media-items?limit=500", "") require.Equal(t, http.StatusOK, code) assert.NotContains(t, string(body), marker, "LEAK: podcast episode surfaced through generic media list") }) t.Run("admin_sees_unsubscribed_feed", func(t *testing.T) { code, body := podcastAuthed(t, setup, adminToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "") assert.Equal(t, http.StatusOK, code, "admin escape must see any feed") assert.Contains(t, string(body), marker) _ = ep }) t.Run("subscriber_sees_feed_then_hidden_library_hides_it", func(t *testing.T) { feedIDStr := uuid.UUID(feed.ID.Bytes).String() subscribeRaw(t, setup, regularID, feed.ID) code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), marker, "subscriber must see their feed") code, body = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), marker, "subscription-scoped search covers the subscriber's feed") // Hide the library → the engine hides the feed row again (the // feed policy carries the library-visibility join). _, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{ UserID: pgtype.UUID{Bytes: regularID, Valid: true}, LibraryID: lib.ID, IsVisible: false, }) require.NoError(t, err) t.Cleanup(func() { _, _ = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{ UserID: pgtype.UUID{Bytes: regularID, Valid: true}, LibraryID: lib.ID, IsVisible: true, }) }) code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "") assert.Equal(t, http.StatusNotFound, code, "hidden library must hide its feeds even from subscribers") }) } // ---- add-feed flow: fetch+parse+upsert+subscribe; allow_member_feeds gate ---- func TestPodcastAddFeedFlow(t *testing.T) { setup := setupTestServer(t) regularID := getRegularUserID(t, setup.DB) lib, _ := createPodcastsLibrary(t, setup, "test AddFeed Podcasts") mux := http.NewServeMux() feedXML := podcastFixture("AddFlow Feed", map[string]string{"ep-a": "Episode A", "ep-b": "Episode B"}) mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(feedXML)) }) mux.HandleFunc("/other.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("Other Feed", map[string]string{"other-1": "Other Episode"}))) }) srv := swapFeedServer(t, mux.ServeHTTP) feedURL := srv.URL + "/feed.xml" libID := uuid.UUID(lib.ID.Bytes).String() addBody := fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, feedURL) regularPG := pgtype.UUID{Bytes: regularID, Valid: true} t.Run("member_adds_feed_subscribes_and_episodes_upsert_metadata_only", func(t *testing.T) { resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody) require.Equal(t, http.StatusCreated, resp, "member add should succeed: %s", string(body)) // The subscription exists; the feed is on the member's list. code, body := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), "AddFlow Feed") // Episode rows are metadata-only: pending, no file facts. feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG) require.NoError(t, err) require.Len(t, feeds, 1) eps, err := setup.DB.ListPodcastEpisodes(rls.WithUser(context.Background(), pgtype.UUID{Bytes: regularID, Valid: true}), feeds[0].ID) require.NoError(t, err) require.Len(t, eps, 2) for _, ep := range eps { assert.Equal(t, "pending", ep.DownloadState, "metadata-only rows stay pending until a download") assert.NotEmpty(t, ep.EnclosureUrl.String, "enclosure URL persisted for the download worker") assert.NotZero(t, ep.FeedPosition.Int32) } }) t.Run("duplicate_url_attaches_without_new_row", func(t *testing.T) { resp, _ := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody) assert.Equal(t, http.StatusCreated, resp) feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG) require.NoError(t, err) assert.Len(t, feeds, 1, "URL dedupe: no second feed row") }) t.Run("allow_member_feeds_false_rejects_members_allows_admins", func(t *testing.T) { _, err := setup.DBPool.Exec(context.Background(), `UPDATE libraries SET allow_member_feeds = false WHERE id = $1`, lib.ID) require.NoError(t, err) t.Cleanup(func() { _, _ = setup.DBPool.Exec(context.Background(), `UPDATE libraries SET allow_member_feeds = true WHERE id = $1`, lib.ID) }) // A second feed URL from a member → rejected. otherURL := srv.URL + "/other.xml" resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL)) assert.Equal(t, http.StatusForbidden, resp, "member add must be refused: %s", string(body)) // Admin keeps the right. resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds", fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL)) assert.Equal(t, http.StatusCreated, resp, "admin add must succeed") // Subscribing to an EXISTING feed stays allowed for members // (the gate governs ADDING feeds, not subscribing). code, _ := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "") require.Equal(t, http.StatusOK, code) }) } // ---- upsert-without-delete: shrunken feeds keep their history (D14) ---- func TestPodcastShrunkenFeedRetention(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test Shrink Podcasts") current := podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One", "ep-2": "Doomed Two"}) mux := http.NewServeMux() mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(current)) }) srv := swapFeedServer(t, mux.ServeHTTP) libID := uuid.UUID(lib.ID.Bytes).String() resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds", fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml")) require.Equal(t, http.StatusCreated, resp, string(body)) adminCtx := adminSeedCtx(t, setup) allFeeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx) require.NoError(t, err) var feedID pgtype.UUID for _, f := range allFeeds { if f.Title == "Shrink Feed" { feedID = f.ID } } feedIDStr := uuid.UUID(feedID.Bytes).String() eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedID) require.NoError(t, err) require.Len(t, eps, 2) // The publisher purges an episode from the XML. current = podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One"}) resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "") require.Equal(t, http.StatusOK, resp) eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedID) require.NoError(t, err) require.Len(t, eps, 2, "PIN: shrunken feeds NEVER delete rows — history accumulates for search") } // ---- edit segregation (D10) ---- func TestPodcastEditSegregation(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test EditSeg Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/edit-seg.xml", Title: "EditSeg Feed", }) require.NoError(t, err) ep := upsertTestEpisode(t, setup, feed, "seg-1", "Segregated Episode", 1) epID := uuid.UUID(ep.ID.Bytes).String() t.Run("generic_edit_rejects_podcast_episodes", func(t *testing.T) { resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+epID, `{"title":"Hacked Title","description":"clobbered"}`) assert.Equal(t, http.StatusBadRequest, resp, "PIN: generic edit must reject episodes: %s", string(body)) }) t.Run("write_to_file_rejects_podcast_episodes", func(t *testing.T) { resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/media-items/"+epID+"/write-metadata", "") assert.NotEqual(t, http.StatusOK, resp, "PIN: metadata write-to-file must refuse episodes: %s", string(body)) }) t.Run("book_edit_still_works", func(t *testing.T) { // Control: the rejection is podcast-specific, not a broken route. libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks") require.NoError(t, err) book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ LibraryID: func() pgtype.UUID { lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{ Name: "test EditSeg Books", LibraryTypeID: libType.ID, }) require.NoError(t, cerr) t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) }) return lib2.ID }(), Title: "Edit Control Book", FilePath: "control-" + uuid.New().String() + ".m4b", }) require.NoError(t, err) resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), `{"title":"Edited Control"}`) assert.True(t, resp == http.StatusOK || resp == http.StatusBadRequest, "control book edit must not 404/500 (podcast-specific gate): %d %s", resp, string(body)) }) } // ---- invariant 2, absolute form: the generic DELETE never touches // episode rows (only whole-feed removal does) ---- func TestDeleteMediaItemPodcastGuard(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test DeleteGuard Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/delete-guard.xml", Title: "DeleteGuard Feed", }) require.NoError(t, err) ep := upsertTestEpisode(t, setup, feed, "delguard-1", "Delete Guard Episode", 1) epID := uuid.UUID(ep.ID.Bytes).String() t.Run("admin_delete_of_episode_refused", func(t *testing.T) { code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+epID, "") assert.Equal(t, http.StatusConflict, code, "PIN (invariant 2, 2026-10-30): episode rows are removed ONLY by whole-feed removal — the generic delete must 409: %s", string(body)) _, err := setup.DB.GetMediaItem(adminSeedCtx(t, setup), ep.ID) require.NoError(t, err, "episode row must survive the refused delete") }) t.Run("book_delete_still_works", func(t *testing.T) { // Control: the rejection is podcast-specific, not a broken route. libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks") require.NoError(t, err) book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ LibraryID: func() pgtype.UUID { lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{ Name: "test DeleteGuard Books", LibraryTypeID: libType.ID, }) require.NoError(t, cerr) t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) }) return lib2.ID }(), Title: "Delete Control Book", FilePath: "control-" + uuid.New().String() + ".m4b", }) require.NoError(t, err) code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), "") assert.Equal(t, http.StatusNoContent, code, "generic delete must stay intact for scanned media: %d %s", code, string(body)) }) } // ---- reader_settings key width (§10 R5, item 2.1): composite // prefix: keys have headroom past the old 50-char ceiling ---- func TestReaderSettingsCompositeKeyHeadroom(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) // 57 chars — the deferred podcast_custom_order: shape. Under // the old VARCHAR(50) this upsert fails; the widening (item 2.1) // makes it ordinary preference state. key := "podcast_custom_order:01921a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b" require.Len(t, key, 57, "sentinel: the pinned key must exceed the old VARCHAR(50)") err := setup.DB.UpsertUserSettingByKey(seedCtx(), database.UpsertUserSettingByKeyParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, SettingKey: key, SettingValue: []byte(`{"order":"custom"}`), }) require.NoError(t, err, "composite keys longer than 50 chars must store (VARCHAR(120) headroom)") val, err := setup.DB.GetUserSettingByKey(seedCtx(), database.GetUserSettingByKeyParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, SettingKey: key, }) require.NoError(t, err) require.JSONEq(t, `{"order":"custom"}`, string(val)) } // ---- item 2.2: queue context source + the player's feed-mode order // control (§10 R2) ---- func TestPodcastPlayerOrderControl(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test PlayerOrder Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/player-order.xml", Title: "PlayerOrder Feed", }) require.NoError(t, err) ep1 := upsertTestEpisode(t, setup, feed, "po-1", "PlayerOrder One", 1) ep2 := upsertTestEpisode(t, setup, feed, "po-2", "PlayerOrder Two", 2) // download_state is download-owned (write-after-success guard) — // tests set it directly under the first-admin identity (raw-pool // execs on a detached context hit RLS and silently write 0 rows). adminScopeExec(t, setup, adminCtxValue(t, setup), `UPDATE media_items SET download_state = 'downloaded', file_size = 1000 WHERE id IN ($1, $2) AND source = 'podcast_episode'`, ep1.ID, ep2.ID) subscribeRaw(t, setup, uuid.UUID(adminCtxValue(t, setup).Bytes), feed.ID) feedID := uuid.UUID(feed.ID.Bytes).String() ep1URL := "/readers/" + uuid.UUID(ep1.ID.Bytes).String() // The reader page embeds the sequence config in a data-json // ATTRIBUTE — templ HTML-escapes the quotes ("), so pins must // match the escaped form. esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } t.Run("context_carries_feed_source", func(t *testing.T) { code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), esc(`"source":"feed"`), "queue context must carry its owner (§10 R2)") assert.Contains(t, string(body), esc(`"feed_id":"`+feedID+`"`)) assert.Contains(t, string(body), esc(`"order":"native"`)) assert.Contains(t, string(body), esc(`"episode_index":1`)) }) t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) { code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), `id="player-order-select"`, "feed-mode queue renders the player order control") assert.Contains(t, string(body), `hx-put="/api/podcasts/feeds/`+feedID+`/order"`) assert.Contains(t, string(body), "Reverse episode order", "quick reverse button present") }) t.Run("order_override_hides_dropdown_after_switch", func(t *testing.T) { // Switch the order the way the player does (the same endpoint; // podcastAuthed sends JSON and the handler binds both shapes), // then re-render: the dropdown must reflect the new value. code, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/podcasts/feeds/"+feedID+"/order", `{"order":"reverse"}`) require.Equal(t, http.StatusOK, code, "%s", string(body)) code, body = podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), esc(`"order":"reverse"`)) assert.Contains(t, string(body), `