// 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)) }) } // ---- download worker: state machine, idempotency, file layout ---- func TestPodcastDownloadWorker(t *testing.T) { setup := setupTestServer(t) if _, err := exec.LookPath("ffmpeg"); err != nil { if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil { t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio") } } lib, root := createPodcastsLibrary(t, setup, "test Download Podcasts") // Generate a small valid MP3 (1s tone) at runtime — no binary fixtures. mp3Path := filepath.Join(t.TempDir(), "tone.mp3") gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path) out, err := gen.CombinedOutput() require.NoError(t, err, "ffmpeg generate: %s", string(out)) audio, err := os.ReadFile(mp3Path) require.NoError(t, err) var enclosureHits int mux := http.NewServeMux() mux.HandleFunc("/audio/ep.mp3", func(w http.ResponseWriter, _ *http.Request) { enclosureHits++ w.Header().Set("Content-Type", "audio/mpeg") _, _ = w.Write(audio) }) mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("Download Feed", map[string]string{"dl-1": "Downloaded Episode"}))) }) 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)) feeds, err := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup)) require.NoError(t, err) var feedRow database.PodcastFeeds for _, f := range feeds { if f.Title == "Download Feed" { feedRow = f } } require.NotZero(t, feedRow.ID, "feed must exist after add") eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedRow.ID) require.NoError(t, err) require.Len(t, eps, 1) ep := eps[0] // Point the enclosure at the TEST server (fixtures use cdn.invalid). // The write goes through an ADMIN-declared ScopedDB — the episode // read policy hides such rows from the raw pool's seed identity, and // onUpdate UPDATE FOR UPDATE reads the target row first. adminCtx := adminSeedCtx(t, setup) adminScope := database.NewScopedDB(setup.DBPool) _, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)), `UPDATE media_items SET enclosure_url = $2 WHERE id = $1`, ep.ID, srv.URL+"/audio/ep.mp3") require.NoError(t, err) updated, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID) require.NoError(t, err) assert.Equal(t, srv.URL+"/audio/ep.mp3", updated.EnclosureUrl.String, "enclosure re-pointed at the test server") worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB)) workerCtx, cancel := context.WithCancel(context.Background()) defer cancel() worker.Start(workerCtx) feedIDStr := uuid.UUID(feedRow.ID.Bytes).String() epIDStr := uuid.UUID(ep.ID.Bytes).String() resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "") require.Equal(t, http.StatusAccepted, resp) // Terminal state within the worker's drain cadence. var done database.MediaItems require.Eventually(t, func() bool { row, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID) if err != nil { return false } done = row return row.DownloadState == "downloaded" || row.DownloadState == "failed" }, 30*time.Second, 250*time.Millisecond, "download must reach a terminal state (got %s)", done.DownloadState) require.Equal(t, "downloaded", done.DownloadState, "inline reason (if failed): %s", done.DownloadError.String) assert.True(t, done.FileSize.Int64 > 0) assert.NotEmpty(t, done.AudioMetadata, "ffprobe facts must land in audio_metadata") assert.FileExists(t, filepath.Join(root, done.FilePath), "episode lands under // per the pinned layout") t.Run("redownload_of_downloaded_episode_is_a_noop", func(t *testing.T) { hitsBefore := enclosureHits resp, _ := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "") require.Equal(t, http.StatusAccepted, resp) time.Sleep(1500 * time.Millisecond) assert.Equal(t, hitsBefore, enclosureHits, "PIN: re-download of a downloaded episode must not re-fetch") }) t.Run("failed_download_persists_reason_inline", func(t *testing.T) { // A dead enclosure: fetch fails, state+reason persist, existing // metadata is never cleared. Writes run under the acting admin // (the seed identity would read-match zero rows under the // subscription policy). // The failure path only exercises with the file ABSENT — an // on-disk, probe-clean file re-marks downloaded without fetching // (the idempotency reconciliation, already pinned by the no-op // subtest above). require.NoError(t, os.Remove(filepath.Join(root, done.FilePath))) _, err := adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)), `UPDATE media_items SET enclosure_url = 'https://127.0.0.1:1/none.mp3', download_state = 'pending', download_error = NULL, download_attempts = 0 WHERE id = $1`, ep.ID) require.NoError(t, err) resp, _ := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "") require.Equal(t, http.StatusAccepted, resp) require.Eventually(t, func() bool { r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID) if err != nil { return false } if r.DownloadState != "failed" { t.Logf("still not failed: state=%s enclosure=%q attempts=%d err=%q", r.DownloadState, r.EnclosureUrl.String, r.DownloadAttempts, r.DownloadError.String) return false } return true }, 30*time.Second, 2*time.Second, "dead enclosure must end failed") r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID) require.NoError(t, err) assert.NotEmpty(t, r.DownloadError.String, "PIN: the reason text persists (renders inline in the episode list)") assert.Equal(t, "Downloaded Episode", r.Title, "PIN: failures never clear episode metadata") }) } // ---- invariant 4 pin: the scanner never sees podcasts folders ---- func TestPodcastScannerExclusion(t *testing.T) { setup := setupTestServer(t) lib, root := createPodcastsLibrary(t, setup, "test ScanSkip Podcasts") // A stray audio file inside the download root must never ingest. require.NoError(t, os.MkdirAll(filepath.Join(root, "some-feed"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(root, "some-feed", "01-ep.mp3"), []byte("not really audio"), 0o644)) scanner := services.NewMediaScanner(setup.DB) err := scanner.SetFolders([]string{root}, false) if err != nil { // Zero scannable folders errors at the watcher (watch=true) — // scan jobs (watch=false) still configure; assert either way. assert.Contains(t, err.Error(), "feed-managed") } assert.Empty(t, scanner.FolderCount(), "PIN: podcasts download roots are never scanner folders") assert.NotZero(t, lib.ID, "library must exist for the pin to be meaningful") } // ---- invariant 3 / task 2.6: order flips never move progress ---- func TestPodcastReorderSafety(t *testing.T) { setup := setupTestServer(t) adminCtx := adminSeedCtx(t, setup) lib, _ := createPodcastsLibrary(t, setup, "test Reorder Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/reorder.xml", Title: "Reorder Feed", }) require.NoError(t, err) e1 := upsertTestEpisode(t, setup, feed, "ro-1", "Reorder One", 1) e2 := upsertTestEpisode(t, setup, feed, "ro-2", "Reorder Two", 2) // Mark both DOWNLOADED so they enter the sequence (the lifecycle // itself is pinned by TestPodcastDownloadWorker). 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'`, e1.ID, e2.ID) adminID := adminCtxValue(t, setup) // Listener is 40% through episode TWO (direct row write — the pin // is about the row's LIFE under renumbering, not the write API). adminScopeExec(t, setup, adminID, `INSERT INTO reading_progress (media_item_id, user_id, percentage, chapter, character_offset) VALUES ($1, $2, 0.4, 0, 0) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 0.4, last_read_at = NOW()`, e2.ID, adminID) before, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{ MediaItemID: e2.ID, UserID: adminID, }) require.NoError(t, err) pctBefore := before.Percentage.Float64 // FLIP the feed's native positions (the fetcher renumbering case: // the publisher reorders; positions renumber on the next fetch). adminScopeExec(t, setup, adminID, `UPDATE media_items SET feed_position = CASE id WHEN $1 THEN 2 WHEN $2 THEN 1 END WHERE id IN ($1, $2)`, e1.ID, e2.ID) after, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{ MediaItemID: e2.ID, UserID: adminID, }) require.NoError(t, err) assert.Equal(t, pctBefore, after.Percentage.Float64, "PIN: progress anchors on the episode (guid/identity), never the ordinal — reordering must not move it") // The sequence still lists both downloaded episodes; the override // only changes ORDER, never membership. memberID := getRegularUserID(t, setup.DB) subscribeRaw(t, setup, memberID, feed.ID) _, body := podcastAuthed(t, setup, setup.Token, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String()+"/sequence", "") var seq struct { Episodes []struct { ID string `json:"id"` } `json:"episodes"` Gaps []interface{} `json:"gaps"` } require.NoError(t, json.Unmarshal(body, &seq)) assert.Len(t, seq.Episodes, 2, "sequence membership survives renumbering") assert.Empty(t, seq.Gaps, "no un-downloaded rows → no gaps") } // adminScopeExec runs raw SQL under the first-admin identity (helper // for pins that need direct row surgery the Queries API lacks). func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, sql string, args ...any) { t.Helper() scope := database.NewScopedDB(setup.DBPool) _, err := scope.Exec(rls.WithUser(context.Background(), adminID), sql, args...) require.NoError(t, err) } // ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ---- func TestPodcastOrderFormEncoding(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test OrderForm Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/order-form.xml", Title: "Order Form Feed", }) require.NoError(t, err) feedIDStr := uuid.UUID(feed.ID.Bytes).String() // The UI's hx-put submits urlencoded form bodies (htmx non-GET // default) — the binder must map them (form tag), not just JSON. formPut := func(body string) int { req, _ := http.NewRequest(http.MethodPut, setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() return resp.StatusCode } assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind") assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too") assert.Equal(t, http.StatusOK, formPut("order=reverse"), "reverse binds too (§0 item 3)") assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)") assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected") } // ---- §0 item 3: reverse = feed position DESC (no pub dates involved) ---- func TestPodcastReverseOrder(t *testing.T) { setup := setupTestServer(t) adminCtx := adminSeedCtx(t, setup) adminID := adminCtxValue(t, setup) lib, _ := createPodcastsLibrary(t, setup, "test ReverseOrder Podcasts") feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/reverse-order.xml", Title: "Reverse Order Feed", }) require.NoError(t, err) feedIDStr := uuid.UUID(feed.ID.Bytes).String() // Episodes with MESSY dates (the Scribl condition): all equal. // reverse must be driven by feed position alone — position 3 first. epA := upsertTestEpisode(t, setup, feed, "rr-1", "Reverse Alpha", 1) epB := upsertTestEpisode(t, setup, feed, "rr-2", "Reverse Beta", 2) epC := upsertTestEpisode(t, setup, feed, "rr-3", "Reverse Gamma", 3) adminScopeExec(t, setup, adminID, ` UPDATE media_items SET download_state = 'downloaded', file_size = 1000, date_published = '2020-01-01T00:00:00Z'::timestamptz WHERE id IN ($1, $2, $3) AND source = 'podcast_episode'`, epA.ID, epB.ID, epC.ID) subscribeRaw(t, setup, uuid.UUID(adminID.Bytes), feed.ID) // Set the override to reverse (the wire the "Reverse feed order" // button uses). formPut := func(body string) int { req, _ := http.NewRequest(http.MethodPut, setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() return resp.StatusCode } require.Equal(t, http.StatusOK, formPut("order=reverse")) // The SEQUENCE endpoint must return position 3, 2, 1 — driven by // feed position DESC, dates being all equal (the Scribl guard). _, body := podcastAuthed(t, setup, setup.Token, "GET", "/api/podcasts/feeds/"+feedIDStr+"/sequence", "") var seq struct { Order string `json:"order"` Episodes []struct { Title string `json:"title"` FeedPosition int `json:"feed_position"` } `json:"episodes"` } require.NoError(t, json.Unmarshal(body, &seq)) assert.Equal(t, "reverse", seq.Order, "the endpoint echoes the effective order") require.Len(t, seq.Episodes, 3) assert.Equal(t, 3, seq.Episodes[0].FeedPosition, "PIN: reverse = position DESC, date-agnostic") assert.Equal(t, 1, seq.Episodes[2].FeedPosition) assert.Equal(t, "Reverse Gamma", seq.Episodes[0].Title, "position 3 first — a messy-date feed reorders by position, not dates") } // ---- §0 item 4: concurrent pool — tickets, exclusivity, drain ---- func TestPodcastDownloadPool(t *testing.T) { setup := setupTestServer(t) if _, err := exec.LookPath("ffmpeg"); err != nil { if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil { t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio") } } lib, _ := createPodcastsLibrary(t, setup, "test Pool Podcasts") mp3Path := filepath.Join(t.TempDir(), "tone.mp3") gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path) out, err := gen.CombinedOutput() require.NoError(t, err, "ffmpeg generate: %s", string(out)) audio, err := os.ReadFile(mp3Path) require.NoError(t, err) mux := http.NewServeMux() mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "audio/mpeg") _, _ = w.Write(audio) }) mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("Pool Feed", map[string]string{ "pl-1": "Pool One", "pl-2": "Pool Two", "pl-3": "Pool Three", }))) }) 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) feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx) require.NoError(t, err) var feedRow database.PodcastFeeds for _, f := range feeds { if f.Title == "Pool Feed" { feedRow = f } } require.NotZero(t, feedRow.ID, "feed must exist after add") eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) require.NoError(t, err) require.Len(t, eps, 3) // Point enclosures at the test server (admin-declared write). adminScope := database.NewScopedDB(setup.DBPool) _, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)), `UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`, feedRow.ID, srv.URL+"/audio/ep.mp3") require.NoError(t, err) worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB)) actx, err := worker.AdminCtx(context.Background()) require.NoError(t, err) // Queue in a KNOWN order: tickets (download_queue_pos SEQUENCE) make // the enqueue loop's ordering the claim order — the §0 item 5 // foundation ("getting the last chapter first doesn't help me"). ordered := make([]database.MediaItems, 0, len(eps)) for _, wantPos := range []int32{1, 2, 3} { for _, ep := range eps { if ep.FeedPosition.Int32 == wantPos { ordered = append(ordered, ep) } } } for _, ep := range ordered { _, err := setup.DB.SetPodcastEpisodeDownloadQueued(actx, ep.ID) require.NoError(t, err) } queued, err := setup.DB.ListQueuedPodcastEpisodes(actx) require.NoError(t, err) require.Len(t, queued, 3) for i, ep := range ordered { assert.Equal(t, ep.ID, queued[i].ID, "PIN: ticket order = enqueue order (claim claims in enqueue order)") } t.Run("claim_is_exclusive", func(t *testing.T) { first, err := setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID) require.NoError(t, err) assert.Equal(t, "downloading", first.DownloadState) // A second claimant racing the same row gets NOTHING — the // single-statement guard (queued → downloading) is the // SKIP-LOCKED-equivalent guarantee. _, err = setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID) assert.Error(t, err, "second claim of a downloading row must not match") // Hand it back for the drain below (enqueue only takes // pending|failed — a stuck 'downloading' needs the manual reset, // exactly what the crash-recovery path does). require.NoError(t, setup.DB.SetPodcastEpisodePending(actx, queued[0].ID)) _, err = setup.DB.SetPodcastEpisodeDownloadQueued(actx, queued[0].ID) require.NoError(t, err) }) worker.DrainNow(context.Background()) require.Eventually(t, func() bool { rowset, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) if err != nil { return false } for _, row := range rowset { if row.DownloadState != "downloaded" { return false } } return len(rowset) == 3 }, 30*time.Second, 250*time.Millisecond, "all 3 episodes must download through the pool") } // ---- issue #8: page search hits EPISODE titles/descriptions too ---- func TestPodcastPageSearchEpisodes(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test PageSearch Podcasts") // Feed title deliberately does NOT contain the needle. feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ LibraryID: lib.ID, FeedUrl: "https://feed.invalid/page-search.xml", Title: "Trader Tales Show", }) require.NoError(t, err) upsertTestEpisode(t, setup, feed, "ps-1", "Zarkorrin Blaster", 1) subscribeRaw(t, setup, getRegularUserID(t, setup.DB), feed.ID) // SSR page search: the needle exists only in an EPISODE title. req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+"/podcasts?q=Zarkorrin", nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() require.Equal(t, http.StatusOK, resp.StatusCode) var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) body := buf.String() assert.Contains(t, body, "Trader Tales Show", "PIN: a feed surfaces when an EPISODE matches the query, even when the feed's own metadata does not") assert.Contains(t, body, "matching episode", "the match count renders on the card") } // ---- issue #3: Download All flips the CALLER's auto-download to all ---- // podcastAuthedForm posts urlencoded (what htmx actually sends for non-GET — // the bug class in §0 item 2: the add modal posts a form, not JSON). func podcastAuthedForm(t *testing.T, setup *TestServerSetup, token, method, path string, form url.Values) (int, []byte) { t.Helper() req, _ := http.NewRequest(method, setup.Server.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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() } func TestPodcastDownloadAllPolicyFlip(t *testing.T) { setup := setupTestServer(t) regularID := getRegularUserID(t, setup.DB) lib, _ := createPodcastsLibrary(t, setup, "test Flip Podcasts") mux := http.NewServeMux() mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("Flip Feed", map[string]string{"f-1": "Flip One"}))) }) srv := swapFeedServer(t, mux.ServeHTTP) libID := uuid.UUID(lib.ID.Bytes).String() regularPG := pgtype.UUID{Bytes: regularID, Valid: true} t.Run("add_feed_with_download_all_sets_policy_all", func(t *testing.T) { resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", fmt.Sprintf(`{"library_id":%q,"url":%q,"download_all":true}`, libID, srv.URL+"/feed.xml")) require.Equal(t, http.StatusCreated, resp, string(body)) sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{ UserID: regularPG, FeedID: func() pgtype.UUID { feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG)) for _, f := range feeds { if f.Title == "Flip Feed" { return f.ID } } return pgtype.UUID{} }(), }) require.NoError(t, err) assert.Equal(t, "all", sub.AutoDownload, "PIN: the add-feed checkbox flips the caller's policy to all") }) t.Run("download_all_button_flips_policy_and_returns_oob_select", func(t *testing.T) { // Set the policy to newest first: the flip must override it. feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG)) var feedID pgtype.UUID for _, f := range feeds { if f.Title == "Flip Feed" { feedID = f.ID } } _, err := setup.DB.UpdatePodcastSubscription(rls.WithUser(context.Background(), regularPG), database.UpdatePodcastSubscriptionParams{ UserID: regularPG, FeedID: feedID, DisplayName: pgtype.Text{}, AutoDownload: "newest", AutoDownloadCount: pgtype.Int4{Int32: 3, Valid: true}, }) require.NoError(t, err) feedIDStr := uuid.UUID(feedID.Bytes).String() req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/episodes/all/download", nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) req.Header.Set("HX-Request", "true") hxResp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = hxResp.Body.Close() }() require.Equal(t, http.StatusOK, hxResp.StatusCode, "HX action answers with the refreshed partial") var buf bytes.Buffer _, _ = buf.ReadFrom(hxResp.Body) body := buf.String() assert.Contains(t, body, `id="episode-list"`, "direct swap: the episode list partial IS the response") assert.Contains(t, body, `hx-swap-oob="true"`, "issue #3: the policy select rides along out-of-band") sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{ UserID: regularPG, FeedID: feedID, }) require.NoError(t, err) assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)") }) } // ---- §0 item 2: download_all via the WIRE the add modal uses (urlencoded) ---- func TestPodcastDownloadAllFormEncoding(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test FormEnc Podcasts") mux := http.NewServeMux() mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("FormEnc Feed", map[string]string{"fe-1": "FormEnc One"}))) }) mux.HandleFunc("/feed2.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("FormEnc Default Feed", map[string]string{"fd-1": "FormEnc Default One"}))) }) mux.HandleFunc("/feed3.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("FormEnc On Feed", map[string]string{"fo-1": "FormEnc On One"}))) }) srv := swapFeedServer(t, mux.ServeHTTP) t.Run("urlencoded_add_with_download_all_true_flips_policy", func(t *testing.T) { form := url.Values{} form.Set("library_id", uuid.UUID(lib.ID.Bytes).String()) form.Set("url", srv.URL+"/feed.xml") form.Set("download_all", "true") // what the add modal's value="true" checkbox submits resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form) require.Equal(t, http.StatusCreated, resp, string(body)) ignore := assertPodcastPolicy(t, setup, "FormEnc Feed", srv.URL+"/feed.xml") assert.Equal(t, "all", ignore, "PIN: urlencoded download_all=true binds exactly like JSON — htmx posts forms, not JSON") }) t.Run("urlencoded_add_with_browser_native_on_also_flips", func(t *testing.T) { form := url.Values{} form.Set("library_id", uuid.UUID(lib.ID.Bytes).String()) form.Set("url", srv.URL+"/feed3.xml") form.Set("download_all", "on") // the raw value — echo's ParseBool chokes on it and failed the WHOLE bind (400) before the FlexBool fix resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form) require.Equal(t, http.StatusCreated, resp, string(body)) assert.Equal(t, "all", assertPodcastPolicy(t, setup, "FormEnc On Feed", srv.URL+"/feed3.xml")) }) t.Run("urlencoded_add_without_download_all_keeps_default", func(t *testing.T) { form := url.Values{} form.Set("library_id", uuid.UUID(lib.ID.Bytes).String()) form.Set("url", srv.URL+"/feed2.xml") resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form) require.Equal(t, http.StatusCreated, resp, string(body)) assert.Equal(t, "off", assertPodcastPolicy(t, setup, "FormEnc Default Feed", srv.URL+"/feed2.xml"), "PIN: an UNchecked add must NOT flip the policy (absent field ≠ true)") }) } // assertPodcastPolicy returns the regular user's auto_download for the // subscribed feed identified by (title, url). func assertPodcastPolicy(t *testing.T, setup *TestServerSetup, title, feedURL string) string { t.Helper() regularID := getRegularUserID(t, setup.DB) regularPG := pgtype.UUID{Bytes: regularID, Valid: true} feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG)) var feedID pgtype.UUID for _, f := range feeds { if f.Title == title && f.FeedUrl == feedURL { feedID = f.ID } } require.True(t, feedID.Valid, "subscribed feed %q (%s) must exist", title, feedURL) sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{ UserID: regularPG, FeedID: feedID, }) require.NoError(t, err) return sub.AutoDownload } // ---- logout actually signs out (fake-signout bug, 2026-10-30) ---- func TestLogoutClearsSession(t *testing.T) { setup := setupTestServer(t) // Real login: capture the session cookie AND the refresh token. req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/login", strings.NewReader(`{"login":"testuser@tests.bookhoard.internal","password":"Test@Pass123!"}`)) req.Header.Set("Content-Type", "application/json") loginResp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = loginResp.Body.Close() }() require.Equal(t, http.StatusOK, loginResp.StatusCode) var cookies []*http.Cookie for _, c := range loginResp.Cookies() { if c.Name == "token" && c.Value != "" { cookies = append(cookies, c) } } require.NotEmpty(t, cookies, "login must set the session cookie") var loginBody struct { RefreshToken string `json:"refresh_token"` } require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody)) require.NotEmpty(t, loginBody.RefreshToken, "login must hand back a refresh token") // Logout WITH the session cookie + refresh token. logoutReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`)) logoutReq.Header.Set("Content-Type", "application/json") for _, c := range cookies { logoutReq.AddCookie(c) } logoutResp, err := http.DefaultClient.Do(logoutReq) require.NoError(t, err) defer func() { _ = logoutResp.Body.Close() }() require.Equal(t, http.StatusOK, logoutResp.StatusCode) // PIN 1: the response deletes the session cookie. var cleared bool for _, c := range logoutResp.Cookies() { if c.Name == "token" && c.Value == "" && c.MaxAge < 0 { cleared = true } } assert.True(t, cleared, "PIN: logout must Set-Cookie an empty/expired token (HttpOnly — the browser is the only eraser)") // PIN 2: the refresh token is revoked server-side. refreshReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/refresh", strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`)) refreshReq.Header.Set("Content-Type", "application/json") refreshResp, err := http.DefaultClient.Do(refreshReq) require.NoError(t, err) defer func() { _ = refreshResp.Body.Close() }() assert.NotEqual(t, http.StatusOK, refreshResp.StatusCode, "PIN: a logged-out refresh token must not mint new access tokens") // Logout WITHOUT a body still clears the cookie (malformed/absent // payloads must never leave the browser signed in). logoutReq2, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", nil) for _, c := range cookies { logoutReq2.AddCookie(c) } logoutResp2, err := http.DefaultClient.Do(logoutReq2) require.NoError(t, err) defer func() { _ = logoutResp2.Body.Close() }() cleared = false for _, c := range logoutResp2.Cookies() { if c.Name == "token" && c.Value == "" && c.MaxAge < 0 { cleared = true } } assert.True(t, cleared, "cookie clear must not depend on the request body") } // ---- §0 item 5: add-modal Download order — override + queue order ---- func TestPodcastAddModalDownloadOrder(t *testing.T) { setup := setupTestServer(t) lib, _ := createPodcastsLibrary(t, setup, "test AddOrder Podcasts") mux := http.NewServeMux() mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(podcastFixture("AddOrder Feed", map[string]string{ "ao-1": "AddOrder One", "ao-2": "AddOrder Two", "ao-3": "AddOrder Three", }))) }) srv := swapFeedServer(t, mux.ServeHTTP) form := url.Values{} form.Set("library_id", uuid.UUID(lib.ID.Bytes).String()) form.Set("url", srv.URL+"/feed.xml") form.Set("download_all", "true") form.Set("download_order", "reverse") // the add-modal dropdown (§0 item 5c) resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form) require.Equal(t, http.StatusCreated, resp, string(body)) regularID := getRegularUserID(t, setup.DB) regularPG := pgtype.UUID{Bytes: regularID, Valid: true} rctx := rls.WithUser(context.Background(), regularPG) workerCtx := adminSeedCtx(t, setup) feeds, _ := setup.DB.ListPodcastFeedsForPolling(rctx) var feedID pgtype.UUID for _, f := range feeds { if f.Title == "AddOrder Feed" { feedID = f.ID } } require.True(t, feedID.Valid, "subscribed feed must exist") // PIN 1: the override row exists (the dropdown IS the initial order). row, err := setup.DB.GetUserSettingByKey(rctx, database.GetUserSettingByKeyParams{ UserID: regularPG, SettingKey: "podcast_order:" + feedID.String(), }) require.NoError(t, err) assert.Contains(t, string(row), "reverse", "PIN: the add-modal dropdown sets the caller's order override") // PIN 2: the queue follows the LISTENING order — with reverse, the // tickets run position 3 → 1 (claim order = download order). Read // the PERSISTED tickets rather than live queued rows: the shared // dev server's worker races this test's queue and flips rows to // failed (its container can't reach the fixture URLs) — tickets // survive every state flip. var tickets []struct { ID string Pos int64 } adminScope := database.NewScopedDB(setup.DBPool) ticketRows, err := adminScope.Query(workerCtx, `SELECT id::TEXT, download_queue_pos FROM media_items WHERE source_feed_id = $1 AND download_queue_pos IS NOT NULL ORDER BY download_queue_pos ASC`, feedID) require.NoError(t, err) for ticketRows.Next() { var id string var pos int64 require.NoError(t, ticketRows.Scan(&id, &pos), "scan ticket") tickets = append(tickets, struct { ID string Pos int64 }{id, pos}) } ticketRows.Close() require.Len(t, tickets, 3, "all 3 episodes got tickets") eps, err := setup.DB.ListPodcastEpisodes(workerCtx, feedID) require.NoError(t, err) byID := map[string]int32{} for _, ep := range eps { byID[uuid.UUID(ep.ID.Bytes).String()] = ep.FeedPosition.Int32 } assert.Equal(t, int32(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first") assert.Equal(t, int32(1), byID[tickets[2].ID]) }