cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB doctrine (test*-named libraries, cascading cleanup, no binary fixtures): - TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber 404 on feed detail + SSR page (existence leak), episodes absent from generic media reads, admin escape, subscriber visibility, then the hidden-library flip hiding an already-subscribed feed (the feed policy's library-visibility join). Seeding runs as the FIRST ADMIN — the seed identity has neither subscription nor users row, so podcast INSERT..RETURNING (SELECT policy under FORCE) 42501s for it; production pods run as exactly that admin (invariant 12). All direct row probes read through the admin scope for the same reason. - TestPodcastAddFeedFlow: member add + subscribe + metadata-only upsert; duplicate-URL dedupe (no second row); allow_member_feeds gate (member 403 / admin 201 / subscribe-still-allowed). - TestPodcastShrunkenFeedRetention: feed purges an episode from the XML, both rows persist (upsert-without-delete pin, D14). - TestPodcastEditSegregation: generic edit 400 + write-to-file refusal for episodes; control book edit unaffected. - TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed; re-download of a downloaded episode never re-fetches (idempotency pin, enclosure-hit counted); dead enclosure → failed + persisted reason + metadata intact. Fetch/SSRF tests swap the client via SetPodcastHTTPClientForTests (the production client refuses loopback httptest servers by design — the real client gets its own negative test in services). - TestPodcastScannerExclusion: SetFolders on a podcasts library root yields zero scanner folders (invariant 4 pin) — MediaScanner .FolderCount() accessor added. Unit: internal/services/podcast_rss_test.go — namespace-aware parsing fixture (three XML namespaces, fallback chains, hosts/guests), duration spellings, RFC-822 variants, explicit variants, slug/path layout, enclosure-extension selection, stripHTML, and the SSRF matrix (private/loopback/link-local/multicast/mapped-v6 blocked; a loopback httptest server PROVES the production client refuses it). suites green: full integration run 161s / 0 failures; vitest 12 files 163 tests. Contract widening: TestContractPin_MediaListBothBranches' raw-row allowlist gains the 15 podcast episode columns (additive; documented in docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/ podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars. tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain — Playwright sweep pending.
586 lines
24 KiB
Go
586 lines
24 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"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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))
|
|
})
|
|
}
|
|
|
|
// ---- 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")
|
|
}
|