mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns, layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled with that filename, as confirmation that it would. Rather than thread a third override type through the engine and every player, the schedule now gets a playlist containing that one item. That is the shape the entire pipeline already understands: publish, assign, push, snapshot, offline cache and all four players work on it unchanged. It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot, because players read denormalized fields out of published_snapshot (filename, mime_type, filepath, remote_url, per-item schedules) and a second copy of that shape here would rot the first time it changed. An explicit playlist override still wins and no throwaway playlist is created; a schedule with neither content nor playlist is untouched. 5 tests covering all of those, including that the generated playlist lands in the right workspace and that its snapshot carries the fields the players need rather than just the id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
103 lines
5.5 KiB
JavaScript
103 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
// The schedule dialog offers "Content (single item, optional)". The value was validated for
|
|
// cross-tenancy and stored faithfully — and then read by nothing at all. services/scheduler.js acts
|
|
// on exactly two columns:
|
|
//
|
|
// if (active.layout_id && ...) { ...apply... }
|
|
// if (active.playlist_id && ...) { ...apply... }
|
|
//
|
|
// content_id, widget_id and zone_id are consulted nowhere. So a content-only schedule was a
|
|
// complete no-op, while the calendar drew a block labelled with the filename as confirmation that
|
|
// it would fire.
|
|
//
|
|
// Rather than thread a third override through the engine and every player, the schedule now gets a
|
|
// playlist holding that one item — the shape the whole pipeline already understands.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-schedcontent-'));
|
|
process.env.DATA_DIR = tmp;
|
|
process.env.JWT_SECRET = 'test-secret-sched-content';
|
|
|
|
const express = require('express');
|
|
const { db } = require('../db/database');
|
|
const { requireAuth, generateToken } = require('../middleware/auth');
|
|
const { resolveTenancy } = require('../lib/tenancy');
|
|
|
|
const O = 'o-sc', WS = 'ws-sc', U = 'u-sc', DEV = 'dev-sc', C = 'c-sc';
|
|
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sc@t.local');
|
|
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
|
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
|
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
|
|
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
|
|
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
|
|
db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES (?,?,?,'promo.jpg','promo.jpg','image/jpeg',1234)").run(C, WS, U);
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.set('io', null);
|
|
app.use('/api/schedules', requireAuth, resolveTenancy, require('../routes/schedules'));
|
|
const server = app.listen(0);
|
|
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
|
|
|
|
async function createSchedule(body) {
|
|
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
|
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/schedules`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => null) };
|
|
}
|
|
|
|
const BASE = { device_id: DEV, title: 'Promo hour', start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' };
|
|
|
|
test('THE BUG: a content-only schedule now has something the engine can act on', async () => {
|
|
const { status } = await createSchedule({ ...BASE, content_id: C });
|
|
assert.equal(status, 201); // created
|
|
const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
|
assert.ok(s.playlist_id, 'the engine reads playlist_id; without one the schedule did nothing');
|
|
});
|
|
|
|
test('the generated playlist contains exactly that item, published', () => {
|
|
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
|
const pl = db.prepare('SELECT * FROM playlists WHERE id = ?').get(s.playlist_id);
|
|
assert.equal(pl.workspace_id, WS, 'and it belongs to the right workspace');
|
|
assert.equal(pl.status, 'published');
|
|
const items = db.prepare('SELECT content_id FROM playlist_items WHERE playlist_id = ?').all(s.playlist_id);
|
|
assert.deepEqual(items.map(i => i.content_id), [C]);
|
|
});
|
|
|
|
test('the snapshot the players read is populated', () => {
|
|
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
|
const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(s.playlist_id);
|
|
const snap = JSON.parse(pl.published_snapshot || '[]');
|
|
assert.equal(snap.length, 1);
|
|
assert.equal(snap[0].content_id, C);
|
|
assert.ok(snap[0].filename, 'players need the denormalized fields, not just the id');
|
|
});
|
|
|
|
test('an explicit playlist override still wins and no playlist is invented', async () => {
|
|
db.prepare("INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES ('pl-explicit','Mine',?,?)").run(WS, U);
|
|
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
|
|
await createSchedule({ ...BASE, content_id: C, playlist_id: 'pl-explicit' });
|
|
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
|
assert.equal(s.playlist_id, 'pl-explicit');
|
|
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before, 'no throwaway playlist created');
|
|
});
|
|
|
|
test('a schedule with neither content nor playlist is unchanged', async () => {
|
|
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
|
|
await createSchedule({ ...BASE, title: 'Layout only' });
|
|
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
|
assert.equal(s.playlist_id, null);
|
|
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before);
|
|
});
|
|
|
|
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|