screentinker/server/test/content-batch-ops.test.js
screentinker 8b661a7347
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>
2026-07-23 12:29:44 -05:00

125 lines
5.9 KiB
JavaScript

'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);
});