From 8b661a73476165eaa3043ff68a25d826a0b78d6e Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 23 Jul 2026 12:29:44 -0500 Subject: [PATCH] =?UTF-8?q?feat(content):=20batch=20operations=20=E2=80=94?= =?UTF-8?q?=20multi-select,=20batch=20delete,=20batch=20move=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/js/api.js | 9 ++ frontend/js/i18n/en.js | 8 ++ frontend/js/i18n/es.js | 8 ++ frontend/js/views/content-library.js | 110 ++++++++++++++- server/routes/content.js | 189 ++++++++++++++++++-------- server/test/content-batch-ops.test.js | 124 +++++++++++++++++ 6 files changed, 390 insertions(+), 58 deletions(-) create mode 100644 server/test/content-batch-ops.test.js diff --git a/frontend/js/api.js b/frontend/js/api.js index 6a11666..ae1e073 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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'), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index b165a93..5c5eb63 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -197,6 +197,14 @@ export default { 'content.sort_name': 'Name A–Z', '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', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index cc96694..0bd48d0 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -160,6 +160,14 @@ export default { 'content.sort_name': 'Nombre A–Z', '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', diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 87f576b..3725377 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -117,6 +117,7 @@ export function render(container) {
+

${t('common.loading')}

@@ -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 ` -
+
+
${c.mime_type === 'video/youtube' ? `
@@ -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 = `

${t('content.failed_to_load')}

${esc(err.message)}

`; } } +// #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 = ` + ${t('content.batch_selected', { count })} + +
+ + +
+ `; + + 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'; diff --git a/server/routes/content.js b/server/routes/content.js index b78dde9..50b018b 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -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; diff --git a/server/test/content-batch-ops.test.js b/server/test/content-batch-ops.test.js new file mode 100644 index 0000000..b84482f --- /dev/null +++ b/server/test/content-batch-ops.test.js @@ -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); +});