Files
bookhoard/cmd/server/tests/podcast_test.go
T
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00

2580 lines
113 KiB
Go

// 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"
"io"
"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(`
<item>
<guid>%s</guid>
<title>%s</title>
<itunes:subtitle>sub of %s</itunes:subtitle>
<description>Description of %s</description>
<itunes:duration>600</itunes:duration>
<pubDate>Mon, 26 Oct 2026 08:00:00 +0000</pubDate>
<enclosure url="https://cdn.invalid/%s.mp3" length="65536" type="audio/mpeg"/>
</item>`, guid, title, guid, title, guid)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>` + channelTitle + `</title>
<language>en-us</language>
<description>Integration test feed</description>
<itunes:author>Test Host</itunes:author>
<itunes:type>serial</itunes:type>` + items + `
</channel>
</rss>`
}
// swapFeedServer hosts feed XML over httptest TLS and wires the
// fetcher's test client (the production client refuses loopback by
// design — the SSRF boundary; restored on cleanup).
func swapFeedServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(func() {
services.SetPodcastHTTPClientForTests(nil)
srv.Close()
})
services.SetPodcastHTTPClientForTests(srv.Client())
return srv
}
// createPodcastsLibrary provisions a podcasts-type library with a
// download root in a temp dir (test-prefixed name; cleanup cascades).
// Returns the download root for file-layout assertions.
func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (database.Libraries, string) {
t.Helper()
ctx := seedCtx()
libType, err := setup.DB.GetLibraryTypeByName(context.Background(), "podcasts")
require.NoError(t, err, "podcasts library type must be seeded")
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "podcast integration test library", Valid: true},
LibraryTypeID: libType.ID,
})
require.NoError(t, err)
root := t.TempDir() // download root
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: lib.ID,
FolderPath: root,
Purpose: services.FolderPurposePodcast,
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib, root
}
// upsertTestEpisode seeds one metadata-only episode row.
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
t.Helper()
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
LibraryID: feed.LibraryID,
Title: title,
Authors: []string{},
AuthorsSearch: []string{},
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
Tags: []string{},
SourceFeedID: feed.ID,
EpisodeGuid: pgtype.Text{String: guid, Valid: true},
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
Hosts: []string{},
Guests: []string{},
})
require.NoError(t, err, "seed episode %q", title)
return ep
}
// subscribeRaw inserts a subscription row (podcast_subscriptions is
// ungoverned by design — the read policy's lookup table).
func subscribeRaw(t *testing.T, setup *TestServerSetup, userID uuid.UUID, feedID pgtype.UUID) {
t.Helper()
_, err := setup.DBPool.Exec(context.Background(),
`INSERT INTO podcast_subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
userID, feedID)
require.NoError(t, err)
}
// adminSeedCtx declares the FIRST-ADMIN identity — the same one the
// fetcher/worker use (invariant 12). The harness seed identity has
// neither a subscription nor a users row, so podcast INSERT..RETURNING
// (which evaluates the SELECT policy under FORCE) 42501s for it; real
// admin writes are exactly what production pods run as.
func adminSeedCtx(t *testing.T, setup *TestServerSetup) context.Context {
t.Helper()
admin, err := setup.DB.GetFirstAdmin(context.Background())
require.NoError(t, err, "podcast tests need an admin identity")
return rls.WithUser(context.Background(), admin)
}
// adminCtxValue is the raw pgtype.UUID behind adminSeedCtx (for
// ScopedDB.Exec calls that want the same identity).
func adminCtxValue(t *testing.T, setup *TestServerSetup) pgtype.UUID {
t.Helper()
admin, err := setup.DB.GetFirstAdmin(context.Background())
require.NoError(t, err)
return admin
}
func podcastAuthed(t *testing.T, setup *TestServerSetup, token, method, path, body string) (int, []byte) {
t.Helper()
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, setup.Server.URL+path, nil)
} else {
req, _ = http.NewRequest(method, setup.Server.URL+path, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.Bytes()
}
// ---- the RLS auditor for podcast surfaces (plan task 1.10 / invariant 13) ----
func TestPodcastRLSSurfaces(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
marker := "PODRLS" + uuid.New().String()[:8]
lib, _ := createPodcastsLibrary(t, setup, "test RLS Podcasts "+marker)
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + ".xml",
Title: "RLS Probe Feed " + marker,
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "guid-1", marker+" Episode One", 1)
adminToken := setup.Token
regularToken := setup.RegularToken
t.Run("non_subscriber_gets_404_everywhere", func(t *testing.T) {
code, _ := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNotFound, code, "unsubscribed feed must be invisible (existence leak)")
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
require.Equal(t, http.StatusOK, code)
// empty via body check below in subscriber test's mirror
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/podcasts/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNotFound, code, "detail page must 404 for non-subscribers")
})
t.Run("episode_hidden_from_generic_media_reads", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/media-items?limit=500", "")
require.Equal(t, http.StatusOK, code)
assert.NotContains(t, string(body), marker, "LEAK: podcast episode surfaced through generic media list")
})
t.Run("admin_sees_unsubscribed_feed", func(t *testing.T) {
code, body := podcastAuthed(t, setup, adminToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusOK, code, "admin escape must see any feed")
assert.Contains(t, string(body), marker)
_ = ep
})
t.Run("subscriber_sees_feed_then_hidden_library_hides_it", func(t *testing.T) {
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
subscribeRaw(t, setup, regularID, feed.ID)
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), marker, "subscriber must see their feed")
code, body = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), marker, "subscription-scoped search covers the subscriber's feed")
// Hide the library → the engine hides the feed row again (the
// feed policy carries the library-visibility join).
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: lib.ID,
IsVisible: false,
})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: lib.ID,
IsVisible: true,
})
})
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
assert.Equal(t, http.StatusNotFound, code, "hidden library must hide its feeds even from subscribers")
})
}
// ---- add-feed flow: fetch+parse+upsert+subscribe; allow_member_feeds gate ----
func TestPodcastAddFeedFlow(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
lib, _ := createPodcastsLibrary(t, setup, "test AddFeed Podcasts")
mux := http.NewServeMux()
feedXML := podcastFixture("AddFlow Feed", map[string]string{"ep-a": "Episode A", "ep-b": "Episode B"})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(feedXML))
})
mux.HandleFunc("/other.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Other Feed", map[string]string{"other-1": "Other Episode"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
feedURL := srv.URL + "/feed.xml"
libID := uuid.UUID(lib.ID.Bytes).String()
addBody := fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, feedURL)
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
t.Run("member_adds_feed_subscribes_and_episodes_upsert_metadata_only", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
require.Equal(t, http.StatusCreated, resp, "member add should succeed: %s", string(body))
// The subscription exists; the feed is on the member's list.
code, body := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "AddFlow Feed")
// Episode rows are metadata-only: pending, no file facts.
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
require.NoError(t, err)
require.Len(t, feeds, 1)
eps, err := setup.DB.ListPodcastEpisodes(rls.WithUser(context.Background(), pgtype.UUID{Bytes: regularID, Valid: true}), feeds[0].ID)
require.NoError(t, err)
require.Len(t, eps, 2)
for _, ep := range eps {
assert.Equal(t, "pending", ep.DownloadState, "metadata-only rows stay pending until a download")
assert.NotEmpty(t, ep.EnclosureUrl.String, "enclosure URL persisted for the download worker")
assert.NotZero(t, ep.FeedPosition.Int32)
}
})
t.Run("duplicate_url_attaches_without_new_row", func(t *testing.T) {
resp, _ := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
assert.Equal(t, http.StatusCreated, resp)
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
require.NoError(t, err)
assert.Len(t, feeds, 1, "URL dedupe: no second feed row")
})
t.Run("allow_member_feeds_false_rejects_members_allows_admins", func(t *testing.T) {
_, err := setup.DBPool.Exec(context.Background(),
`UPDATE libraries SET allow_member_feeds = false WHERE id = $1`, lib.ID)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(context.Background(),
`UPDATE libraries SET allow_member_feeds = true WHERE id = $1`, lib.ID)
})
// A second feed URL from a member → rejected.
otherURL := srv.URL + "/other.xml"
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
assert.Equal(t, http.StatusForbidden, resp, "member add must be refused: %s", string(body))
// Admin keeps the right.
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
assert.Equal(t, http.StatusCreated, resp, "admin add must succeed")
// Subscribing to an EXISTING feed stays allowed for members
// (the gate governs ADDING feeds, not subscribing).
code, _ := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
require.Equal(t, http.StatusOK, code)
})
}
// ---- upsert-without-delete: shrunken feeds keep their history (D14) ----
func TestPodcastShrunkenFeedRetention(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test Shrink Podcasts")
current := podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One", "ep-2": "Doomed Two"})
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(current))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
adminCtx := adminSeedCtx(t, setup)
allFeeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
require.NoError(t, err)
var feedID pgtype.UUID
for _, f := range allFeeds {
if f.Title == "Shrink Feed" {
feedID = f.ID
}
}
feedIDStr := uuid.UUID(feedID.Bytes).String()
eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedID)
require.NoError(t, err)
require.Len(t, eps, 2)
// The publisher purges an episode from the XML.
current = podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One"})
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
require.Equal(t, http.StatusOK, resp)
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedID)
require.NoError(t, err)
require.Len(t, eps, 2, "PIN: shrunken feeds NEVER delete rows — history accumulates for search")
}
// ---- edit segregation (D10) ----
func TestPodcastEditSegregation(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test EditSeg Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/edit-seg.xml",
Title: "EditSeg Feed",
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "seg-1", "Segregated Episode", 1)
epID := uuid.UUID(ep.ID.Bytes).String()
t.Run("generic_edit_rejects_podcast_episodes", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+epID,
`{"title":"Hacked Title","description":"clobbered"}`)
assert.Equal(t, http.StatusBadRequest, resp, "PIN: generic edit must reject episodes: %s", string(body))
})
t.Run("write_to_file_rejects_podcast_episodes", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/media-items/"+epID+"/write-metadata", "")
assert.NotEqual(t, http.StatusOK, resp, "PIN: metadata write-to-file must refuse episodes: %s", string(body))
})
t.Run("book_edit_still_works", func(t *testing.T) {
// Control: the rejection is podcast-specific, not a broken route.
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
require.NoError(t, err)
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: func() pgtype.UUID {
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
Name: "test EditSeg Books",
LibraryTypeID: libType.ID,
})
require.NoError(t, cerr)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
return lib2.ID
}(),
Title: "Edit Control Book",
FilePath: "control-" + uuid.New().String() + ".m4b",
})
require.NoError(t, err)
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(),
`{"title":"Edited Control"}`)
assert.True(t, resp == http.StatusOK || resp == http.StatusBadRequest,
"control book edit must not 404/500 (podcast-specific gate): %d %s", resp, string(body))
})
}
// ---- invariant 2, absolute form: the generic DELETE never touches
// episode rows (only whole-feed removal does) ----
func TestDeleteMediaItemPodcastGuard(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test DeleteGuard Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/delete-guard.xml",
Title: "DeleteGuard Feed",
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "delguard-1", "Delete Guard Episode", 1)
epID := uuid.UUID(ep.ID.Bytes).String()
t.Run("admin_delete_of_episode_refused", func(t *testing.T) {
code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+epID, "")
assert.Equal(t, http.StatusConflict, code,
"PIN (invariant 2, 2026-10-30): episode rows are removed ONLY by whole-feed removal — the generic delete must 409: %s", string(body))
_, err := setup.DB.GetMediaItem(adminSeedCtx(t, setup), ep.ID)
require.NoError(t, err, "episode row must survive the refused delete")
})
t.Run("book_delete_still_works", func(t *testing.T) {
// Control: the rejection is podcast-specific, not a broken route.
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
require.NoError(t, err)
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: func() pgtype.UUID {
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
Name: "test DeleteGuard Books",
LibraryTypeID: libType.ID,
})
require.NoError(t, cerr)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
return lib2.ID
}(),
Title: "Delete Control Book",
FilePath: "control-" + uuid.New().String() + ".m4b",
})
require.NoError(t, err)
code, body := podcastAuthed(t, setup, setup.Token, "DELETE",
"/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNoContent, code,
"generic delete must stay intact for scanned media: %d %s", code, string(body))
})
}
// ---- reader_settings key width (§10 R5, item 2.1): composite
// prefix:<uuid> keys have headroom past the old 50-char ceiling ----
func TestReaderSettingsCompositeKeyHeadroom(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
// 57 chars — the deferred podcast_custom_order:<uuid> shape. Under
// the old VARCHAR(50) this upsert fails; the widening (item 2.1)
// makes it ordinary preference state.
key := "podcast_custom_order:01921a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b"
require.Len(t, key, 57, "sentinel: the pinned key must exceed the old VARCHAR(50)")
err := setup.DB.UpsertUserSettingByKey(seedCtx(), database.UpsertUserSettingByKeyParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
SettingKey: key,
SettingValue: []byte(`{"order":"custom"}`),
})
require.NoError(t, err, "composite keys longer than 50 chars must store (VARCHAR(120) headroom)")
val, err := setup.DB.GetUserSettingByKey(seedCtx(), database.GetUserSettingByKeyParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
SettingKey: key,
})
require.NoError(t, err)
require.JSONEq(t, `{"order":"custom"}`, string(val))
}
// ---- item 2.2: queue context source + the player's feed-mode order
// control (§10 R2) ----
func TestPodcastPlayerOrderControl(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test PlayerOrder Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/player-order.xml",
Title: "PlayerOrder Feed",
})
require.NoError(t, err)
ep1 := upsertTestEpisode(t, setup, feed, "po-1", "PlayerOrder One", 1)
ep2 := upsertTestEpisode(t, setup, feed, "po-2", "PlayerOrder Two", 2)
// download_state is download-owned (write-after-success guard) —
// tests set it directly under the first-admin identity (raw-pool
// execs on a detached context hit RLS and silently write 0 rows).
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id IN ($1, $2) AND source = 'podcast_episode'`, ep1.ID, ep2.ID)
subscribeRaw(t, setup, uuid.UUID(adminCtxValue(t, setup).Bytes), feed.ID)
feedID := uuid.UUID(feed.ID.Bytes).String()
ep1URL := "/readers/" + uuid.UUID(ep1.ID.Bytes).String()
// The reader page embeds the sequence config in a data-json
// ATTRIBUTE — templ HTML-escapes the quotes (&#34;), so pins must
// match the escaped form.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
t.Run("unset_default_is_reversed", func(t *testing.T) {
// 2026-11-01: the UNSET per-feed order resolves to "reverse"
// (audiobook-first — feed document order runs newest-first, so
// native started a book at its last chapter). The player's
// sequence config carries it; the podcast page's dropdown shows
// it selected; ep1 (feed position 1 of 2) plays LAST.
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), esc(`"source":"feed"`), "queue context must carry its owner (§10 R2)")
assert.Contains(t, string(body), esc(`"feed_id":"`+feedID+`"`))
assert.Contains(t, string(body), esc(`"order":"reverse"`),
"unset order defaults to reversed (audiobook-first)")
assert.Contains(t, string(body), esc(`"episode_index":2`),
"feed position 1 plays last under the reversed default")
})
t.Run("podcast_page_dropdown_shows_reversed_when_unset", func(t *testing.T) {
code, body := podcastAuthed(t, setup, setup.Token, "GET", "/podcasts/"+feedID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, `id="podcast-order-select"`, "the feed page renders its Order dropdown")
assert.Contains(t, html, `<option value="reverse" selected`,
"the unset default renders reversed as the selected option")
assert.NotContains(t, html, `<option value="native" selected`,
"native is no longer the unset default")
})
t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) {
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `id="player-order-select"`, "feed-mode queue renders the player order control")
assert.Contains(t, string(body), `hx-put="/api/podcasts/feeds/`+feedID+`/order"`)
assert.Contains(t, string(body), "Reverse episode order", "quick reverse button present")
})
t.Run("order_override_hides_dropdown_after_switch", func(t *testing.T) {
// Switch the order the way the player does (the same endpoint;
// podcastAuthed sends JSON and the handler binds both shapes),
// then re-render: the dropdown must reflect the new value.
code, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/podcasts/feeds/"+feedID+"/order", `{"order":"reverse"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
code, body = podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), esc(`"order":"reverse"`))
assert.Contains(t, string(body), `<option value="reverse" selected`, "dropdown reflects the override")
})
}
// ---- item 2.3: episode collections — picker scope, hint rows, the
// collection chain (queue=collection:<id>) ----
func TestPodcastEpisodeCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test EpiColl Podcasts")
marker := fmt.Sprintf("epicoll%d", time.Now().UnixNano()%100000)
// Feed A: the regular user SUBSCRIBES. Feed B: collected-but-UNSUBSCRIBED
// (the plan's hint-row case). Admin identity seeds both feeds' rows.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "EpiColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "EpiColl Feed B " + marker,
})
require.NoError(t, err)
a0 := upsertTestEpisode(t, setup, feedA, marker+"-a0", "EpiColl A Zero", 1)
a1 := upsertTestEpisode(t, setup, feedA, marker+"-a1", "EpiColl A One", 2)
a3 := upsertTestEpisode(t, setup, feedA, marker+"-a3", "EpiColl A Three", 4)
a2 := upsertTestEpisode(t, setup, feedA, marker+"-a2", "EpiColl A Two Pending", 3)
b1 := upsertTestEpisode(t, setup, feedB, marker+"-b1", "EpiColl B One", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id IN ($1, $2, $3, $4) AND source = 'podcast_episode'`,
a0.ID, a1.ID, a3.ID, b1.ID) // a2 stays pending
subscribeRaw(t, setup, regUUID, feedA.ID)
// The collection (regular user's own) via the real API path.
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Up Next `+marker+`","description":"episode collection test"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// Add episodes through the same endpoint the picker submits —
// book_ids is the key for ANY media id. Includes the unsubscribed
// b1: the insert must succeed; the DETAIL page decides visibility.
code, body = podcastAuthed(t, setup, regToken, "POST", "/api/collections/"+collID+"/books",
`{"book_ids":["`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a2.ID.Bytes).String()+`","`+uuid.UUID(b1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusNoContent, code, "%s", string(body))
// Pin the collection order explicitly (added_at ties are possible —
// same-transaction inserts share NOW(); positions make it
// deterministic). Order: A3, A0, A1 (+ pending A2, invisible B1).
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// Sequence config rides a data-json ATTRIBUTE — templ escapes the
// quotes (&#34;); pins must match the escaped form.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
t.Run("picker_scope_episodes_rls_scoped", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
html := buf.String()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, html, "EpiColl A Zero", "subscribed feed's episodes appear")
assert.Contains(t, html, "EpiColl Feed A", "rows carry the feed title")
assert.Contains(t, html, "EpiColl A Two Pending", "pending episodes are pickable — the detail page shows them as not downloaded")
assert.Contains(t, html, "not downloaded", "pending picker rows carry the honest badge")
assert.NotContains(t, html, "EpiColl B One", "unsubscribed feed's episodes are RLS-dropped from the picker")
})
t.Run("picker_books_scope_still_excludes_podcasts", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl A Zero",
"the books scope keeps its podcasts exclusion (plan §9) — episodes enter ONLY through the episode scope")
})
t.Run("picker_respects_family_lens_hidden_libraries", func(t *testing.T) {
// Invariant-13 auditor case: a SUBSCRIBED episode in a
// family-HIDDEN library still drops — the episode predicate
// ANDs subscription with the family lens. (RLS auditor:
// membership-visible surfaces leak nothing from hidden libs.)
libH, _ := createPodcastsLibrary(t, setup, "test EpiColl Hidden")
feedH, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: libH.ID,
FeedUrl: "https://feed.invalid/" + marker + "-h.xml",
Title: "EpiColl Feed Hidden " + marker,
})
require.NoError(t, err)
upsertTestEpisode(t, setup, feedH, marker+"-h1", "EpiColl Hidden One", 1)
subscribeRaw(t, setup, regUUID, feedH.ID)
_, err = setup.DB.SetLibraryVisibility(seedCtx(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regUUID, Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl Hidden One",
"LEAK: a subscribed episode in a family-hidden library must not reach the picker")
})
t.Run("detail_renders_episode_rows_and_unsubscribed_hint", func(t *testing.T) {
// The membership view (All Libraries cookie) shows the full
// collection including hint rows; the library-scoped view keeps
// its existing INNER-JOIN behavior (that's the switcher's
// contract, unchanged for books).
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode)
html := buf.String()
assert.Contains(t, html, "EpiColl A One", "subscribed downloaded episode renders")
assert.Contains(t, html, "EpiColl Feed A", "episode rows carry the feed title")
assert.Contains(t, html, "Play", "downloaded episodes get the play affordance")
assert.Contains(t, html, "Not downloaded", "pending episode renders the honest state")
assert.Contains(t, html, "Unavailable", "collected-but-unsubscribed episode renders the hint row (LEFT JOIN)")
assert.Contains(t, html, "Remove entry", "hint rows stay removable")
assert.Contains(t, html, "Play as Sequence", "header sequence-start button renders when something can play")
})
t.Run("collection_chain_carries_queue_param", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
// The collection's pinned order is A3 → A0 → A1; a1 is LAST:
// prev = A0 (second-to-last), no next.
assert.Contains(t, html, esc(`"source":"collection"`))
assert.Contains(t, html, esc(`"episode_index":3`))
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"prev hop carries the queue param — the chain survives")
assert.NotContains(t, html, esc(`"next_url"`), "end of the collection queue")
assert.NotContains(t, html, `id="player-order-select"`,
"the order control is FEED-mode only (§10 R2) — collection order is content")
})
t.Run("start_button_plays_from_first_in_order", func(t *testing.T) {
// The header button starts from the FIRST playable episode in
// collection order (a3) — and that page chains forward to a0.
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a3.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
t.Run("foreign_collection_falls_back_to_feed_queue", func(t *testing.T) {
foreign := uuid.New().String()
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+foreign, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"source":"feed"`), "not-owned collection id falls back to the feed queue")
assert.Contains(t, html, `id="player-order-select"`, "feed mode renders its order control")
})
t.Run("reorder_changes_the_chain", func(t *testing.T) {
// Reverse to A1 → A0 → A3: the same reader page now chains the
// other way — collection order IS the queue order (R1 content).
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
}
// ---- item 2.4: smart episode collections — episode rule fields +
// the ANY/ALL match modes (the Daily Fiction building blocks) ----
func TestPodcastSmartCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000)
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "SmartColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "SmartColl Feed B " + marker,
})
require.NoError(t, err)
aNew := upsertTestEpisode(t, setup, feedA, marker+"-new", "SmartColl A New", 1)
aOld := upsertTestEpisode(t, setup, feedA, marker+"-old", "SmartColl A Old", 2)
aPlayed := upsertTestEpisode(t, setup, feedA, marker+"-played", "SmartColl A Played", 3)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "SmartColl B Ep", 1)
// Seed the episode facts the rules read: publication dates + the
// played marker (viewer progress ≥ 0.999 = the canonical finished
// definition, same predicate as hide_finished).
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2, $3)`, aNew.ID, aPlayed.ID, bEp.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE - 30 WHERE id = $1`, aOld.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
INSERT INTO reading_progress (media_item_id, user_id, percentage)
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET percentage = 1.0`, aPlayed.ID, regUUID)
subscribeRaw(t, setup, regUUID, feedA.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
// Sequence config rides a data-json attribute — templ escapes quotes.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
// Smart collection with the briefing's AND shape, created through
// the real API. The tree carries the mode ('all' at the root) — the
// match-mode endpoint retired 2026-10-31 with the rules storage.
briefing, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID}},
{"field": "played", "op": "is_false"},
{"field": "published_date", "op": "within_days", "values": []string{"7"}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","filter_tree":`+string(briefing)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
t.Run("all_mode_is_the_briefing", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
Books []struct {
Title string `json:"title"`
} `json:"books"`
IsSmart bool `json:"is_smart"`
Tree map[string]interface{} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal(body, &out))
require.True(t, out.IsSmart)
titles := map[string]bool{}
for _, b := range out.Books {
titles[b.Title] = true
}
assert.Equal(t, "all", out.Tree["mode"], "the tree's root mode IS the match mode now")
assert.True(t, titles["SmartColl A New"], "feed-A + unplayed + <7d matches")
assert.Len(t, out.Books, 1, "exactly the briefing set — aOld fails <7d, aPlayed fails unplayed, bEp is RLS-dropped")
})
// ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ----
// One more briefing match so the chain has a next hop.
aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "SmartColl A New Two", 5)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID)
t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) {
// ALL mode is the stored tree root: today's unplayed feed-A
// episodes = aNew + aNew2. BORN REVERSED (2026-11-01): the
// collection's default chain order is reverse, so the page's
// first card is feed position 5 (aNew2) and the chain runs
// 5 → 1 — book order for feeds that list newest-first.
// The detail page (smart branch, All Libraries view) renders
// episode cards with the play affordance + the header button.
// (The library-scoped view filters matches to that library — the
// switcher contract, same as books.)
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode)
html := buf.String()
assert.Contains(t, html, "Play as Sequence", "smart collections play as a sequence too")
assert.Contains(t, html, "?queue=collection:"+collID, "smart episode cards chain through the queue param")
// Start from the page's FIRST card (feed position 5 under the
// reversed default): index 1 of 2, next = feed position 1, both
// hops carrying the queue param.
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aNew2.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html = string(body)
assert.Contains(t, html, esc(`"source":"collection"`))
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"index_total":2`), "exactly the briefing set: aOld fails <7d, aPlayed fails unplayed, aPending is un-downloaded, bEp is RLS-dropped")
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
assert.NotContains(t, html, `id="player-order-select"`, "computed order — the order control stays hidden (R1)")
})
t.Run("acceptance_stale_briefing_match_falls_back", func(t *testing.T) {
// aPlayed is DOWNLOADED but no longer matches the briefing
// (played). Opening its reader with the queue param: the smart
// chain can't place it → feed-queue fallback, not a broken page.
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aPlayed.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), esc(`"source":"feed"`), "a match that aged out of the briefing falls back to the feed queue")
})
}
// TestEpisodeCollectionsFilterTree pins item 3.2: smart collections
// evaluate through the unified engine; the briefing rules ride the qf
// DSL; the reader chain follows the tree's live membership; edits
// without a tree keep the stored one.
func TestEpisodeCollectionsFilterTree(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test TreeColl Podcasts")
marker := fmt.Sprintf("treecoll%d", time.Now().UnixNano()%100000)
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "TreeColl Feed A " + marker,
})
require.NoError(t, err)
aNew := upsertTestEpisode(t, setup, feedA, marker+"-new", "TreeColl New", 1)
aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "TreeColl New Two", 5)
aOld := upsertTestEpisode(t, setup, feedA, marker+"-old", "TreeColl Old", 9)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2)`, aNew.ID, aNew2.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE - 30 WHERE id = $1`, aOld.ID)
subscribeRaw(t, setup, regUUID, feedA.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
// Sequence config rides a data-json attribute — templ escapes quotes.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
// MULTI-PICK wire shape (2026-11-01 user report): the builder's pick
// loop stores several feeds as ONE comma-joined value — "feeds in
// [A, B]" must match BOTH feeds' episodes, not zero.
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "TreeColl Feed B " + marker,
})
require.NoError(t, err)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "TreeColl B Ep", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id = $1`, bEp.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
t.Run("comma_joined_multi_feed_matches_both", func(t *testing.T) {
joined := feedAID + "," + uuid.UUID(feedB.ID.Bytes).String()
multi, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "any",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{joined}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test TreeColl Multi `+marker+`","filter_tree":`+string(multi)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
h := string(body)
assert.Contains(t, h, "TreeColl New", "feed A's episodes match")
assert.Contains(t, h, "TreeColl B Ep", "feed B's episodes match")
assert.Contains(t, h, "TreeColl Old", "no date rule — every episode of both feeds")
})
// The Daily Fiction briefing AS A TREE (item 2.5's shape, unified DSL).
briefing, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID}},
{"field": "played", "op": "is_false"},
{"field": "published_date", "op": "within_days", "values": []string{"7"}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test TreeColl `+marker+`","filter_tree":`+string(briefing)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
})
t.Run("tree_evaluates_through_the_engine", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
IsSmart bool `json:"is_smart"`
Tree interface{} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal(body, &out))
assert.True(t, out.IsSmart, "a tree collection IS smart without any rules array")
assert.NotNil(t, out.Tree, "the tree round-trips in the envelope")
h := string(body)
assert.Contains(t, h, "TreeColl New")
assert.Contains(t, h, "TreeColl New Two")
assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days")
// BORN REVERSED (2026-11-01): a new episodes smart collection is
// created with chain_order=reverse seeded into view_settings.
assert.Contains(t, h, `"chain_order":"reverse"`,
"new episodes smart collections are born with the reversed default")
// NOTE: the API envelope's match list is not chain-ordered (it
// never was — order pins live on the SSR page and the reader
// chain, which the curated-order subtest covers).
})
t.Run("chain_follows_the_tree", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"source":"collection"`))
// BORN REVERSED (2026-11-01): the seeded chain_order=reverse puts
// feed position 1 (aNew) LAST — index 2/2 with a prev hop.
assert.Contains(t, html, esc(`"episode_index":2`))
assert.Contains(t, html, esc(`"index_total":2`), "the briefing tree = the 2 fresh downloaded episodes")
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"the reversed default chain hops back through feed position 5")
assert.NotContains(t, html, esc(`"next_url"`),
"feed position 1 has no next hop under the reversed default")
})
t.Run("invalid_tree_is_400", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"Bad Tree `+marker+`","filter_tree":{"scope":"manga","mode":"all","children":[]}}`)
assert.Equal(t, http.StatusBadRequest, code,
"an invalid tree must be rejected at write: %s", string(body))
})
t.Run("edit_form_without_tree_keeps_it", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","description":"kept the tree"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
Tree interface{} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal(body, &out))
assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one")
})
// Plan §10 R2 + the 2026-10-31 chain-order option: the collection's
// CURATED listening order (view_settings.chain_order) governs the
// chain. reverse flips the feed positions, so the episode that was
// 1/2 under native becomes 2/2 and gains a prev hop.
t.Run("chain_follows_the_curated_order", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT",
"/api/collections/"+collID+"/view-settings", `{"chain_order":"reverse"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// The edit-form save path must not clobber the merged key.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","description":"order survives edits"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"view_settings merge keeps chain_order across whole-map writes")
// THE PAGE follows the flip (2026-11-01 user report: the select
// changed nothing on the page): reverse puts feed position 5
// (New Two) BEFORE position 1 (New) on the rendered cards —
// the visible order is what the sequence will play, and Play
// launches from the first card.
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
page, perr := http.DefaultClient.Do(req)
require.NoError(t, perr)
pageBody, _ := io.ReadAll(page.Body)
_ = page.Body.Close()
require.Equal(t, http.StatusOK, page.StatusCode)
ph := string(pageBody)
require.Contains(t, ph, "TreeColl New", "the page renders the smart cards at all")
assert.Less(t, strings.Index(ph, "TreeColl New Two"), strings.Index(ph, `"TreeColl New"`),
"reverse re-orders the page cards — the visible order is the play order")
assert.Contains(t, ph, `<option value="reverse" selected`,
"the collection's dropdown shows the reversed default selected")
// aNew is feed position 1: FIRST under native, LAST under reverse.
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":2`),
"reverse order puts feed position 1 last")
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"the reversed chain hops back through feed position 5")
assert.NotContains(t, html, esc(`"next_url"`),
"the last episode of the chain has no next hop")
})
}
// ---- 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 <download-root>/<feed-slug>/ 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 <input type=checkbox> 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])
}
// ---- §0 item 8: admin all-podcasts view + file maintenance ----
func TestAdminPodcastsView(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test AdminView 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("AdminView Feed", map[string]string{"av-1": "AdminView One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
// Subscribe the REGULAR user: the admin view must show their feed +
// their name even though the library was created by/for the test.
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
// Admin sees the page with the regular user's feed + their name.
code, page := podcastAuthed(t, setup, setup.Token, "GET", "/admin/podcasts", "")
require.Equal(t, http.StatusOK, code, string(page))
assert.Contains(t, string(page), "AdminView Feed",
"PIN: the admin view lists feeds the admin does not subscribe to (RLS admin escape)")
assert.Contains(t, string(page), "All Podcasts")
regularName := "testregularuser"
assert.Contains(t, string(page), regularName, "subscriber names render")
// Regular users are locked out of the page AND the maintenance actions.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "GET", "/admin/podcasts", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins never see the all-podcasts view")
feeds, _ := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
var feedIDStr string
for _, f := range feeds {
if f.Title == "AdminView Feed" {
feedIDStr = uuid.UUID(f.ID.Bytes).String()
}
}
require.NotEmpty(t, feedIDStr)
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot delete files")
}
func TestAdminDeleteEpisodeFiles(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 AdminFiles 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("AdminFiles Feed", map[string]string{
"af-1": "AdminFiles One", "af-2": "AdminFiles Two",
})))
})
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")
resp, body := podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds", form)
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 == "AdminFiles Feed" {
feedRow = f
}
}
require.NotZero(t, feedRow.ID, "feed must exist")
feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
// Point enclosures at the fixture server, THEN queue + drain
// immediately: queueing after re-pointing keeps the dev server's
// worker (which can't reach the fixture URL and would fail the
// rows) out of the race.
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))
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range eps {
_, err := setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both episodes downloaded")
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
ep1, ep2 := eps[0], eps[1]
pathFor := func(ep database.MediaItems) string { return filepath.Join(root, ep.FilePath) }
require.FileExists(t, pathFor(ep1))
require.FileExists(t, pathFor(ep2))
// --- single-episode delete-file (admin) ---
ep1Str := uuid.UUID(ep1.ID.Bytes).String()
code, rbody := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+ep1Str+"/delete-file", "")
require.Equal(t, http.StatusOK, code, string(rbody))
after1, err := setup.DB.GetPodcastEpisode(adminCtx, ep1.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after1.DownloadState, "PIN: deleted-file episode resets to pending")
assert.False(t, after1.FileSize.Valid, "file facts cleared")
assert.Nil(t, after1.AudioMetadata, "audio_metadata cleared (it described the deleted file)")
after2, err := setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "downloaded", after2.DownloadState, "the sibling episode is untouched")
assert.NoFileExists(t, pathFor(after1), "disk file removed")
assert.FileExists(t, pathFor(after2), "sibling file kept")
// --- per-feed delete-files (admin) ---
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), "reset_episodes")
after2, err = setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after2.DownloadState)
assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
// --- bulk multi-select delete-files (§0 item 8 follow-up) ---
// Re-download both, then delete them in ONE request with a forged
// foreign id mixed in: only the matching-feed ids are applied.
for _, ep := range []database.MediaItems{ep1, ep2} {
require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID))
_, err = setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both re-downloaded for the bulk test")
foreignID := uuid.New().String()
bulkForm := url.Values{}
bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()}
code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm)
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied")
assert.NoFileExists(t, pathFor(ep1))
assert.NoFileExists(t, pathFor(ep2))
afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range afterBulk {
assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title)
}
// Non-admins cannot bulk-delete either.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files")
// --- refresh must NOT re-materialize deleted files (invariant 2):
// the poll upsert rewrites metadata but never touches download_state.
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
require.Equal(t, http.StatusOK, code, string(rbody))
refreshed, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
require.Len(t, refreshed, 2)
for _, ep := range refreshed {
assert.Equal(t, "pending", ep.DownloadState,
"PIN: a feed refresh does not resurrect deleted downloads (%s)", ep.Title)
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
}
}
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
// episodes root so a fresh Episodes smart collection has a stored scope
// before its first rule. The seed must be accepted at write, and the
// collection must match NOTHING — the composed query must never degrade
// to the bare scope head, which would swallow every episode in scope.
func TestEpisodeCollectionsEmptySeedMatchesNothing(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
marker := fmt.Sprintf("emptyseed%d", time.Now().UnixNano()%100000)
seed := `{"scope":"episodes","mode":"all","children":[]}`
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test EmptySeed `+marker+`","collection_type":"smart","filter_tree":`+seed+`}`)
require.Equal(t, http.StatusCreated, code, "the empty seed must parse at write: %s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"is_smart":true`,
"a seeded tree collection IS smart without any rules array")
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
BookCount int `json:"book_count"`
Books []interface{} `json:"books"`
}
require.NoError(t, json.Unmarshal(body, &out))
assert.Equal(t, 0, out.BookCount,
"an empty-seed episodes collection matches nothing — a composer that degraded to the bare scope head would report the whole library here")
assert.Empty(t, out.Books)
}
// The create modal posts the seed as a FORM (urlencoded hx-post), and
// echo's binder cannot set an any-typed struct field from a form
// string — the first in-browser create 400'd "invalid request"
// (2026-10-31). The form variant binds a dedicated string field and
// rides the same parse path; this pins the form transport.
func TestEpisodeCollectionsFormSeedCreate(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
marker := fmt.Sprintf("formseed%d", time.Now().UnixNano()%100000)
form := url.Values{}
form.Set("collection_type", "smart")
form.Set("name", "test FormSeed "+marker)
form.Set("filter_tree", `{"scope":"episodes","mode":"all","children":[]}`)
code, body := podcastAuthedForm(t, setup, regToken, "POST", "/api/collections", form)
require.Equal(t, http.StatusCreated, code, "the FORM-encoded seed must bind and parse: %s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips")
}
// ── SSR id-label resolution (2026-11-01) ─────────────────────────────
// Pages rendering a filter tree embed the id→name map at render time
// (data-id-labels). The pre-fix resolution path — the media-constrained
// suggest facet — could only label ids whose media matches the tree's
// SIBLING rules: live repro "feed in [A,B] AND title contains X"
// labeled one feed of three. The resolver reads the entity tables: a
// feed with ZERO episodes labels fine; a hidden library does not.
func TestRulesPageEmbedsIdLabels(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test Labels Podcasts")
marker := fmt.Sprintf("labelsembed%d", time.Now().UnixNano()%100000)
// Feed A: subscribed, one downloaded episode matching the sibling
// rule. Feed B: subscribed, ZERO episodes — no media row exists, so
// the facet path structurally could never label it.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "Labels Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "Labels Feed B " + marker,
})
require.NoError(t, err)
aEp := upsertTestEpisode(t, setup, feedA, marker+"-a", "Labels A Ep "+marker, 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id = $1`, aEp.ID)
subscribeRaw(t, setup, regUUID, feedA.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
feedBID := uuid.UUID(feedB.ID.Bytes).String()
feedBTitle := "Labels Feed B " + marker
feedATitle := "Labels Feed A " + marker
// The user's exact shape: feed in [A,B] AND title contains "…".
tree := map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID + "," + feedBID}},
{"field": "title", "op": "contains", "values": []string{"Labels A Ep"}},
},
}
treeJSON, _ := json.Marshal(tree)
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Labels Coll `+marker+`","filter_tree":`+string(treeJSON)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
t.Run("rules_page_labels_zero_episode_feed", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
html := string(pageBody)
require.Contains(t, html, "data-id-labels", "the embed attribute renders")
// Feed B's title can ONLY come from the labels embed — the feed
// has no media, so nothing else on the page can name it.
assert.Contains(t, html, feedBTitle,
"a zero-episode feed resolves its label at render")
assert.Contains(t, html, feedATitle)
})
// Books-scope: a URL-carried qf library rule embeds the library's
// name — until the family lens hides that library, at which point
// the resolver must NOT name it (the facet's visibility shape).
// Library creation is admin-only; the regular user still SEES the
// library (default-ALLOW visibility) until the subtest hides it.
booksLibID := createTestLibrary(t, setup.Server, setup.Token, "test Labels Books Lib "+marker)
libUUID := uuid.MustParse(booksLibID)
qf := url.QueryEscape(`{"scope":"books","mode":"all","children":[` +
`{"field":"library","op":"is","values":["` + booksLibID + `"]}]}`)
bookshelf := func() string {
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf?qf="+qf, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
return string(pageBody)
}
t.Run("bookshelf_labels_url_qf_library", func(t *testing.T) {
html := bookshelf()
require.Contains(t, html, "data-id-labels")
assert.Contains(t, html, "test Labels Books Lib "+marker,
"the qf library rule's id resolves to its name at render")
})
t.Run("hidden_library_not_labeled", func(t *testing.T) {
seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
t.Cleanup(func() {
seedExec(t, setup.DBPool,
`DELETE FROM library_visibility WHERE library_id = $1 AND user_id = $2`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
})
html := bookshelf()
assert.NotContains(t, html, "test Labels Books Lib "+marker,
"a family-lens-hidden library is not named, even by explicit id")
})
}
// ---- creation seed: new podcast smart collections are BORN reversed ----
// TestSmartCollectionBornReversed pins the 2026-11-01 creation default:
// an episodes-scope smart collection is created with
// view_settings.chain_order = "reverse" (the dropdown's default
// selection and the chain's behavior come from the STORED value — no
// resolution-logic changes). Books-scope smart collections and statics
// get nothing seeded; an explicit client value is never clobbered.
func TestSmartCollectionBornReversed(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
create := func(t *testing.T, payload string) string {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
return coll.ID
}
t.Run("episodes_scope_seeded_reverse", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Ep `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"a new episodes smart collection carries the reversed default")
})
t.Run("explicit_view_settings_not_clobbered", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Keep `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]},"view_settings":{"chain_order":"native"}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"native"`,
"an explicit client chain_order is never clobbered by the seed")
assert.NotContains(t, string(body), `"chain_order":"reverse"`)
})
t.Run("books_scope_not_seeded", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Books `+t.Name()+`","filter_tree":{"scope":"books","mode":"all","children":[]}}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.NotContains(t, string(body), `"chain_order"`,
"books-scope smart collections get no chain_order seed")
})
t.Run("static_not_seeded", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Static `+t.Name()+`"}`)
_, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.NotContains(t, string(body), `"chain_order"`,
"static collections get no chain_order seed")
})
// The creation seed's twin (2026-11-01): APPLYING an episodes tree
// to a collection with no curated order seeds reverse — a static
// collection becoming smart lands with the same default a
// born-smart one gets. Explicit values (client-sent or stored) are
// never clobbered.
t.Run("apply_path_seeds_when_unordered", func(t *testing.T) {
id := create(t, `{"name":"test BornRev Apply `+t.Name()+`"}`)
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id,
`{"name":"test BornRev Apply `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
_, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"applying an episodes tree to an unordered collection seeds reverse")
})
t.Run("apply_path_respects_stored_native", func(t *testing.T) {
id := create(t, `{"name":"test BornRev KeepNative `+t.Name()+`","view_settings":{"chain_order":"native"}}`)
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id,
`{"name":"test BornRev KeepNative `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
_, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"native"`,
"a stored explicit chain_order is never clobbered by the apply seed")
assert.NotContains(t, string(body), `"chain_order":"reverse"`)
})
t.Run("apply_path_respects_client_native", func(t *testing.T) {
id := create(t, `{"name":"test BornRev ClientNative `+t.Name()+`"}`)
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+id,
`{"name":"test BornRev ClientNative `+t.Name()+`","filter_tree":{"scope":"episodes","mode":"all","children":[]},"view_settings":{"chain_order":"native"}}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
_, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+id, "")
assert.Contains(t, string(body), `"chain_order":"native"`,
"the client's explicit chain_order wins over the apply seed")
assert.NotContains(t, string(body), `"chain_order":"reverse"`)
})
}
// ---- label endpoint: names id-kind values for client-held trees ----
// TestFilterLabelsEndpoint pins GET /api/filter/labels (2026-11-01):
// the client path that never touches a server render — applying a
// SAVED filter from a cold bookshelf — resolves names through ENTITY
// tables, not the media-constrained suggest facet. A zero-episode feed
// resolves its title (the facet path structurally could not); an
// unsubscribed feed id resolves to nothing (RLS); a non-id field is a
// 400.
func TestFilterLabelsEndpoint(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test LabelsEP Podcasts")
marker := fmt.Sprintf("labelsep%d", time.Now().UnixNano()%100000)
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "LabelsEP Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "LabelsEP Feed B " + marker,
})
require.NoError(t, err)
// feedA is subscribed; feedB is NOT (RLS must hide it even by id).
subscribeRaw(t, setup, regUUID, feedA.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
feedBID := uuid.UUID(feedB.ID.Bytes).String()
t.Run("zero_episode_feed_resolves", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/api/filter/labels?field=feed&ids="+feedAID, "")
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), `"labels"`)
assert.Contains(t, string(body), "LabelsEP Feed A "+marker,
"a subscribed feed with ZERO episodes resolves its title (no media join)")
})
t.Run("comma_joined_ids_resolve_together", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/api/filter/labels?field=feed&ids="+feedAID+","+feedBID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "LabelsEP Feed A "+marker)
})
t.Run("unsubscribed_feed_stays_unnamed", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/api/filter/labels?field=feed&ids="+feedBID, "")
require.Equal(t, http.StatusOK, code)
assert.NotContains(t, string(body), "LabelsEP Feed B "+marker,
"an unsubscribed feed id is invisible to resolution (RLS)")
})
t.Run("non_id_field_is_400", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/api/filter/labels?field=title&ids=whatever", "")
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
})
}
// ---- the "?" affordance: the builder links to its manual ----
// TestFilterHelpAffordance pins the help links (2026-11-01): every
// filter-builder mount carries a "?" linking to the manual, and the
// manual's FOLDER URL resolves to its index page (the docs loader's
// folder-index fallback — the guide is docs/user/filters/index.md).
func TestFilterHelpAffordance(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
t.Run("docs_folder_url_serves_the_guide", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/docs/user/filters", nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "the folder URL resolves to index.md; body: %.400s", string(body))
assert.Contains(t, string(body), "Filters — the complete guide")
})
t.Run("drawer_carries_help_link", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(body), `href="/docs/user/filters"`,
"the All Books drawer links to the filter guide")
})
t.Run("rules_page_carries_help_link", func(t *testing.T) {
marker := fmt.Sprintf("helpaff%d", time.Now().UnixNano()%100000)
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test HelpAff `+marker+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(pageBody), `href="/docs/user/filters"`,
"the collection rules editor links to the filter guide")
})
}