test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
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.
This commit is contained in:
1 parent
28420d56ed
commit
49b0b6eeb9
24 files changed
+1510
-10
No files matched your search
@@ -60,3 +60,13 @@ variables:
|
|||||||
name: book_sha256
|
name: book_sha256
|
||||||
- secret: true
|
- secret: true
|
||||||
name: book_uuid
|
name: book_uuid
|
||||||
|
- secret: true
|
||||||
|
name: podcasts_library_id
|
||||||
|
- name: podcast_feed_url
|
||||||
|
value: https://example.com/feed.xml
|
||||||
|
- secret: true
|
||||||
|
name: podcast_feed_id
|
||||||
|
- secret: true
|
||||||
|
name: podcast_episode_id
|
||||||
|
- name: podcast_search_query
|
||||||
|
value: episode
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
info:
|
||||||
|
name: Add Podcast Feed
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"library_id": "{{podcasts_library_id}}",
|
||||||
|
"url": "{{podcast_feed_url}}",
|
||||||
|
"download_all": false
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 30000
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 201", function() {
|
||||||
|
expect(res.status).to.eql(201);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Delete Feed (admin; ?files=false keeps files)
|
||||||
|
type: http
|
||||||
|
seq: 12
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download All (every non-downloaded episode)
|
||||||
|
type: http
|
||||||
|
seq: 8
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/all/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Download / Retry Episode (idempotent state machine)
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/{{podcast_episode_id}}/download'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 202", function() {
|
||||||
|
expect(res.status).to.eql(202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Feed Detail (episodes + download states)
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200 (404 would be scoping/RLS)", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: List My Podcast Feeds
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Manual Refresh (poll one feed)
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/refresh'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Effective Sequence (override applied, downloaded-only, gaps)
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/sequence'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
info:
|
||||||
|
name: Set Order Override (native | oldest | newest)
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/order'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"order": "oldest"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Subscription-scoped Search (covers un-downloaded + historical)
|
||||||
|
type: http
|
||||||
|
seq: 11
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/podcasts/search?q={{podcast_search_query}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
info:
|
||||||
|
name: Unsubscribe (files retained)
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
info:
|
||||||
|
name: Update Subscription (display name + auto-download)
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"display_name": "My Serialized Feed",
|
||||||
|
"auto_download": "newest",
|
||||||
|
"auto_download_count": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
@@ -752,6 +752,15 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
"tags_search", "contributors_search", "narrators_search", "file_sha256",
|
||||||
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
|
||||||
"library_type_name_2",
|
"library_type_name_2",
|
||||||
|
// Podcast episode columns (2026-10-29 additive widening — plan
|
||||||
|
// podcast-integration.md tasks 1.1/1.10): columns on
|
||||||
|
// media_items, NULL for scan rows. See docs/contract_pins.md
|
||||||
|
// changelog.
|
||||||
|
"source", "source_feed_id", "episode_guid", "feed_position",
|
||||||
|
"season_number", "episode_number", "enclosure_url",
|
||||||
|
"declared_duration_seconds", "transcript_url", "chapters_url",
|
||||||
|
"hosts", "guests", "download_state", "download_error",
|
||||||
|
"download_attempts",
|
||||||
}
|
}
|
||||||
resolvedKeys := []string{
|
resolvedKeys := []string{
|
||||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -610,6 +610,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
conversionService := services.NewConversionService(queries, getCachePath())
|
conversionService := services.NewConversionService(queries, getCachePath())
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
|
||||||
|
// Podcast subsystem (mirrors main.go wiring): fetcher service +
|
||||||
|
// download worker + handler. The poller is NOT started in tests
|
||||||
|
// (no background ticks against the shared dev DB); the worker loop
|
||||||
|
// only drains when a test queues work.
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
// Create Echo instance
|
// Create Echo instance
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -672,6 +680,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||||
LibraryService: libraryService,
|
LibraryService: libraryService,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
}
|
}
|
||||||
|
|
||||||
router.RegisterRoutes(routerConfig)
|
router.RegisterRoutes(routerConfig)
|
||||||
|
|||||||
@@ -443,3 +443,19 @@ Both answers are gated: 403 (Go-level explicit-library checks remain on
|
|||||||
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||||
Pins asserting 403 on item-scoped content endpoints widen to accept
|
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||||
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
||||||
|
|
||||||
|
## 2026-10-29 — Podcast episode columns join media_items (additive widening)
|
||||||
|
|
||||||
|
The podcast integration (plan podcast-integration.md tasks 1.1/1.10) adds
|
||||||
|
fifteen nullable/no-null-default columns to `media_items` — they surface in
|
||||||
|
raw sqlc rows ONLY (`library-scoped list emits raw sqlc rows`), never in the
|
||||||
|
resolved unscoped map:
|
||||||
|
|
||||||
|
`source` ('scan' default), `source_feed_id`, `episode_guid`, `feed_position`,
|
||||||
|
`season_number`, `episode_number`, `enclosure_url`,
|
||||||
|
`declared_duration_seconds`, `transcript_url`, `chapters_url`, `hosts`,
|
||||||
|
`guests`, `download_state`, `download_error`, `download_attempts`.
|
||||||
|
|
||||||
|
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
||||||
|
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
||||||
|
Nothing removed, nothing renamed.
|
||||||
@@ -504,6 +504,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FolderCount reports the active root-folder count (test/audit helper:
|
||||||
|
// the podcast-exclusion pin asserts it stays 0 for podcasts roots).
|
||||||
|
func (s *MediaScanner) FolderCount() int {
|
||||||
|
return len(s.folders)
|
||||||
|
}
|
||||||
|
|
||||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||||
// fsnotify watcher over the full directory tree.
|
// fsnotify watcher over the full directory tree.
|
||||||
//
|
//
|
||||||
@@ -539,9 +545,7 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|||||||
}
|
}
|
||||||
folders = scannable
|
folders = scannable
|
||||||
|
|
||||||
s.folders = folders
|
s.folders = folders // Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||||
|
|
||||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ type fetchResult struct {
|
|||||||
StatusCode int
|
StatusCode int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
|
||||||
|
// exercises the REAL client; fetch-driven integration tests swap in a
|
||||||
|
// client that trusts httptest's loopback TLS cert. Never reassign
|
||||||
|
// outside tests (use SetPodcastHTTPClientForTests).
|
||||||
|
var podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
|
||||||
|
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
|
||||||
|
// production client (SSRF guards intact).
|
||||||
|
func SetPodcastHTTPClientForTests(c *http.Client) {
|
||||||
|
if c == nil {
|
||||||
|
podcastHTTPClientFactory = podcastHTTPClient
|
||||||
|
return
|
||||||
|
}
|
||||||
|
podcastHTTPClientFactory = func() *http.Client { return c }
|
||||||
|
}
|
||||||
|
|
||||||
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
|
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,7 +173,7 @@ func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize
|
|||||||
req.Header.Set("If-Modified-Since", lastModified)
|
req.Header.Set("If-Modified-Since", lastModified)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := podcastHTTPClient().Do(req)
|
resp, err := podcastHTTPClientFactory().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Unwrap redirect/scheme guard errors so callers can classify.
|
// Unwrap redirect/scheme guard errors so callers can classify.
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -113,17 +114,18 @@ type PodcastItemXML struct {
|
|||||||
} `xml:"person"`
|
} `xml:"person"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsePodcastFeed decodes a feed document. Returns an error when the
|
// ParsePodcastFeed decodes a feed document via a namespace-aware token
|
||||||
// XML is unparseable or the document is not an RSS channel.
|
// decoder (see decodePodcastFeed). Returns an error when the XML is
|
||||||
|
// unparseable or the document is not an RSS channel.
|
||||||
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||||
var feed PodcastFeedXML
|
feed, err := decodePodcastFeed(data)
|
||||||
if err := xml.Unmarshal(data, &feed); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("feed parse: %w", err)
|
return nil, fmt.Errorf("feed parse: %w", err)
|
||||||
}
|
}
|
||||||
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
|
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
|
||||||
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
|
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
|
||||||
}
|
}
|
||||||
return &feed, nil
|
return feed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
|
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
|
||||||
@@ -255,3 +257,304 @@ func parseFeedTime(v string) (t time.Time, ok bool) {
|
|||||||
}
|
}
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Namespace-aware decoding.
|
||||||
|
//
|
||||||
|
// WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores
|
||||||
|
// namespaces — a field tagged `xml:"title"` also matches
|
||||||
|
// `<itunes:title>` (first field in declaration order wins, document
|
||||||
|
// order clobbers), so the two collide unpredictably. RSS feeds stack
|
||||||
|
// core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local
|
||||||
|
// names (title, author, description, guid), so a token-loop decoder
|
||||||
|
// dispatching on (namespace, local) is the only deterministic shape.
|
||||||
|
// It also tolerates BOTH podcast-namespace URIs the ecosystem uses and
|
||||||
|
// arbitrary prefix spellings, because matching is on resolved
|
||||||
|
// namespace, never on the prefix string.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// decodePodcastFeed runs the token loop and fills the document struct.
|
||||||
|
func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||||
|
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.Strict = false // feeds in the wild carry stray entities; be liberal
|
||||||
|
|
||||||
|
feed := &PodcastFeedXML{}
|
||||||
|
// Find the <rss> start element (tolerate comments/PIs/doctype).
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if se, ok := tok.(xml.StartElement); ok {
|
||||||
|
if se.Name.Local == "rss" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Some feeds wrap in nothing else; only rss is accepted.
|
||||||
|
return nil, fmt.Errorf("unexpected root element %q", se.Name.Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if t.Name.Local == "channel" {
|
||||||
|
if err := decodeChannel(dec, &feed.Channel); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := skipElement(dec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
// </rss> — done.
|
||||||
|
return feed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeChannel walks the channel's children.
|
||||||
|
func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
ns, local := t.Name.Space, t.Name.Local
|
||||||
|
switch {
|
||||||
|
case ns == "" && local == "title":
|
||||||
|
ch.Title, err = elementText(dec)
|
||||||
|
case ns == "" && local == "description":
|
||||||
|
ch.Description, err = elementText(dec)
|
||||||
|
case ns == "" && local == "language":
|
||||||
|
ch.Language, err = elementText(dec)
|
||||||
|
case ns == "" && local == "link":
|
||||||
|
ch.Link, err = elementText(dec)
|
||||||
|
case ns == "" && local == "lastBuildDate":
|
||||||
|
ch.LastBuildDate, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "author":
|
||||||
|
ch.ITunesAuthor, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "owner":
|
||||||
|
err = decodeITunesOwner(dec, ch)
|
||||||
|
case ns == itunesNS && local == "image":
|
||||||
|
ch.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||||
|
err = skipElement(dec)
|
||||||
|
case ns == itunesNS && local == "explicit":
|
||||||
|
ch.ITunesExplicit, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "type":
|
||||||
|
ch.ITunesType, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "new-feed-url":
|
||||||
|
ch.ITunesNewFeedURL, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "complete":
|
||||||
|
ch.ITunesComplete, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "category":
|
||||||
|
ch.ITunesCategory.Text = attrValue(t.Attr, "text")
|
||||||
|
err = decodeITunesCategory(dec, ch)
|
||||||
|
case ns != "" && local == "guid":
|
||||||
|
// podcast:guid (either declared URI); core RSS has no
|
||||||
|
// channel-level guid, so any namespaced guid is it.
|
||||||
|
ch.PodcastGUID, err = elementText(dec)
|
||||||
|
case ns != "" && local == "medium":
|
||||||
|
ch.PodcastMedium, err = elementText(dec)
|
||||||
|
case ns == "" && local == "item":
|
||||||
|
var item PodcastItemXML
|
||||||
|
if err := decodeItem(dec, &item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ch.Items = append(ch.Items, item)
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
err = skipElement(dec)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil // </channel>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if t.Name.Space == itunesNS && t.Name.Local == "name" {
|
||||||
|
if ch.ITunesOwner.Name, err = elementText(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Name.Space == itunesNS && t.Name.Local == "email" {
|
||||||
|
if ch.ITunesOwner.Email, err = elementText(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := skipElement(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if t.Name.Space == itunesNS && t.Name.Local == "category" {
|
||||||
|
ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text")
|
||||||
|
}
|
||||||
|
if err := skipElement(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeItem walks one <item>.
|
||||||
|
func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error {
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
ns, local := t.Name.Space, t.Name.Local
|
||||||
|
switch {
|
||||||
|
case ns == "" && local == "guid":
|
||||||
|
it.GUID, err = elementText(dec)
|
||||||
|
case ns == "" && local == "title":
|
||||||
|
it.Title, err = elementText(dec)
|
||||||
|
case ns == "" && local == "description":
|
||||||
|
it.Description, err = elementText(dec)
|
||||||
|
case ns == "" && local == "pubDate":
|
||||||
|
it.PubDate, err = elementText(dec)
|
||||||
|
case ns == "" && local == "author":
|
||||||
|
it.Author, err = elementText(dec)
|
||||||
|
case ns == "" && local == "enclosure":
|
||||||
|
it.Enclosure.URL = attrValue(t.Attr, "url")
|
||||||
|
it.Enclosure.Type = attrValue(t.Attr, "type")
|
||||||
|
if raw := attrValue(t.Attr, "length"); raw != "" {
|
||||||
|
it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64)
|
||||||
|
}
|
||||||
|
err = skipElement(dec)
|
||||||
|
case ns == itunesNS && local == "title":
|
||||||
|
it.ITunesTitle, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "subtitle":
|
||||||
|
it.Subtitle, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "summary":
|
||||||
|
it.ITunesSummary, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "author":
|
||||||
|
it.ITunesAuthor, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "explicit":
|
||||||
|
it.ITunesExplicit, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "duration":
|
||||||
|
it.ITunesDuration, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "episode":
|
||||||
|
it.ITunesEpisode, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "season":
|
||||||
|
it.ITunesSeason, err = elementText(dec)
|
||||||
|
case ns == itunesNS && local == "image":
|
||||||
|
it.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||||
|
err = skipElement(dec)
|
||||||
|
case ns == "http://purl.org/dc/elements/1.1/" && local == "creator":
|
||||||
|
it.Creator, err = elementText(dec)
|
||||||
|
case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded":
|
||||||
|
it.ContentEncoded, err = elementText(dec)
|
||||||
|
case ns != "" && local == "transcript":
|
||||||
|
it.Transcript.URL = attrValue(t.Attr, "url")
|
||||||
|
err = skipElement(dec)
|
||||||
|
case ns != "" && local == "chapters":
|
||||||
|
it.Chapters.URL = attrValue(t.Attr, "url")
|
||||||
|
err = skipElement(dec)
|
||||||
|
case ns != "" && local == "person":
|
||||||
|
href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role")
|
||||||
|
it.Persons = append(it.Persons, struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
Role string `xml:"role,attr"`
|
||||||
|
}{Href: href, Role: role})
|
||||||
|
err = skipElement(dec)
|
||||||
|
default:
|
||||||
|
err = skipElement(dec)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil // </item>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// elementText accumulates chardata until this element closes (leaf-ish
|
||||||
|
// elements: CDATA sections come through as xml.CharData and are
|
||||||
|
// included; child elements are skipped).
|
||||||
|
func elementText(dec *xml.Decoder) (string, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
depth := 1
|
||||||
|
for depth > 0 {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
depth++
|
||||||
|
case xml.EndElement:
|
||||||
|
depth--
|
||||||
|
case xml.CharData:
|
||||||
|
if depth == 1 {
|
||||||
|
sb.Write(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(sb.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipElement consumes an element we don't model (depth-safe).
|
||||||
|
func skipElement(dec *xml.Decoder) error {
|
||||||
|
depth := 1
|
||||||
|
for depth > 0 {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
depth++
|
||||||
|
case xml.EndElement:
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrValue returns the named attribute (local-name match; the
|
||||||
|
// attributes we read carry no namespace).
|
||||||
|
func attrValue(attrs []xml.Attr, local string) string {
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.Name.Local == local {
|
||||||
|
return strings.TrimSpace(a.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Unit tests for the podcast fetcher's pure logic (plan task 1.4/1.10):
|
||||||
|
// RSS/iTunes/Podcasting-2.0 parsing, duration/pubDate spellings, slug/
|
||||||
|
// path layout, and the SSRF guard (scheme + resolved-IP checks). No DB.
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pgUUID alias keeps the helper signatures short.
|
||||||
|
type pgUUID = pgtype.UUID
|
||||||
|
|
||||||
|
// Full-fixture feed covering the three namespaces and the fallback
|
||||||
|
// chains (itunes:title vs title; description vs itunes:summary vs
|
||||||
|
// content:encoded; itunes:author vs author vs dc:creator).
|
||||||
|
const podcastFixtureXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||||
|
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||||
|
<channel>
|
||||||
|
<title>The Test Feed</title>
|
||||||
|
<link>https://example.com/show</link>
|
||||||
|
<language>en-us</language>
|
||||||
|
<description><p>A feed about <b>testing</b>.</p></description>
|
||||||
|
<itunes:author>Host Name</itunes:author>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:name>Owner Name</itunes:name>
|
||||||
|
<itunes:email>owner@example.com</itunes:email>
|
||||||
|
</itunes:owner>
|
||||||
|
<itunes:image href="https://cdn.example.com/art.jpg"/>
|
||||||
|
<itunes:explicit>false</itunes:explicit>
|
||||||
|
<itunes:type>serial</itunes:type>
|
||||||
|
<itunes:category text="Fiction">
|
||||||
|
<itunes:category text="Drama"/>
|
||||||
|
</itunes:category>
|
||||||
|
<podcast:guid>9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx</podcast:guid>
|
||||||
|
<podcast:medium>audiobook</podcast:medium>
|
||||||
|
<lastBuildDate>Mon, 26 Oct 2026 08:00:00 +0000</lastBuildDate>
|
||||||
|
<item>
|
||||||
|
<guid isPermaLink="false">episode-guid-1</guid>
|
||||||
|
<itunes:title>Episode One: The Beginning</itunes:title>
|
||||||
|
<title>Ep 1</title>
|
||||||
|
<itunes:subtitle>First chapter</itunes:subtitle>
|
||||||
|
<description><![CDATA[<p>Chapter one text.</p>]]></description>
|
||||||
|
<itunes:author>Narrator A</itunes:author>
|
||||||
|
<itunes:duration>1:02:03</itunes:duration>
|
||||||
|
<itunes:episode>1</itunes:episode>
|
||||||
|
<itunes:season>2</itunes:season>
|
||||||
|
<pubDate>Sun, 25 Oct 2026 12:00:00 +0000</pubDate>
|
||||||
|
<enclosure url="https://cdn.example.com/ep1.mp3" length="52428800" type="audio/mpeg"/>
|
||||||
|
<podcast:transcript url="https://cdn.example.com/ep1.json" type="application/json"/>
|
||||||
|
<podcast:chapters url="https://cdn.example.com/ep1-chapters.json" type="application/json+chapters"/>
|
||||||
|
<podcast:person href="Narrator A" role="host"/>
|
||||||
|
<podcast:person href="Guest B" role="guest"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<guid>episode-guid-2</guid>
|
||||||
|
<title>Episode Two</title>
|
||||||
|
<itunes:summary>Summary only description.</itunes:summary>
|
||||||
|
<dc:creator>Writer C</dc:creator>
|
||||||
|
<itunes:duration>5400</itunes:duration>
|
||||||
|
<pubDate>Mon, 26 Oct 2026 12:00:00 GMT</pubDate>
|
||||||
|
<enclosure url="https://cdn.example.com/ep2.m4a" length="1048576" type="audio/mp4"/>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>`
|
||||||
|
|
||||||
|
func TestParsePodcastFeed(t *testing.T) {
|
||||||
|
feed, err := ParsePodcastFeed([]byte(podcastFixtureXML))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ch := feed.Channel
|
||||||
|
assert.Equal(t, "The Test Feed", ch.Title)
|
||||||
|
assert.Equal(t, "en-us", ch.Language)
|
||||||
|
assert.Equal(t, "Host Name", ch.ITunesAuthor)
|
||||||
|
assert.Equal(t, "Owner Name", ch.ITunesOwner.Name)
|
||||||
|
assert.Equal(t, "owner@example.com", ch.ITunesOwner.Email)
|
||||||
|
assert.Equal(t, "https://cdn.example.com/art.jpg", ch.ITunesImage.Href)
|
||||||
|
assert.Equal(t, "serial", ch.ITunesType)
|
||||||
|
assert.Equal(t, "Fiction", ch.ITunesCategory.Text)
|
||||||
|
assert.Equal(t, "Drama", ch.ITunesCategory.Subcategory.Text)
|
||||||
|
assert.Equal(t, "9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx", ch.PodcastGUID)
|
||||||
|
assert.Equal(t, "audiobook", ch.PodcastMedium)
|
||||||
|
assert.False(t, parseExplicit(ch.ITunesExplicit))
|
||||||
|
|
||||||
|
buildAt, ok := parseFeedTime(ch.LastBuildDate)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 2026, buildAt.Year())
|
||||||
|
|
||||||
|
require.Len(t, ch.Items, 2)
|
||||||
|
|
||||||
|
// Item 1: itunes:title preferred, description chain, hosts/guests.
|
||||||
|
it1 := &ch.Items[0]
|
||||||
|
assert.Equal(t, "Episode One: The Beginning", it1.EpisodeTitle())
|
||||||
|
assert.Equal(t, "First chapter", it1.Subtitle)
|
||||||
|
assert.Equal(t, "<p>Chapter one text.</p>", it1.EpisodeDescription()) // raw HTML; stripping happens at storage time
|
||||||
|
assert.Equal(t, "Narrator A", it1.EpisodeAuthor())
|
||||||
|
assert.Equal(t, 3723, parseITunesDuration(it1.ITunesDuration)) // 1:02:03
|
||||||
|
assert.Equal(t, 2, intMust(it1.ITunesSeason))
|
||||||
|
assert.Equal(t, 1, intMust(it1.ITunesEpisode))
|
||||||
|
assert.Equal(t, "https://cdn.example.com/ep1.mp3", it1.Enclosure.URL)
|
||||||
|
assert.Equal(t, int64(52428800), it1.Enclosure.Length)
|
||||||
|
assert.Equal(t, "audio/mpeg", it1.Enclosure.Type)
|
||||||
|
assert.Equal(t, "https://cdn.example.com/ep1.json", it1.Transcript.URL)
|
||||||
|
assert.Equal(t, "https://cdn.example.com/ep1-chapters.json", it1.Chapters.URL)
|
||||||
|
hosts, guests := it1.HostsAndGuests()
|
||||||
|
assert.Equal(t, []string{"Narrator A"}, hosts)
|
||||||
|
assert.Equal(t, []string{"Guest B"}, guests)
|
||||||
|
assert.Equal(t, "2026-10-25", mustDate(it1.PubDate))
|
||||||
|
|
||||||
|
// Item 2: fallback chains — plain title, itunes:summary, dc:creator.
|
||||||
|
it2 := &ch.Items[1]
|
||||||
|
assert.Equal(t, "Episode Two", it2.EpisodeTitle())
|
||||||
|
assert.Equal(t, "Summary only description.", it2.EpisodeDescription())
|
||||||
|
assert.Equal(t, "Writer C", it2.EpisodeAuthor())
|
||||||
|
assert.Equal(t, 5400, parseITunesDuration(it2.ITunesDuration)) // seconds
|
||||||
|
assert.Equal(t, "https://cdn.example.com/ep2.m4a", it2.Enclosure.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intMust(s string) int {
|
||||||
|
n, _ := parseSeasonOrEpisode(s)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSeasonOrEpisode(s string) (int, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDate(pubDate string) string {
|
||||||
|
if t, ok := parseFeedTime(pubDate); ok {
|
||||||
|
return t.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseITunesDuration(t *testing.T) {
|
||||||
|
assert.Equal(t, 0, parseITunesDuration(""))
|
||||||
|
assert.Equal(t, 90, parseITunesDuration("90"))
|
||||||
|
assert.Equal(t, 3723, parseITunesDuration("1:02:03"))
|
||||||
|
assert.Equal(t, 123, parseITunesDuration("2:03"))
|
||||||
|
assert.Equal(t, 3600, parseITunesDuration("1 hour"))
|
||||||
|
assert.Equal(t, 120, parseITunesDuration("2 minutes"))
|
||||||
|
assert.Equal(t, 0, parseITunesDuration("soonish"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedTimeVariants(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
year int
|
||||||
|
}{
|
||||||
|
{"Mon, 26 Oct 2026 08:00:00 +0000", 2026},
|
||||||
|
{"Mon, 26 Oct 2026 08:00:00 GMT", 2026},
|
||||||
|
{"26 Oct 2026 08:00:00 +0000", 2026},
|
||||||
|
} {
|
||||||
|
tm, ok := parseFeedTime(tc.in)
|
||||||
|
require.True(t, ok, "parse %q", tc.in)
|
||||||
|
assert.Equal(t, tc.year, tm.Year())
|
||||||
|
}
|
||||||
|
_, ok := parseFeedTime("not a date")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEpisodeExplicitSpellings(t *testing.T) {
|
||||||
|
assert.True(t, parseExplicit("true"))
|
||||||
|
assert.True(t, parseExplicit("yes"))
|
||||||
|
assert.True(t, parseExplicit("explicit"))
|
||||||
|
assert.False(t, parseExplicit("false"))
|
||||||
|
assert.False(t, parseExplicit("clean"))
|
||||||
|
assert.False(t, parseExplicit(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlugifyAndPaths(t *testing.T) {
|
||||||
|
assert.Equal(t, "the-test-feed", slugify("The Test Feed!", 60))
|
||||||
|
assert.Equal(t, "a-b-c", slugify(" A -- B // C ", 60))
|
||||||
|
assert.Equal(t, "abcdefghij", slugify("ABCDEFGHIJ", 10))
|
||||||
|
|
||||||
|
slug := feedSlug("My Feed", testUUID("11111111-1111-1111-1111-111111111111"))
|
||||||
|
assert.Equal(t, "my-feed-11111111", slug)
|
||||||
|
|
||||||
|
rel := episodeRelPath(slug, 42, "Episode: The Test", ".mp3")
|
||||||
|
assert.Equal(t, "my-feed-11111111/0042-episode-the-test.mp3", rel)
|
||||||
|
|
||||||
|
assert.Equal(t, "my-feed-11111111/artwork.jpg", artworkRelPath(slug, ".jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnclosureExtension(t *testing.T) {
|
||||||
|
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/a/b/ep.mp3?token=1", ""))
|
||||||
|
assert.Equal(t, ".m4a", enclosureExtension("https://x.test/ep", "audio/mp4"))
|
||||||
|
assert.Equal(t, ".flac", enclosureExtension("https://x.test/ep", "audio/flac"))
|
||||||
|
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/ep", "weird/type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripHTML(t *testing.T) {
|
||||||
|
assert.Equal(t, "hello world", stripHTML("<p>hello <b>world</b></p>"))
|
||||||
|
assert.Equal(t, "a b", stripHTML("a\n\n b"))
|
||||||
|
assert.Equal(t, "", stripHTML(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPublicIP(t *testing.T) {
|
||||||
|
blocked := []string{
|
||||||
|
"127.0.0.1", "10.0.0.5", "172.16.0.9", "192.168.1.1",
|
||||||
|
"169.254.169.254", // cloud metadata endpoint
|
||||||
|
"0.0.0.0", "::1", "fe80::1", "fd00::5", "::ffff:127.0.0.1",
|
||||||
|
"::ffff:10.0.0.1", "224.0.0.1",
|
||||||
|
}
|
||||||
|
for _, s := range blocked {
|
||||||
|
assert.False(t, isPublicIP(net.ParseIP(s)), "%s must be blocked", s)
|
||||||
|
}
|
||||||
|
allowed := []string{
|
||||||
|
"93.184.216.34", "1.1.1.1", "2606:4700:4700::1111",
|
||||||
|
}
|
||||||
|
for _, s := range allowed {
|
||||||
|
assert.True(t, isPublicIP(net.ParseIP(s)), "%s must be allowed", s)
|
||||||
|
}
|
||||||
|
assert.False(t, isPublicIP(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardURLScheme(t *testing.T) {
|
||||||
|
u, _ := url.Parse("http://example.com/feed.xml")
|
||||||
|
assert.Error(t, guardURLScheme(u))
|
||||||
|
u, _ = url.Parse("ftp://example.com/feed.xml")
|
||||||
|
assert.Error(t, guardURLScheme(u))
|
||||||
|
u, _ = url.Parse("https://example.com/feed.xml")
|
||||||
|
assert.NoError(t, guardURLScheme(u))
|
||||||
|
assert.Error(t, guardURLScheme(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The REAL client (production factory) must refuse loopback targets —
|
||||||
|
// this is the SSRF boundary; httptest servers live on 127.0.0.1.
|
||||||
|
func TestGuardFetchBlocksLoopback(t *testing.T) {
|
||||||
|
srv := newInsecureFeedServer(t, podcastFixtureXML)
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := guardFetch(t.Context(), srv.URL, "", "", maxFeedSizeBytes)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, strings.Contains(err.Error(), "blocked") ||
|
||||||
|
strings.Contains(err.Error(), "https"), "expected an SSRF/scheme rejection, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardFetchRejectsPlainHTTP(t *testing.T) {
|
||||||
|
// A public-looking http:// URL: scheme guard fires before any dial.
|
||||||
|
_, err := guardFetch(t.Context(), "http://example.com/feed.xml", "", "", maxFeedSizeBytes)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyFetchError(t *testing.T) {
|
||||||
|
err := classifyFetchError(ErrBlockedPrivateAddress)
|
||||||
|
assert.Contains(t, err.Error(), "blocked")
|
||||||
|
someErr := &url.Error{Op: "Get", Err: errors.New("boom")}
|
||||||
|
assert.Equal(t, someErr.Error(), classifyFetchError(someErr).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// testUUID builds a pgtype.UUID from a string (test helper).
|
||||||
|
func testUUID(s string) pgUUID {
|
||||||
|
u, _ := uuid.Parse(s)
|
||||||
|
return pgUUID{Bytes: u, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInsecureFeedServer serves body over httptest TLS (loopback) —
|
||||||
|
// used to prove the production client REFUSES it.
|
||||||
|
func newInsecureFeedServer(t *testing.T, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
@@ -129,6 +129,12 @@ type AddFeedResult struct {
|
|||||||
FetchError string
|
FetchError string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeedError returns the fetch failure text ("" when the fetch was fine
|
||||||
|
// or there is no feed row).
|
||||||
|
func (r *AddFeedResult) FeedError() string {
|
||||||
|
return r.FetchError
|
||||||
|
}
|
||||||
|
|
||||||
// AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the
|
// AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the
|
||||||
// user-supplied URL, create-or-reuse the feed row, upsert metadata-only
|
// user-supplied URL, create-or-reuse the feed row, upsert metadata-only
|
||||||
// episodes, subscribe the creator. Authorization on explicit arguments
|
// episodes, subscribe the creator. Authorization on explicit arguments
|
||||||
|
|||||||
@@ -22,3 +22,20 @@ Gate: every line below executes for BOTH personas unless marked
|
|||||||
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
||||||
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
||||||
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
|
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
|
||||||
|
| 18 | Podcasts: page renders (empty state before any feed) | 🔲 | 🔲 |
|
||||||
|
| 19 | Podcasts: add-feed modal → feed page (member flow) | 🔲 | 🔲 |
|
||||||
|
| 20 | Podcasts: feed detail — episode list lifecycle (download/queue/failed+retry states inline), Download All, gap banner | 🔲 | 🔲 |
|
||||||
|
| 21 | Podcasts: cross-user invisibility — own feed only (RLS §20 surfaces) | 🔲 | 🔲 |
|
||||||
|
| 22 | Podcasts: order selector re-renders episode list; serial suggestion shown only for serial/audiobook feeds | 🔲 | 🔲 |
|
||||||
|
| 23 | Podcasts: audio reader "Ep N/M" + prev/next episode; chain-advance to next episode | 🔲 | 🔲 |
|
||||||
|
|
||||||
|
### Podcast-specific sweep notes (plan task 1.10)
|
||||||
|
|
||||||
|
- The download lifecycle needs a fetchable enclosure: sweep with a local
|
||||||
|
feed server fixture (the integration tests use `swapFeedServer`; the
|
||||||
|
Playwright run needs the same trick) — otherwise episodes stay
|
||||||
|
pending and rows 20's queue/failed states can't render.
|
||||||
|
- Row 21 rides `TestPodcastRLSSurfaces` (API level) until the sweep
|
||||||
|
lands; the page-level check is the 404 render for a foreign feed id.
|
||||||
|
- Row 23 chains by page navigation — assert the URL becomes the next
|
||||||
|
episode's reader and progress carries over.
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user