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:
1 parent
0741e98ea5
commit
7d5bbd1177
6 files changed
+543
-272
No files matched your search
@@ -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", "")
|
||||
|
||||
Reference in new issue
Block a user