mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
150 lines
7.4 KiB
JavaScript
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');
|
|
});
|