Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/ clauseCollection parsed each rule value as ONE uuid, but the builder's pick loop stores several picks as a single comma-joined value (["id1,id2"]) — uuid.Parse failed on the joined string, the id list came back empty, and "feeds in [A, B]" matched no episodes (one pick worked, which is why the briefing recipe survived). parseUUIDValues now splits every value on commas (trimming) before parsing, accepting both the builder's joined shape and hand-written separate elements — same leniency the suggest-side id lookup already has. The multi-value cells now say so: id-kind value inputs get the placeholder "pick one or more" (the only boxes on the page that accept several values; every other cell replaces its value). Pins: comma_joined_multi_feed_matches_both — a tree collection built with the exact comma-joined wire the client sends, asserting BOTH feeds' episodes match. Filter-builder vitest 35/35, container filter + episode-tree slices green.
2187 lines
95 KiB
Go
2187 lines
95 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"
|
|
"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("context_carries_feed_source", func(t *testing.T) {
|
|
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, string(body), esc(`"source":"feed"`), "queue context must carry its owner (§10 R2)")
|
|
assert.Contains(t, string(body), esc(`"feed_id":"`+feedID+`"`))
|
|
assert.Contains(t, string(body), esc(`"order":"native"`))
|
|
assert.Contains(t, string(body), esc(`"episode_index":1`))
|
|
})
|
|
|
|
t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) {
|
|
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, string(body), `id="player-order-select"`, "feed-mode queue renders the player order control")
|
|
assert.Contains(t, string(body), `hx-put="/api/podcasts/feeds/`+feedID+`/order"`)
|
|
assert.Contains(t, string(body), "Reverse episode order", "quick reverse button present")
|
|
})
|
|
|
|
t.Run("order_override_hides_dropdown_after_switch", func(t *testing.T) {
|
|
// Switch the order the way the player does (the same endpoint;
|
|
// podcastAuthed sends JSON and the handler binds both shapes),
|
|
// then re-render: the dropdown must reflect the new value.
|
|
code, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/podcasts/feeds/"+feedID+"/order", `{"order":"reverse"}`)
|
|
require.Equal(t, http.StatusOK, code, "%s", string(body))
|
|
code, body = podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, string(body), esc(`"order":"reverse"`))
|
|
assert.Contains(t, string(body), `<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")
|
|
})
|
|
|
|
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"`))
|
|
assert.Contains(t, html, esc(`"episode_index":1`))
|
|
assert.Contains(t, html, esc(`"index_total":2`), "the briefing tree = the 2 fresh downloaded episodes")
|
|
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
|
|
"chain hops stay on the tree's live membership")
|
|
})
|
|
|
|
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")
|
|
|
|
// 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")
|
|
}
|