screentinker/server/test/content-replace-metadata.test.js
ScreenTinker 3e37d33b80 QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:12:29 -05:00

150 lines
7.4 KiB
JavaScript

'use strict';
/*
* PUT /api/content/:id/replace must re-derive everything the BYTES decide.
*
* The route carried its own shorter copy of the ingest logic that handled images only, so:
* - replacing a VIDEO left duration_sec at the OLD clip's length and nulled width/height.
* That is not cosmetic: lib/item-duration.js defaults a new playlist item to the content's
* duration, so after replacing a 32s clip with a 5s one, every later "add to playlist"
* scheduled 32 seconds of a 5-second video — 27s of frozen last frame on the screen.
* - replacing an IMAGE measured it with raw sharp metadata and thumbnailed without .rotate(),
* re-introducing the EXIF-orientation bug (#170) that ingest fixes: a portrait photo came
* back recorded as landscape.
*
* Driven over real HTTP against the real router, because the bug was in the route, not the lib.
*/
const os = require('node:os');
const path = require('node:path');
const fsp = require('node:fs/promises');
const fs = require('node:fs');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-replace-' + crypto.randomBytes(4).toString('hex'));
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 sharp = require('sharp');
const { db } = require('../db/database');
const config = require('../config');
const WS = 'ws-replace';
const USER = 'u-replace';
let server, base;
function hasFfmpeg() {
try {
const { execFileSync } = require('node:child_process');
execFileSync('ffprobe', ['-version'], { stdio: 'ignore' });
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch { return false; }
}
// Seed through the SHARED ingest lib (the same call POST /api/content makes) rather than over
// HTTP, so the fixture is a genuine first-class content row and the only thing this suite drives
// over the wire is the route under test.
const { ingestUploadedFile } = require('../lib/content-ingest');
async function upload(bytes, filename) {
const tmp = path.join(config.contentDir, crypto.randomUUID() + '.part');
await fsp.mkdir(config.contentDir, { recursive: true });
await fsp.writeFile(tmp, bytes);
return ingestUploadedFile({
file: { path: tmp, originalname: filename, size: bytes.length },
userId: USER, workspaceId: WS,
});
}
async function replace(id, bytes, filename, type) {
const fd = new FormData();
fd.append('file', new Blob([bytes], { type }), filename);
const r = await fetch(`${base}/${id}/replace`, { method: 'PUT', body: fd });
return { status: r.status, body: await r.json() };
}
before(async () => {
db.prepare("INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, 'platform_admin')").run(USER, 'replace@test', 'QA');
db.prepare('INSERT INTO organizations (id, name, owner_user_id) VALUES (?, ?, ?)').run('org-replace', 'Org', USER);
db.prepare('INSERT INTO workspaces (id, organization_id, name) VALUES (?, ?, ?)').run(WS, 'org-replace', 'WS');
const app = express();
app.use((req, _res, next) => {
req.workspaceId = WS;
req.user = { id: USER, role: 'platform_admin' };
next();
});
app.use('/', 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('replacing an image re-measures it — a landscape photo does not keep the old portrait dims', async () => {
const tall = await sharp({ create: { width: 40, height: 90, channels: 3, background: '#123456' } }).png().toBuffer();
const wide = await sharp({ create: { width: 120, height: 30, channels: 3, background: '#654321' } }).png().toBuffer();
const row = await upload(tall, 'tall.png', 'image/png');
assert.equal(row.width, 40);
assert.equal(row.height, 90);
const { status, body } = await replace(row.id, wide, 'wide.png', 'image/png');
assert.equal(status, 200);
assert.equal(body.width, 120, 'width comes from the NEW bytes');
assert.equal(body.height, 30, 'height comes from the NEW bytes');
assert.notEqual(body.filepath, row.filepath, 'a replace writes a new randomly-named file');
});
test('replacing an image honours EXIF orientation, the same way ingest does (#170)', async () => {
// orientation 6 = "rotate 90° CW to display": a 30x100 stored buffer DISPLAYS as 100x30.
// The old replace path read sharp's raw metadata and recorded 30x100 — the exact bug that
// put blue bars down portrait uploads before #172 fixed the ingest path.
const plain = await sharp({ create: { width: 10, height: 10, channels: 3, background: '#000' } }).jpeg().toBuffer();
const rotated = await sharp({ create: { width: 30, height: 100, channels: 3, background: '#00ff00' } })
.withMetadata({ orientation: 6 }).jpeg().toBuffer();
const row = await upload(plain, 'plain.jpg', 'image/jpeg');
const { status, body } = await replace(row.id, rotated, 'rotated.jpg', 'image/jpeg');
assert.equal(status, 200);
assert.equal(body.width, 100, 'EXIF-rotated image is measured as DISPLAYED, not as stored');
assert.equal(body.height, 30);
});
test('replacing an image regenerates its thumbnail file, rather than pointing at a deleted one', async () => {
const a = await sharp({ create: { width: 60, height: 60, channels: 3, background: '#ff0000' } }).png().toBuffer();
const b = await sharp({ create: { width: 80, height: 80, channels: 3, background: '#0000ff' } }).png().toBuffer();
const row = await upload(a, 'a.png', 'image/png');
const { body } = await replace(row.id, b, 'b.png', 'image/png');
assert.ok(body.thumbnail_path, 'a thumbnail is recorded');
assert.ok(fs.existsSync(path.join(config.contentDir, body.thumbnail_path)), 'and the file it names EXISTS');
});
test('replacing a video re-probes its duration — a stale one mis-defaults every later playlist add', { skip: hasFfmpeg() ? false : 'ffmpeg/ffprobe not installed' }, async () => {
const { execFileSync } = require('node:child_process');
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'st-vid-'));
const long = path.join(dir, 'long.mp4');
const short = path.join(dir, 'short.mp4');
const mk = (out, secs, size) => execFileSync('ffmpeg', ['-v', 'quiet', '-y', '-f', 'lavfi', '-i', `color=c=blue:s=${size}:d=${secs}`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-t', String(secs), out], { timeout: 60000 });
mk(long, 8, '320x240');
mk(short, 2, '240x320');
const row = await upload(await fsp.readFile(long), 'long.mp4', 'video/mp4');
assert.ok(row.duration_sec >= 7.5 && row.duration_sec <= 8.5, `seeded 8s clip probed as ${row.duration_sec}`);
const { status, body } = await replace(row.id, await fsp.readFile(short), 'short.mp4', 'video/mp4');
assert.equal(status, 200);
assert.ok(body.duration_sec >= 1.5 && body.duration_sec <= 2.5,
`duration follows the NEW bytes (got ${body.duration_sec}; the old code left 8)`);
assert.equal(body.width, 240, 'and the dimensions do too — they used to be nulled for video');
assert.equal(body.height, 320);
// The point of all of it: the shared duration default now describes the file that is there.
const { resolveItemDuration } = require('../lib/item-duration');
assert.equal(resolveItemDuration(undefined, body), 2,
'a playlist item added after the replace gets the NEW clip\'s length, not the old one\'s');
});