// 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])
}