mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and regression tests guarding the data contracts. Bug 1 — per-item mute was a no-op end to end: - GET /api/devices/:id dropped the `muted` column from its assignments SELECT, so the dashboard toggle never reflected state (the muted=false case in particular). Column restored to the device payload. - Android player now honours the per-item mute flag for YouTube (initial state + live via the IFrame JS API). Bug 2 — items whose zone_id belongs to a different layout were silently dropped: - Player fallback (web + Android): an orphaned zone_id is recovered into the largest zone instead of vanishing, with telemetry. - server/lib/zone-validate.js is the single source of truth for the orphan rule (zone not in the device's active layout); used by the device payload (per-item `orphan` flag + `active_layout_zones`) and the device list (`orphan_count`). - Assign-time hardening: a stale zone_id (not in the device's active layout) is cleared to null on POST/PUT rather than persisted as a new orphan. - scripts/find-orphan-zone-items.js: read-only sweep for existing orphans. Dashboard warnings (operator-facing, never on the live player): - Per-item badge + reassign affordance, device-list glance, preview banner. - Graceful degradation: the zone selector falls back to /api/layouts/:id so it can't vanish on a stale payload. i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design; count strings interpolate through tn()). Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the data contracts above (muted true/false round-trip, active_layout_zones, orphan flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
9 KiB
JavaScript
167 lines
9 KiB
JavaScript
'use strict';
|
|
|
|
// Regression tests for the SERVER-SIDE data contracts added by the mute + zone-orphan
|
|
// branch. These guard the exact bugs we fixed so they can't silently come back:
|
|
// 1. GET /api/devices/:id must carry each item's `muted` — and BOTH true and false
|
|
// (the bug was a SELECT that dropped the column; the false case is the one that broke).
|
|
// 2. GET /api/devices/:id must return `active_layout_zones` for a multi-zone device
|
|
// (the contract the dashboard zone-selector now depends on).
|
|
// 3. The single-source orphan rule (lib/zone-validate): a zone in the active layout is
|
|
// NOT orphaned; a zone from a DIFFERENT layout IS — surfaced as the per-item `orphan`
|
|
// flag and the device-list `orphan_count`.
|
|
// 4. Reassigning an orphan to a valid zone drops `orphan_count` to 0.
|
|
// 5. Assign-time hardening: a zone_id not in the device's active layout is cleared to
|
|
// null on POST; a valid one is kept.
|
|
//
|
|
// Mirrors mute.test.js: boots the real server.js against an isolated DB and seeds rows on
|
|
// one connection (FK off) to avoid WAL visibility races. No player/DOM/Playwright tests.
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const PORT = 3996;
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-zone-test-' + crypto.randomBytes(4).toString('hex'));
|
|
const LOG = path.join(os.tmpdir(), 'st-zone-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
const PW = 'Passw0rd123';
|
|
let proc, db;
|
|
const S = {};
|
|
|
|
async function jfetch(p, opts = {}) {
|
|
const res = await fetch(BASE + p, opts);
|
|
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
|
|
return { status: res.status, body };
|
|
}
|
|
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } });
|
|
const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) });
|
|
const put = (tok, obj) => ({ method: 'PUT', ...auth(tok), body: JSON.stringify(obj || {}) });
|
|
|
|
// Find one item in the device payload by playlist_item id (ids are integers; coerce both).
|
|
async function getAssignment(itemId) {
|
|
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
|
return (r.body.assignments || []).find((a) => Number(a.id) === Number(itemId));
|
|
}
|
|
// Read the device's orphan_count off the workspace device list.
|
|
async function getOrphanCount() {
|
|
const r = await jfetch('/api/devices', auth(S.jwt));
|
|
const d = (r.body || []).find((x) => x.id === S.deviceId);
|
|
return d ? d.orphan_count : undefined;
|
|
}
|
|
|
|
before(async () => {
|
|
const logFd = fs.openSync(LOG, 'w');
|
|
proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) {
|
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
|
|
// First user -> platform_admin; register returns the JWT, the user, and the workspace.
|
|
const reg = await jfetch('/api/auth/register', post(null, { email: 'z' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW }));
|
|
S.jwt = reg.body.token;
|
|
S.userId = reg.body.user.id;
|
|
S.wsA = reg.body.current_workspace_id;
|
|
|
|
// Active multi-zone layout L1 (Main, Side) + a DIFFERENT layout L2 (Other) — via the API
|
|
// so zone ids are real and workspace-scoped. L2's zone is the "different layout" orphan.
|
|
const l1 = await jfetch('/api/layouts', post(S.jwt, { name: 'L1', zones: [{ name: 'Main', width_percent: 60, height_percent: 100 }, { name: 'Side', width_percent: 40, height_percent: 100 }] }));
|
|
S.L1 = l1.body.id; S.Z1 = l1.body.zones[0].id; S.Z2 = l1.body.zones[1].id;
|
|
const l2 = await jfetch('/api/layouts', post(S.jwt, { name: 'L2', zones: [{ name: 'Other', width_percent: 100, height_percent: 100 }] }));
|
|
S.ZX = l2.body.zones[0].id;
|
|
|
|
const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'zone-pl' }));
|
|
S.playlistId = pl.body.id;
|
|
|
|
// Seed content, a device, and playlist_items on one connection (FK off). The orphan item
|
|
// is seeded DIRECTLY so it bypasses assign-time validation — that's how real orphans
|
|
// arise (assigned under a different layout / layout switched after the fact).
|
|
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
|
|
db.pragma('foreign_keys = OFF');
|
|
const mkContent = (name) => {
|
|
const id = crypto.randomUUID();
|
|
db.prepare("INSERT INTO content (id, filename, filepath, mime_type, file_size, remote_url) VALUES (?,?,?,?,0,?)")
|
|
.run(id, name, '', 'image/png', 'https://example.com/' + name + '.png');
|
|
return id;
|
|
};
|
|
S.cMute = mkContent('mute'); S.cValid = mkContent('valid'); S.cOrphan = mkContent('orphan');
|
|
S.cPostStale = mkContent('post-stale'); S.cPostOk = mkContent('post-ok');
|
|
|
|
S.deviceId = crypto.randomUUID();
|
|
db.prepare("INSERT INTO devices (id, name, status, workspace_id, user_id, layout_id, playlist_id) VALUES (?,?,?,?,?,?,?)")
|
|
.run(S.deviceId, 'ZoneDev', 'online', S.wsA, S.userId, S.L1, S.playlistId);
|
|
|
|
const addItem = (contentId, zoneId, sort) =>
|
|
db.prepare("INSERT INTO playlist_items (playlist_id, content_id, zone_id, sort_order, duration_sec, muted) VALUES (?,?,?,?,10,0)")
|
|
.run(S.playlistId, contentId, zoneId, sort).lastInsertRowid;
|
|
S.itemMute = addItem(S.cMute, null, 0); // no zone — for the mute round-trip
|
|
S.itemValid = addItem(S.cValid, S.Z1, 1); // zone in the active layout -> NOT orphan
|
|
S.itemOrphan = addItem(S.cOrphan, S.ZX, 2); // zone from L2 -> orphan
|
|
});
|
|
|
|
after(async () => {
|
|
try { db?.close(); } catch { /* */ }
|
|
if (proc) proc.kill('SIGKILL');
|
|
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
|
|
});
|
|
|
|
// 1. muted must round-trip through the device payload SELECT — both states.
|
|
test('GET /api/devices/:id carries per-item muted (true AND false)', async () => {
|
|
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: true }));
|
|
let a = await getAssignment(S.itemMute);
|
|
assert.ok(a, 'item appears in the device payload');
|
|
assert.equal(a.muted, 1, 'muted=true survives the GET /api/devices/:id SELECT');
|
|
|
|
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: false }));
|
|
a = await getAssignment(S.itemMute);
|
|
assert.equal(a.muted, 0, 'muted=false survives too (the case that originally broke)');
|
|
});
|
|
|
|
// 2. active_layout_zones contract for a multi-zone device.
|
|
test('GET /api/devices/:id returns active_layout_zones for a multi-zone device', async () => {
|
|
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
|
const zones = r.body.active_layout_zones;
|
|
assert.ok(Array.isArray(zones), 'active_layout_zones is present');
|
|
assert.equal(zones.length, 2, 'both zones of the active layout are returned');
|
|
assert.deepEqual(zones.map((z) => z.id).sort(), [S.Z1, S.Z2].sort(), 'exactly the active-layout zone ids');
|
|
});
|
|
|
|
// 3. orphan definition: in-layout zone -> not orphan; different-layout zone -> orphan.
|
|
test('orphan flag + orphan_count reflect the single-source rule', async () => {
|
|
const valid = await getAssignment(S.itemValid);
|
|
const orphan = await getAssignment(S.itemOrphan);
|
|
assert.equal(valid.orphan, false, 'a zone in the active layout is NOT orphaned');
|
|
assert.equal(orphan.orphan, true, 'a zone from a different layout IS orphaned');
|
|
assert.equal(await getOrphanCount(), 1, 'device list orphan_count counts exactly the one orphan');
|
|
});
|
|
|
|
// 4. reassigning the orphan to a valid zone clears the count.
|
|
test('reassigning an orphan to a valid zone clears orphan_count', async () => {
|
|
assert.equal(await getOrphanCount(), 1, 'precondition: one orphan');
|
|
const r = await jfetch(`/api/assignments/${S.itemOrphan}`, put(S.jwt, { zone_id: S.Z1 }));
|
|
assert.equal(r.body.zone_id, S.Z1, 'reassignment to a valid zone persists');
|
|
assert.equal(await getOrphanCount(), 0, 'orphan_count drops to 0 after reassign');
|
|
});
|
|
|
|
// 5. assign-time hardening: stale zone_id cleared, valid kept.
|
|
test('POST assignment clears a stale zone_id and keeps a valid one', async () => {
|
|
const stale = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostStale, zone_id: S.ZX, duration_sec: 10 }));
|
|
assert.equal(stale.status, 201);
|
|
assert.equal(stale.body.zone_id, null, 'a zone_id from a different layout is cleared to null on add');
|
|
|
|
const ok = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostOk, zone_id: S.Z2, duration_sec: 10 }));
|
|
assert.equal(ok.status, 201);
|
|
assert.equal(ok.body.zone_id, S.Z2, 'a zone_id in the active layout is kept');
|
|
});
|