feat(content): batch operations — multi-select, batch delete, batch move (#224)

The content library had no batch operations — every item was managed one at a
time. Add multi-select with batch delete and batch move.

Backend (content.js):
- POST /content/batch/delete — array of ids, atomic: validates + authorizes
  EVERY id first (malformed/missing/forbidden rejects the whole batch), then
  deletes in one transaction. Reuses the single-delete teardown.
- POST /content/batch/move — array of ids + target folder_id, same atomic
  validate-all-first; target folder must share each item's workspace. Folder is
  organizational (not in the snapshot), so no device push.
- Refactor: extract purgeContentRow() (file removal + snapshot scrub + row
  delete + affected-device collection) and pushContentUpdates(); DELETE /:id now
  uses them, so single + batch share one scrub path (no duplication). Add a
  boolean contentWritable() mirroring checkContentWrite's authorization.
- 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE.

Frontend (content-library):
- Per-card selection checkbox, select-all/none (visible), shift-click range.
- Selection persists across folders/pages (issue-aligned cross-page selection);
  cleared after a successful batch op.
- Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete
  with click-again confirm. Selected cards get an outline.
- api.batchDeleteContent / batchMoveContent; en/es i18n.

Not included: batch "set expiry" (listed in the issue's toolbar sketch but only
delete/move had endpoint specs) — deferred; PUT already does per-item expiry.

Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs
snapshots; atomic rejection leaves valid rows intact; malformed id -> 400;
batch move reassigns folder; cross-workspace folder refused; empty batch -> 400.
Suite 553/553.

Closes #213

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-23 12:29:44 -05:00 committed by GitHub
parent 5c6d508032
commit 8b661a7347
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 390 additions and 58 deletions

View file

@ -75,6 +75,15 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ folder_id: folderId })
}),
// #213: batch operations — the whole batch succeeds or fails atomically server-side.
batchDeleteContent: (ids) => request('/content/batch/delete', {
method: 'POST',
body: JSON.stringify({ ids })
}),
batchMoveContent: (ids, folderId) => request('/content/batch/move', {
method: 'POST',
body: JSON.stringify({ ids, folder_id: folderId || null })
}),
// Folders
getFolders: () => request('/folders'),

View file

@ -197,6 +197,14 @@ export default {
'content.sort_name': 'Name AZ',
'content.sort_size': 'Largest first',
'content.result_count': '{count} result(s)',
'content.batch_selected': '{count} selected',
'content.batch_select_all': 'Select all',
'content.batch_select_none': 'Select none',
'content.batch_move_placeholder': 'Move to…',
'content.batch_delete': 'Delete ({count})',
'content.batch_delete_confirm': 'Click again to delete {count}',
'content.toast.batch_moved': '{count} item(s) moved',
'content.toast.batch_deleted': '{count} item(s) deleted',
'content.new_folder_btn': '+ New Folder',
'content.breadcrumb_root': 'All Content',
'content.rename_btn': 'Rename',

View file

@ -160,6 +160,14 @@ export default {
'content.sort_name': 'Nombre AZ',
'content.sort_size': 'Más grandes primero',
'content.result_count': '{count} resultado(s)',
'content.batch_selected': '{count} seleccionado(s)',
'content.batch_select_all': 'Seleccionar todo',
'content.batch_select_none': 'Deseleccionar todo',
'content.batch_move_placeholder': 'Mover a…',
'content.batch_delete': 'Eliminar ({count})',
'content.batch_delete_confirm': 'Haz clic otra vez para eliminar {count}',
'content.toast.batch_moved': '{count} elemento(s) movido(s)',
'content.toast.batch_deleted': '{count} elemento(s) eliminado(s)',
'content.new_folder_btn': '+ Nueva carpeta',
'content.breadcrumb_root': 'Todo el contenido',
'content.rename_btn': 'Renombrar',

View file

@ -117,6 +117,7 @@ export function render(container) {
</label>
</div>
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
<div id="batchToolbar" style="display:none"></div>
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
<div class="content-grid" id="contentGrid">
<div class="empty-state" style="grid-column:1/-1"><h3>${t('common.loading')}</h3></div>
@ -230,6 +231,8 @@ const state = {
search: '', // #214: server-side text search (spans the whole workspace)
type: 'all', // #214: type filter — all | video | image | youtube | web
sort: 'date_desc', // #214: sort order — date_desc | date_asc | name | size
selected: new Set(), // #213: ids selected for batch operations (scoped to the current view)
lastClickedId: null, // #213: anchor for shift-click range selection
};
async function handleFiles(files) {
@ -419,7 +422,10 @@ async function loadContent() {
grid.innerHTML = content.map(c => {
const exp = expiryInfo(c);
return `
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="${exp.expired ? 'opacity:.55' : ''}">
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
<label class="content-select-wrap" style="position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);border-radius:4px;padding:3px;display:flex;cursor:pointer">
<input type="checkbox" class="content-select" data-content-id="${c.id}" ${state.selected.has(c.id) ? 'checked' : ''} style="width:16px;height:16px;margin:0;cursor:pointer">
</label>
<div class="content-item-preview">
${c.mime_type === 'video/youtube'
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
@ -491,8 +497,34 @@ async function loadContent() {
});
});
// #213: selection checkboxes (with shift-click range). `content` is the current page's
// ordered list, so a range fills between the anchor and the clicked item.
grid.querySelectorAll('.content-select').forEach(cb => {
cb.addEventListener('click', (e) => {
const id = cb.dataset.contentId;
if (e.shiftKey && state.lastClickedId) {
const order = content.map(c => c.id);
const a = order.indexOf(state.lastClickedId);
const b = order.indexOf(id);
if (a !== -1 && b !== -1) {
const [lo, hi] = a < b ? [a, b] : [b, a];
const on = cb.checked; // apply the clicked box's new state across the range
for (let i = lo; i <= hi; i++) { if (on) state.selected.add(order[i]); else state.selected.delete(order[i]); }
}
} else if (cb.checked) {
state.selected.add(id);
} else {
state.selected.delete(id);
}
state.lastClickedId = id;
loadContent(); // re-render to reflect range + selection outlines + toolbar
});
});
// Delete handler via event delegation
grid.onclick = async (e) => {
// #213: ignore clicks originating on a selection checkbox (handled above).
if (e.target.closest('.content-select-wrap')) return;
// Preview on click (not on delete button)
const previewTarget = e.target.closest('.content-item-preview');
if (previewTarget) {
@ -552,11 +584,87 @@ async function loadContent() {
}, 3000);
};
// #213: batch-operations toolbar reflects the current selection.
renderBatchToolbar(content);
} catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
}
}
// #213: the batch toolbar — shown only when something is selected. `visible` is the current
// page's items, used by "select all". Actions validate/act atomically server-side; on success
// the selection is cleared and the grid reloaded.
function renderBatchToolbar(visible) {
const bar = document.getElementById('batchToolbar');
if (!bar) return;
const count = state.selected.size;
if (count === 0) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const allVisibleSelected = visible.length > 0 && visible.every(c => state.selected.has(c.id));
bar.style.display = 'flex';
bar.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg)';
bar.innerHTML = `
<strong style="font-size:13px">${t('content.batch_selected', { count })}</strong>
<button class="btn btn-secondary btn-sm" id="batchSelectAll">${allVisibleSelected ? t('content.batch_select_none') : t('content.batch_select_all')}</button>
<div style="display:flex;align-items:center;gap:6px;margin-left:auto">
<select id="batchMoveFolder" class="input btn-sm" style="width:auto;background:var(--bg-input)">
<option value="">${t('content.batch_move_placeholder')}</option>
<option value="__root__">${t('content.folder_root_option')}</option>
${state.folders.map(f => `<option value="${f.id}">${esc(folderPath(f, state.folders))}</option>`).join('')}
</select>
<button class="btn btn-danger btn-sm" id="batchDelete">${t('content.batch_delete', { count })}</button>
</div>
`;
bar.querySelector('#batchSelectAll').onclick = () => {
if (allVisibleSelected) visible.forEach(c => state.selected.delete(c.id));
else visible.forEach(c => state.selected.add(c.id));
loadContent();
};
bar.querySelector('#batchMoveFolder').onchange = async (e) => {
const val = e.target.value;
if (!val) return;
const folderId = val === '__root__' ? null : val;
const ids = [...state.selected];
try {
await api.batchMoveContent(ids, folderId);
showToast(t('content.toast.batch_moved', { count: ids.length }), 'success');
state.selected.clear();
state.lastClickedId = null;
loadContent();
} catch (err) {
showToast(err.message, 'error');
e.target.value = '';
}
};
const delBtn = bar.querySelector('#batchDelete');
delBtn.onclick = async () => {
const ids = [...state.selected];
if (delBtn.dataset.confirming !== 'true') {
delBtn.dataset.confirming = 'true';
delBtn.textContent = t('content.batch_delete_confirm', { count: ids.length });
setTimeout(() => { if (delBtn.dataset.confirming === 'true') { delBtn.dataset.confirming = 'false'; delBtn.textContent = t('content.batch_delete', { count: ids.length }); } }, 3000);
return;
}
try {
delBtn.disabled = true;
await api.batchDeleteContent(ids);
showToast(t('content.toast.batch_deleted', { count: ids.length }), 'success');
state.selected.clear();
state.lastClickedId = null;
loadContent();
} catch (err) {
showToast(err.message, 'error');
delBtn.disabled = false;
delBtn.dataset.confirming = 'false';
delBtn.textContent = t('content.batch_delete', { count: ids.length });
}
};
}
function showEditModal(contentItem, onSave) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';

View file

@ -271,6 +271,132 @@ function checkContentWrite(req, res) {
return content;
}
// #213: boolean form of checkContentWrite for batch paths (no res side effects). True if
// req.user may modify this content row. Mirrors checkContentWrite's authorization exactly.
function contentWritable(req, content) {
if (!content) return false;
if (!content.workspace_id) return PLATFORM_ROLES.includes(req.user.role);
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return false;
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return false;
return true;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// #213: shared single-row teardown used by DELETE /:id and POST /batch/delete. Removes the
// row's files, scrubs it from published snapshots in its workspace, deletes the row (cascades
// playlist_items). Returns the device ids whose playlists referenced it so the caller can push
// updates. Pure DB+FS, no HTTP. `content.id` MUST be a validated UUID (LIKE scrub) and the
// caller MUST have authorized the write. File unlinks are wrapped so they never throw.
function purgeContentRow(content) {
const id = content.id;
const unlink = (rel) => {
if (!rel) return;
const p = path.join(config.contentDir, path.basename(rel));
if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch (e) { /* best-effort */ } }
};
unlink(content.filepath);
unlink(content.thumbnail_path);
unlink(content.subtitle_url); // #216 sidecar (undefined on pre-#216 rows — no-op)
const affected = db.prepare(`
SELECT DISTINCT d.id as device_id FROM devices d
JOIN playlists p ON d.playlist_id = p.id
JOIN playlist_items pi ON pi.playlist_id = p.id
WHERE pi.content_id = ?
`).all(id).map(r => r.device_id);
const snapshotPlaylists = db.prepare(
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
).all(content.workspace_id, `%${id}%`);
for (const pl of snapshotPlaylists) {
try {
const items = JSON.parse(pl.published_snapshot);
const filtered = items.filter(item => item.content_id !== id);
if (filtered.length !== items.length) {
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?').run(JSON.stringify(filtered), pl.id);
}
} catch (e) { /* corrupt snapshot, skip */ }
}
db.prepare('DELETE FROM content WHERE id = ?').run(id);
return affected;
}
// #213: push a playlist refresh to a set of device ids (deduped). Silent on any failure.
function pushContentUpdates(req, deviceIds) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const deviceNs = io.of('/device');
for (const id of new Set(deviceIds)) {
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, id, buildPlaylistPayload);
}
} catch (e) { /* silent */ }
}
// #213: batch delete. Validates + authorizes EVERY id first (atomic — the whole batch is
// rejected if any id is malformed/missing/forbidden), then deletes in one transaction.
router.post('/batch/delete', (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
const rows = [];
for (const id of ids) {
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
rows.push(content);
}
const affected = new Set();
db.transaction(() => {
for (const content of rows) for (const d of purgeContentRow(content)) affected.add(d);
})();
pushContentUpdates(req, affected);
res.json({ success: true, deleted: rows.length, affectedDevices: [...affected] });
});
// #213: batch move. Reassigns folder_id for many items at once. Folder is organizational only
// (not in the published snapshot), so no device push is needed. Same atomic validate-all-first.
router.post('/batch/move', (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : null;
const folderId = req.body.folder_id || null;
if (!ids || ids.length === 0) return res.status(400).json({ error: 'ids must be a non-empty array' });
if (ids.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per batch)' });
const rows = [];
for (const id of ids) {
if (typeof id !== 'string' || !UUID_RE.test(id)) return res.status(400).json({ error: `Invalid content ID: ${id}` });
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
if (!content) return res.status(404).json({ error: `Content not found: ${id}` });
if (!contentWritable(req, content)) return res.status(403).json({ error: `Access denied for content: ${id}` });
rows.push(content);
}
// Target folder (if any) must exist and share the workspace of every moved item.
if (folderId) {
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folderId);
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
for (const content of rows) {
if (target.workspace_id !== content.workspace_id) {
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
}
}
}
db.transaction(() => {
const stmt = db.prepare('UPDATE content SET folder_id = ? WHERE id = ?');
for (const content of rows) stmt.run(folderId, content.id);
})();
res.json({ success: true, moved: rows.length, folder_id: folderId });
});
// Get content metadata
router.get('/:id', (req, res) => {
const content = checkContentRead(req, res);
@ -411,65 +537,14 @@ router.get('/:id/thumbnail', (req, res) => {
router.delete('/:id', (req, res) => {
const content = checkContentWrite(req, res);
if (!content) return;
// Delete file from disk (skip for remote URL content)
if (content.filepath) {
const filePath = path.join(config.contentDir, content.filepath);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
// Delete thumbnail
if (content.thumbnail_path) {
const thumbPath = path.join(config.contentDir, content.thumbnail_path);
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
}
// Get devices that have this content in their playlist (via playlist_items)
const affectedDevices = db.prepare(`
SELECT DISTINCT d.id as device_id FROM devices d
JOIN playlists p ON d.playlist_id = p.id
JOIN playlist_items pi ON pi.playlist_id = p.id
WHERE pi.content_id = ?
`).all(req.params.id);
// Scrub published snapshots that reference this content
// Validate UUID format to prevent LIKE wildcard injection
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Validate UUID format to prevent LIKE wildcard injection in the snapshot scrub.
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
// Phase 2.2k: scope snapshot scrubbing by content.workspace_id (was content.user_id).
// Playlists referencing this content live in the same workspace; user_id-keying missed
// cross-user playlists in the same workspace once playlists became workspace-scoped.
const snapshotPlaylists = db.prepare(
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
).all(content.workspace_id, `%${req.params.id}%`);
for (const pl of snapshotPlaylists) {
try {
const items = JSON.parse(pl.published_snapshot);
const filtered = items.filter(item => item.content_id !== req.params.id);
if (filtered.length !== items.length) {
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
.run(JSON.stringify(filtered), pl.id);
}
} catch (e) { /* corrupt snapshot, skip */ }
}
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
// Push updated snapshots to affected devices
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const deviceNs = io.of('/device');
for (const d of affectedDevices) {
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.device_id, buildPlaylistPayload);
}
}
} catch (e) { /* silent */ }
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
// #213: shared teardown (file removal + snapshot scrub + row delete). Returns the affected
// device ids so we can push a refresh.
const affectedDevices = purgeContentRow(content);
pushContentUpdates(req, affectedDevices);
res.json({ success: true, affectedDevices });
});
module.exports = router;

View file

@ -0,0 +1,124 @@
'use strict';
// #213 batch operations. POST /content/batch/delete and /batch/move. The atomic
// validate-all-first contract and the shared snapshot-scrub path are the parts worth guarding.
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-batch-'));
process.env.DATA_DIR = TMP;
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const { db } = require('../db/database');
const { publishPlaylist } = require('../routes/playlists');
const UUID = () => crypto.randomUUID();
const USER = 'u-batch';
let server, base;
function post(pathname, body) {
const data = Buffer.from(JSON.stringify(body));
return new Promise((resolve, reject) => {
const req = http.request(`${base}${pathname}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
}, (r) => { let o = ''; r.on('data', (c) => (o += c)); r.on('end', () => resolve({ status: r.statusCode, json: o ? JSON.parse(o) : null })); });
req.on('error', reject);
req.end(data);
});
}
function mkContent(id, { filepath = '', workspace = 'ws-batch' } = {}) {
db.prepare('INSERT INTO content (id, filename, mime_type, filepath, workspace_id) VALUES (?, ?, ?, ?, ?)')
.run(id, id + '.mp4', 'video/mp4', filepath, workspace);
}
before(async () => {
fs.mkdirSync(require('../config').contentDir, { recursive: true });
db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local');
db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-batch', 'Org', ?)").run(USER);
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-batch', 'org-batch', 'WS')").run();
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-other', 'org-batch', 'Other')").run();
db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-batch', 'Folder', 'u-batch', 'ws-batch')").run();
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.workspaceId = 'ws-batch'; req.user = { id: USER, role: 'platform_admin' }; next(); });
app.use('/content', require('../routes/content'));
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((r) => server.close(r)));
test('batch delete removes all rows, their files, and scrubs published snapshots', async () => {
const a = UUID(), b = UUID();
// give `a` a real file on disk to prove it's removed
const fp = a + '.mp4';
fs.writeFileSync(path.join(require('../config').contentDir, fp), 'x');
mkContent(a, { filepath: fp });
mkContent(b);
// a published playlist carrying both -> must be scrubbed of both
const pl = UUID();
db.prepare("INSERT INTO playlists (id, user_id, workspace_id, name, status) VALUES (?, ?, 'ws-batch', 'P', 'draft')").run(pl, USER);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(pl, a);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 1)').run(pl, b);
publishPlaylist(pl);
assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 2);
const res = await post('/content/batch/delete', { ids: [a, b] });
assert.equal(res.status, 200);
assert.equal(res.json.deleted, 2);
assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id IN (?,?)').get(a, b).n, 0);
assert.ok(!fs.existsSync(path.join(require('../config').contentDir, fp)), 'file removed from disk');
assert.equal(JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id=?').get(pl).published_snapshot).length, 0);
});
test('batch delete is atomic: one bad id rejects the whole batch, nothing deleted', async () => {
const a = UUID();
mkContent(a);
const res = await post('/content/batch/delete', { ids: [a, UUID() /* not found */] });
assert.equal(res.status, 404);
assert.equal(db.prepare('SELECT COUNT(*) n FROM content WHERE id=?').get(a).n, 1, 'valid row survived the rejected batch');
});
test('batch delete rejects a malformed id (LIKE-injection guard)', async () => {
const res = await post('/content/batch/delete', { ids: ['not-a-uuid'] });
assert.equal(res.status, 400);
});
test('batch move reassigns folder for all ids', async () => {
const a = UUID(), b = UUID();
mkContent(a); mkContent(b);
const res = await post('/content/batch/move', { ids: [a, b], folder_id: 'f-batch' });
assert.equal(res.status, 200);
assert.equal(res.json.moved, 2);
for (const id of [a, b]) assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(id).folder_id, 'f-batch');
// move back to root
const res2 = await post('/content/batch/move', { ids: [a, b], folder_id: null });
assert.equal(res2.status, 200);
assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null);
});
test('batch move to a folder in another workspace is refused', async () => {
const a = UUID();
mkContent(a);
db.prepare("INSERT INTO content_folders (id, name, user_id, workspace_id) VALUES ('f-other', 'Other', 'u-batch', 'ws-other')").run();
const res = await post('/content/batch/move', { ids: [a], folder_id: 'f-other' });
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT folder_id FROM content WHERE id=?').get(a).folder_id, null, 'not moved');
});
test('empty / oversized batches are rejected', async () => {
assert.equal((await post('/content/batch/delete', { ids: [] })).status, 400);
assert.equal((await post('/content/batch/move', { ids: [], folder_id: null })).status, 400);
});