Release v2.19.0: indexed content references
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m11s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-24 19:50:12 +01:00
1 parent 24f1603b43
commit b59fe08298
31 files changed
+663 -237

No files matched your search

+30 -3
View File
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById } = require('../src/data/api-source-groups');
const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById, fetchApiGroupsPage } = require('../src/data/api-source-groups');
const { getPendingMigrations } = require('../src/db/migrations');
const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources');
@@ -20,8 +20,18 @@ function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
if (sql.includes('SELECT default_content_json FROM c_template_regions')) return [templateDefaults.map(value => ({ default_content_json: value }))];
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
if (sql.includes('FROM c_content_references')) {
const ids = new Set();
function walk(value) {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) return value.forEach(walk);
if (value.source_id !== undefined && value.source_id !== null && value.source_id !== '') ids.add(String(Number(value.source_id)));
Object.values(value).forEach(walk);
}
(slides || []).forEach(value => walk(JSON.parse(value)));
(templateDefaults || []).forEach(value => walk(typeof value === 'string' ? JSON.parse(value) : value));
return [[...ids].map(reference_value => ({ reference_value }))];
}
if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }];
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
return [{}];
@@ -30,6 +40,23 @@ function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
return { calls, async getConnection() { return connection; } };
}
test('API source list marks groups with indexed request usage', async () => {
const queries = [];
const pool = {
async query(sql) {
queries.push(sql);
if (sql.includes('SELECT COUNT(*) AS count FROM i_api_source_groups')) return [[{ count: 1 }]];
if (sql.includes('FROM i_api_source_groups auth')) return [[{ id: 4, name: 'Product Metrics', request_count: 1, inUse: 1 }]];
return [[]];
}
};
const result = await fetchApiGroupsPage(pool, 1, 25, '', '', '');
const listQuery = queries.find(sql => sql.includes('AS inUse'));
assert.ok(listQuery);
assert.match(listQuery, /c_content_references/);
assert.equal(result.apiSources[0].inUse, 1);
});
test('group payload supports public requests and independent request settings', () => {
const group = payload([{ id: 5, name: 'Event', api_url: 'https://example.com/event', update_interval_value: 2, update_interval_unit: 'hours' }, { name: 'Donors', api_url: 'https://example.com/donors', items_path: 'data', update_interval_value: 30, update_interval_unit: 'seconds', enabled: false }]);
assert.equal(group.auth.auth_method, 'none');
+5
View File
@@ -54,6 +54,11 @@ test('api source duplicate form model keeps info toast variant', () => {
assert.equal(model.showSaveSecondaryActions, true);
});
test('API source edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '../src/web/views/data-sources/api-sources/form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this API source\?"/);
});
test('API source form normalization is shared by add, edit, and duplication', () => {
const source = { id: 9, name: 'Tiltify', auth_method: 'token_login', token_url: 'https://example.com/token', token_response_path: 'data.token', token_header_prefix: '', update_interval_value: 30, update_interval_unit: 'seconds', enabled: 0 };
const add = buildApiSourceFormViewModel(source, '', null, {}, false).apiSource;
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { collectContentReferences, replaceContentReferences } = require('../src/data/content-references');
test('content reference extraction indexes typed sources, media paths, and fonts by owner region', () => {
const references = collectContentReferences('template', 42, {
brand: {
type: 'html',
value: '<img src="https://signage.example/media/uploads/brand.png"><span style="font-family: \'Acme Display\', sans-serif">Brand</span>',
font_family: 'Acme Display'
},
agenda: { type: 'timetable', timetable_group_id: 7 },
feed: { type: 'rss', feed_id: '8' },
forecast: { type: 'weather', weather_location_id: 9, weather_icon_set_id: 'night' },
api: { type: 'api', source_id: 10 }
});
const values = references.map(reference => [reference.owner_key, reference.reference_type, reference.reference_value]);
assert.deepEqual(values, [
['brand', 'media', '/media/uploads/brand.png'],
['brand', 'font-family', 'acme display'],
['agenda', 'timetable-group', '7'],
['feed', 'rss-feed', '8'],
['forecast', 'weather-location', '9'],
['forecast', 'weather-icon-set', 'night'],
['api', 'api-request', '10']
]);
assert.ok(references.every(reference => reference.owner_type === 'template' && reference.owner_id === 42 && Buffer.isBuffer(reference.reference_hash)));
});
test('content reference extraction deduplicates repeated references within a region', () => {
const references = collectContentReferences('slide', 12, {
hero: { value: '/media/uploads/a.png', secondary: '/media/uploads/a.png', source_id: 4, copy: { source_id: 4 } }
});
assert.deepEqual(references.map(reference => [reference.reference_type, reference.reference_value]), [
['media', '/media/uploads/a.png'],
['api-request', '4']
]);
});
test('content reference replacement preserves unchanged rows and only deletes or inserts the delta', async () => {
const calls = [];
const connection = {
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, owner_key')) {
return [[
{ id: 101, owner_key: 'hero', reference_type: 'media', reference_value: '/media/uploads/keep.png' },
{ id: 102, owner_key: 'api', reference_type: 'api-request', reference_value: '4' },
{ id: 103, owner_key: 'old', reference_type: 'font-family', reference_value: 'arial' }
]];
}
return [{}];
}
};
await replaceContentReferences(connection, 'slide', 22, {
hero: { value: '/media/uploads/keep.png' },
api: { source_id: 5 },
feed: { feed_id: 8 }
});
const deletion = calls.find(([sql]) => sql.startsWith('DELETE FROM c_content_references'));
assert.deepEqual(deletion[1], ['slide', 22, [102, 103]]);
const insertion = calls.find(([sql]) => sql.startsWith('INSERT IGNORE INTO c_content_references'));
assert.equal(insertion[1][0].length, 2);
assert.deepEqual(insertion[1][0].map(row => [row[3], row[4]]), [['api-request', '5'], ['rss-feed', '8']]);
assert.ok(!calls.some(([sql]) => sql === 'DELETE FROM c_content_references WHERE owner_type = ? AND owner_id = ?'));
});
+2 -1
View File
@@ -198,7 +198,8 @@ test('template default font references mark fonts in use and block deletion', as
registerFontRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('FROM c_template_regions')) return [[{ match_count: 1 }]];
if (sql.includes('FROM c_content_references') && sql.includes('COUNT(DISTINCT')) return [[{ owner_count: 1 }]];
if (sql.includes('FROM c_content_references')) return [[{ reference_value: 'alpha sans' }]];
return [[{ match_count: 0 }]];
}
},
+10 -6
View File
@@ -171,22 +171,26 @@ test('media library counts slide, template background, and region default refere
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.includes('FROM c_slides')) {
if (sql.includes('FROM c_content_references')) {
return [[{ owner_count: 3 }]];
}
if (sql.includes('thumbnail_path')) {
return [[{ ref_count: 2 }]];
}
if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]];
return [[{ ref_count: 3 }]];
return [[]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6);
assert.equal(queries.length, 3);
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
assert.deepEqual(queries[0].params, ['media', '/media/uploads/shared.png']);
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
assert.equal(queries[2].params[0], '/media/uploads/shared.png');
assert.match(queries[0].sql, /LOCATE/);
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
assert.match(queries[2].sql, /default_content_json/);
assert.match(queries[0].sql, /c_content_references/);
assert.match(queries[0].sql, /idx_content_reference_lookup|reference_type/);
assert.match(queries[1].sql, /background_image_path/);
assert.match(queries[2].sql, /thumbnail_path/);
});
test('media library upload and delete routes audit successful asset changes', async () => {
+6
View File
@@ -39,6 +39,12 @@ test('rss feed duplicate form model keeps info toast variant and secondary actio
assert.equal(model.showSaveSecondaryActions, true);
});
test('RSS feed edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this RSS feed\?"/);
});
test('rss feed list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'list.hbs'), 'utf8');
+40
View File
@@ -215,4 +215,44 @@ test('template region defaults migration upgrades a 2.17.5 installation', async
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN default_content_json JSON NULL')));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0')));
});
test('2.19.0 indexed-reference migration backfills slide and template defaults', async () => {
const queries = [];
const insertedReferences = [];
const pool = {
queries,
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('FROM information_schema.COLUMNS')) return [[{ column_count: 1 }]];
if (text.includes('FROM information_schema.TABLES')) return [[{ table_count: 0 }]];
if (text.startsWith('SELECT id, content_json FROM c_slides')) {
return Number(params[0]) === 0
? [[{ id: 2, content_json: JSON.stringify({ hero: { type: 'image', value: '/media/uploads/hero.png' } }) }]]
: [[]];
}
if (text.startsWith('SELECT id, template_id, region_key, default_content_json FROM c_template_regions')) {
return Number(params[0]) === 0
? [[{ id: 7, template_id: 4, region_key: 'headline', default_content_json: { type: 'text', font_family: 'Acme Display' } }]]
: [[]];
}
if (text.startsWith('INSERT IGNORE INTO c_content_references')) {
insertedReferences.push.apply(insertedReferences, params[0]);
}
return [[]];
}
};
const pending = await getPendingMigrations(pool, { currentVersion: '2.18.0' });
const migration = pending.find(item => item.version === '2.19.0');
assert.ok(migration);
await migration.run(pool);
assert.ok(queries.some(([sql]) => sql.includes('CREATE TABLE IF NOT EXISTS c_content_references')));
assert.ok(queries.some(([sql]) => sql.includes('id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY')));
assert.ok(queries.some(([sql]) => sql.includes('UNIQUE KEY uq_content_reference_owner')));
assert.ok(queries.every(([sql]) => !sql.includes('idx_content_reference_owner')));
assert.ok(insertedReferences.some(row => row[0] === 'slide' && row[1] === 2 && row[3] === 'media' && row[4] === '/media/uploads/hero.png'));
assert.ok(insertedReferences.some(row => row[0] === 'template' && row[1] === 4 && row[2] === 'headline' && row[3] === 'font-family' && row[4] === 'acme display'));
});
+16 -3
View File
@@ -5,12 +5,25 @@ const { getDataSourceUsageMaps: getRssUsage } = require('../src/web/routes/data-
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
const { collectContentReferences } = require('../src/data/content-references');
function createPool(slideContent, templateContent) {
const references = collectContentReferences('slide', 1, { slide: slideContent })
.concat(collectContentReferences('template', 2, { region: templateContent }));
return {
async query(sql) {
if (sql.includes('FROM c_slides')) return [[{ content_json: JSON.stringify(slideContent) }]];
return [[{ default_content_json: JSON.stringify(templateContent) }]];
async query(sql, params) {
if (!sql.includes('FROM c_content_references')) throw new Error('Unexpected query: ' + sql);
const referenceType = params[0];
const matching = references.filter(function (reference) {
return reference.reference_type === referenceType && (params.length < 2 || reference.reference_value === String(params[1]));
});
if (sql.includes('COUNT(DISTINCT')) return [[{ owner_count: matching.length ? 1 : 0 }]];
if (sql.includes('GROUP BY reference_value')) {
const counts = new Map();
matching.forEach(function (reference) { counts.set(reference.reference_value, (counts.get(reference.reference_value) || 0) + 1); });
return [Array.from(counts, ([reference_value, owner_count]) => ({ reference_value, owner_count }))];
}
return [Array.from(new Set(matching.map(reference => reference.reference_value)), reference_value => ({ reference_value }))];
}
};
}
+6
View File
@@ -47,3 +47,9 @@ test('weather list template includes duplicate action', () => {
assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('weather edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this weather location\?"/);
});
+44 -8
View File
@@ -7,6 +7,7 @@ const sharp = require('sharp');
require('../src/common');
const library = require('../src/web/lib/media/weather-icon-library');
const weatherIcons = require('../src/web/public/js/shared/weather-icons');
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
const vm = require('node:vm');
const placeholderContext = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), placeholderContext);
@@ -206,15 +207,50 @@ test('weather sets require every condition image and support replacement', async
}
});
test('weather icon usage recognizes one set reference', function () {
const content = { weather: { weather_icon_set_id: 'set' } };
const isUsed = function (setId) {
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === setId;
});
test('weather icon usage guard checks the indexed reference value', async function () {
const pool = { async query(_sql, params) { return [[{ owner_count: params[1] === 'set' ? 1 : 0 }]]; } };
assert.equal(await isWeatherIconSetUsed(pool, 'set'), true);
assert.equal(await isWeatherIconSetUsed(pool, 'other'), false);
});
test('weather icon sync refreshes slides using direct and template-default references once', async function () {
const originalSetWeatherIconSetEnabled = library.setWeatherIconSetEnabled;
const originalCollectWeatherIconSyncOperations = library.collectWeatherIconSyncOperations;
const handlers = {};
const tasks = [];
const app = {
get(route, ...callbacks) { handlers['GET ' + route] = callbacks; },
post(route, ...callbacks) { handlers['POST ' + route] = callbacks; }
};
assert.equal(isUsed('set'), true);
assert.equal(isUsed('other'), false);
library.setWeatherIconSetEnabled = function (_root, id, enabled) { return { id: id, name: 'Set', enabled: enabled }; };
library.collectWeatherIconSyncOperations = function () { return []; };
try {
require('../src/web/routes/settings/weather-icons')(app, {
mediaDir: 'unused',
pool: {
async query(sql) {
if (sql.includes('FROM d_players')) return [[{ id: 1, identifier: 'player-1' }]];
if (sql.includes('SELECT DISTINCT owner_type, owner_id')) return [[
{ owner_type: 'slide', owner_id: 22 },
{ owner_type: 'template', owner_id: 7 }
]];
if (sql.includes('FROM c_slides WHERE template_id IN')) return [[{ id: 23 }, { id: 22 }]];
return [[]];
}
},
backgroundTaskQueue: { async enqueueTask(task) { tasks.push(task); } },
requirePermission() { return function (_req, _res, next) { next(); }; }
});
const response = { redirect() { return this; } };
await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)(
{ body: { enabled: '0' }, params: { id: 'set' } }, response, function (error) { if (error) throw error; }
);
assert.deepEqual(tasks.filter(function (task) { return task.taskType === 'slide-thumbnail-refresh'; }).map(function (task) { return task.payload.slideId; }).sort(), [22, 23]);
} finally {
library.setWeatherIconSetEnabled = originalSetWeatherIconSetEnabled;
library.collectWeatherIconSyncOperations = originalCollectWeatherIconSyncOperations;
}
});
test('weather icon set create, update, toggle, and delete are audited', async function () {