diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 6e4dc700..32001f00 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -423,6 +423,54 @@ func TestPodcastEditSegregation(t *testing.T) { }) } +// ---- invariant 2, absolute form: the generic DELETE never touches +// episode rows (only whole-feed removal does) ---- + +func TestDeleteMediaItemPodcastGuard(t *testing.T) { + setup := setupTestServer(t) + lib, _ := createPodcastsLibrary(t, setup, "test DeleteGuard Podcasts") + feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/delete-guard.xml", + Title: "DeleteGuard Feed", + }) + require.NoError(t, err) + ep := upsertTestEpisode(t, setup, feed, "delguard-1", "Delete Guard Episode", 1) + epID := uuid.UUID(ep.ID.Bytes).String() + + t.Run("admin_delete_of_episode_refused", func(t *testing.T) { + code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+epID, "") + assert.Equal(t, http.StatusConflict, code, + "PIN (invariant 2, 2026-10-30): episode rows are removed ONLY by whole-feed removal โ€” the generic delete must 409: %s", string(body)) + _, err := setup.DB.GetMediaItem(adminSeedCtx(t, setup), ep.ID) + require.NoError(t, err, "episode row must survive the refused delete") + }) + + t.Run("book_delete_still_works", func(t *testing.T) { + // Control: the rejection is podcast-specific, not a broken route. + libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks") + require.NoError(t, err) + book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ + LibraryID: func() pgtype.UUID { + lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{ + Name: "test DeleteGuard Books", + LibraryTypeID: libType.ID, + }) + require.NoError(t, cerr) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) }) + return lib2.ID + }(), + Title: "Delete Control Book", + FilePath: "control-" + uuid.New().String() + ".m4b", + }) + require.NoError(t, err) + code, body := podcastAuthed(t, setup, setup.Token, "DELETE", + "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), "") + assert.Equal(t, http.StatusNoContent, code, + "generic delete must stay intact for scanned media: %d %s", code, string(body)) + }) +} + // ---- download worker: state machine, idempotency, file layout ---- func TestPodcastDownloadWorker(t *testing.T) { diff --git a/internal/handlers/media.go b/internal/handlers/media.go index cf8606d2..bb65040a 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1881,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + // Invariant 2 (podcast plan ยง10, absolute form): episode rows are + // removed ONLY when the whole feed is removed โ€” the feed must match + // its XML until then. Same segregation as the edit path (D9/D10): + // episodes are fetcher-owned; delete FILES via the podcast surfaces + // (feed page / admin podcast view) instead. + if media.Source == "podcast_episode" { + return c.JSON(http.StatusConflict, map[string]string{ + "error": "podcast episodes are removed only by removing the whole feed โ€” delete the episode's files instead (feed page or admin podcast view)", + }) + } + // Storage-down guard (see HandleBulkDelete): refuse while the library // storage is unreachable so the cascade can't destroy annotations for // a file that will re-import on the next scan.