screentinker/server/test/device-zone-contract.test.js
ScreenTinker a36880b147 fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
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>
2026-06-22 23:16:29 -05:00

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