feat(admin): multi-select episode file deletion — checkboxes on downloaded rows

The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
This commit is contained in:
John O'Keefe committed 2026-10-30 14:06:54 -04:00
1 parent 0741e98ea5
commit 7d5bbd1177
6 files changed
+543 -272

No files matched your search

+40
View File
@@ -1399,6 +1399,46 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
assert.Equal(t, "pending", after2.DownloadState)
assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
// --- bulk multi-select delete-files (§0 item 8 follow-up) ---
// Re-download both, then delete them in ONE request with a forged
// foreign id mixed in: only the matching-feed ids are applied.
for _, ep := range []database.MediaItems{ep1, ep2} {
require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID))
_, err = setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both re-downloaded for the bulk test")
foreignID := uuid.New().String()
bulkForm := url.Values{}
bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()}
code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm)
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied")
assert.NoFileExists(t, pathFor(ep1))
assert.NoFileExists(t, pathFor(ep2))
afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range afterBulk {
assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title)
}
// Non-admins cannot bulk-delete either.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files")
// --- refresh must NOT re-materialize deleted files (invariant 2):
// the poll upsert rewrites metadata but never touches download_state.
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")