The creation seed (d56031f3) covered born-smart collections; a STATIC
collection that becomes smart through the rules editor's Apply landed
without any curated order — its dropdown stayed "Feed order" while
every other podcast smart collection opened reversed. Same decision,
same guardrails (2026-11-01 user approved closing this gap): a PUT
carrying an episodes-scope tree seeds chain_order=reverse only when
neither the request NOR the stored view_settings names one — a
deliberate native (client-sent or saved) is never clobbered, and
books-scope trees seed nothing.
Pins: apply-seeds-when-unordered, respects-stored-native,
respects-client-native (TestSmartCollectionBornReversed now covers
both seed paths); changelogged in contract_pins.
2521 lines
111 KiB
Go
2521 lines
111 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 ("), so pins must
|
|
// match the escaped form.
|
|
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
|
|
|
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 ("); pins must match the escaped form.
|
|
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
|
|
|
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, `"`, """) }
|
|
|
|
// 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, chained in native feed order. Playing
|
|
// the FIRST match (the header's Play-as-Sequence target) must
|
|
// chain into the second.
|
|
|
|
// 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 first briefing match (feed position 1): index 1
|
|
// of 2, next = the second, both hops carrying the queue param.
|
|
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"`))
|
|
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(aNew2.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, `"`, """) }
|
|
|
|
// 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))
|
|
})
|
|
}
|