'use strict'; // Choosing a sync protocol from the dashboard has one failure mode that matters: a value the // resolver does not recognise is read as 'auto'. So a typo — or a client sending a stale/renamed // value — would store fine, return 200, and leave the UI showing a protocol the group is not // running. The operator's only clue would be a wall that is subtly out of step. // // The other half is reporting. When the request cannot be honoured (native sync on a mixed fleet) // the group must still be told what it will ACTUALLY run and why, or the setting silently means // something different from what it says. 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-sbapi-')); process.env.DATA_DIR = tmp; process.env.JWT_SECRET = 'test-secret-sync-backend-api'; const express = require('express'); const { db } = require('../db/database'); const { requireAuth, generateToken } = require('../middleware/auth'); const { resolveTenancy } = require('../lib/tenancy'); const O = 'o-sa', WS = 'ws-sa', U = 'u-sa', PL = 'pl-sa', G = 'g-sa'; db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sa@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 playlists (id,user_id,name,workspace_id) VALUES (?,?,?,?)').run(PL, U, 'Shared', WS); db.prepare(`INSERT OR IGNORE INTO device_groups (id,name,user_id,workspace_id,playlist_id,sync_enabled) VALUES (?,?,?,?,?,1)`).run(G, 'Group', U, WS, PL); // A mixed group: one BrightSign, one Android. Native sync cannot include the Android one. for (const [id, platform] of [['d-sa-bs', 'brightsign'], ['d-sa-and', 'Android 12']]) { db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,playlist_id,status,platform,ip_address,created_at,updated_at) VALUES (?,?,?,?, 'online',?, '10.0.0.1', strftime('%s','now'),strftime('%s','now'))`) .run(id, id, WS, PL, platform); db.prepare('INSERT OR IGNORE INTO device_group_members (group_id,device_id) VALUES (?,?)').run(G, id); } const app = express(); app.use(express.json()); app.set('io', null); app.use('/api/groups', requireAuth, resolveTenancy, require('../routes/device-groups')); const server = app.listen(0); const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS); async function put(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/groups/${G}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return { status: res.status, body: await res.json().catch(() => null) }; } test('THE TYPO: an unrecognised backend is refused, not stored as a silent "auto"', async () => { const { status } = await put({ sync_backend: 'brightsigne' }); assert.equal(status, 400, 'storing it would show the operator a protocol the group is not running'); const stored = db.prepare('SELECT sync_backend FROM device_groups WHERE id = ?').get(G).sync_backend; assert.equal(stored, 'auto', 'the rejected value must not have been written'); }); test('a refused request is saved but reported with what will actually run, and why', async () => { const { status, body } = await put({ sync_backend: 'brightsign' }); assert.equal(status, 200); assert.equal(body.sync_backend, 'brightsign', 'the operator\'s choice is remembered'); assert.equal(body.sync_effective, 'screentinker', 'but this is what the screens will run'); assert.equal(body.sync_downgraded, true); assert.match(body.sync_reason, /non-BrightSign/); }); test('the group list carries the same decision, so the UI never disagrees with the players', async () => { await new Promise(r => (server.listening ? r() : server.once('listening', r))); const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups`, { headers: { Authorization: `Bearer ${token}` }, }); const groups = await res.json(); const g = groups.find(x => x.id === G); assert.equal(g.sync_effective, 'screentinker'); assert.equal(g.sync_downgraded, true); }); test('every accepted value round-trips', async () => { for (const v of ['auto', 'screentinker', 'brightsign']) { const { status, body } = await put({ sync_backend: v }); assert.equal(status, 200, v); assert.equal(body.sync_backend, v); } }); test.after(() => server.close());