Release v2.19.0: indexed content references
This commit is contained in:
1 parent
24f1603b43
commit
b59fe08298
31 files changed
+663
-237
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ?'));
|
||||
});
|
||||
@@ -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 }]];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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 }))];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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\?"/);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user