Release 2.16.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-21 00:39:51 +01:00
1 parent 3514d514a3
commit 8ce8228091
25 files changed
+1307 -613

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildApiGroupPayload, saveApiGroup } = require('../src/data/api-source-groups');
const { getPendingMigrations } = require('../src/db/migrations');
const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources');
function payload(requests, auth) {
return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth));
}
function createPool(existing, slides) {
const calls = [];
const connection = {
async beginTransaction() { calls.push(['begin']); },
async commit() { calls.push(['commit']); },
async rollback() { calls.push(['rollback']); },
release() { calls.push(['release']); },
async query(sql, params) {
calls.push([sql, params]);
if (sql.includes('SELECT id FROM i_api_source_groups WHERE id')) return [[{ id: 5 }]];
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 content_json')) return [(slides || []).map(value => ({ content_json: 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 [{}];
}
};
return { calls, async getConnection() { return connection; } };
}
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');
assert.equal(group.requests[0].id, 5);
assert.equal(group.requests[0].updateIntervalUnit, 'hours');
assert.equal(group.requests[1].itemsPath, 'data');
assert.equal(group.requests[1].enabled, 0);
assert.throws(() => payload([]), /between 1 and 50/);
assert.throws(() => payload([{ name: 'Event', api_url: 'https://example.com' }, { name: 'event', api_url: 'https://example.com' }]), /unique name/);
assert.throws(() => payload([{ id: 5, name: 'Event', api_url: 'https://example.com' }, { id: 5, name: 'Donors', api_url: 'https://example.com' }]), /repeated/);
});
test('request-level auth cannot override group authentication', () => {
const group = payload([{ name: 'Event', api_url: 'https://example.com', auth_method: 'none' }, { name: 'Donors', api_url: 'https://example.com', auth_bearer_token: 'wrong-token' }], { auth_method: 'bearer', auth_bearer_token: 'shared-token' });
assert.ok(group.requests.every(request => request.authBearerToken === 'shared-token'));
});
test('saving a group preserves request IDs and does not overwrite response snapshots', async () => {
const pool = createPool([5]);
const result = await saveApiGroup(pool, 5, payload([{ id: 5, name: 'Event', api_url: 'https://example.com/event' }, { name: 'Donors', api_url: 'https://example.com/donors' }]), 1, true);
assert.deepEqual(result.requests.map(request => request.id), [5, 99]);
assert.equal(result.id, 5);
const update = pool.calls.find(([sql]) => sql.startsWith('UPDATE i_api_sources'));
assert.ok(update);
assert.doesNotMatch(update[0], /last_response|auth_password|auth_bearer_token/);
assert.deepEqual(update[1].slice(-2), [5, 5]);
assert.ok(pool.calls.some(([sql]) => sql === 'commit'));
});
test('request ownership, slide usage, and delete permission are enforced before writes', async () => {
const request = { id: 5, name: 'Event', api_url: 'https://example.com/event' };
await assert.rejects(saveApiGroup(createPool([5]), 5, payload([{ ...request, id: 6 }]), 1, true), /does not belong/);
const used = createPool([5, 6], [JSON.stringify({ region: { type: 'api', source_id: 6 } })]);
await assert.rejects(saveApiGroup(used, 5, payload([request]), 1, true), /still used/);
assert.ok(!used.calls.some(([sql]) => sql.startsWith('UPDATE')));
await assert.rejects(saveApiGroup(createPool([5, 6]), 5, payload([request]), 1, false), /permission/);
const unused = createPool([5, 6]);
const result = await saveApiGroup(unused, 5, payload([request]), 1, true);
assert.deepEqual(result.removed, [6]);
assert.ok(unused.calls.some(([sql]) => sql.startsWith('DELETE FROM i_api_sources')));
});
test('2.16.1 migration preserves source IDs and backfills parent authentication', async () => {
const calls = [];
const pool = { async query(sql, params) {
calls.push([sql, params]);
if (sql.includes('information_schema.COLUMNS')) return [[{ column_count: 1 }]];
if (sql.includes('information_schema.TABLES')) return [[{ table_count: 0 }]];
if (sql.includes('information_schema.KEY_COLUMN_USAGE')) return [[{ fk_count: 1 }]];
return [[]];
} };
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.0' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.16.1');
await pending[0].run(pool);
assert.ok(calls.some(([sql]) => sql.includes('INSERT IGNORE INTO i_api_source_groups (id,') && sql.includes('SELECT id,')));
assert.ok(calls.some(([sql]) => sql.includes('SET auth_source_id = id WHERE auth_source_id IS NULL')));
assert.ok(!calls.some(([sql]) => /UPDATE i_api_sources SET id\s*=/.test(sql)));
});
test('editor/player API lists exclude credentials while refresh lookup resolves group authentication', async () => {
const queries = [];
const pool = { async query(sql) { queries.push(sql); return [[]]; } };
await fetchApiSourcesData(pool);
await fetchApiSourceById(pool, 5);
assert.doesNotMatch(queries[0], /auth_password|token_request_body_json|request\.\*/);
assert.match(queries[0], /group_name/);
assert.match(queries[1], /auth\.auth_password/);
assert.match(queries[1], /WHERE request.id = \?/);
});
test('authentication-only tests never call a data endpoint', async () => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async function (url) {
calls.push(url);
return { status: 200, ok: true, headers: { get: () => 'application/json' }, text: async () => '{"access_token":"private-token"}' };
};
try {
const result = await testApiSourceAuthentication({ auth_method: 'token_login', token_url: 'https://example.com/token', token_request_body_json: '{"secret":"private-secret"}', api_url: 'https://example.com/donors' });
assert.equal(result.ok, true);
assert.deepEqual(calls, ['https://example.com/token']);
assert.doesNotMatch(JSON.stringify(result), /private-token|private-secret/);
const publicAuth = await testApiSourceAuthentication({ auth_method: 'none' });
assert.equal(publicAuth.ok, true);
assert.equal(calls.length, 1);
} finally { global.fetch = originalFetch; }
});
+305 -1
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchApiSourceResponse, buildApiSourcePayload } = require('../src/data/api-sources');
const { fetchApiSourceResponse, buildApiSourcePayload, testApiSourceConnection } = require('../src/data/api-sources');
function jsonResponse(status, body) {
return {
@@ -12,6 +12,118 @@ function jsonResponse(status, body) {
};
}
test('requests in one API group share a token while keeping separate responses', async () => {
const originalFetch = global.fetch;
let logins = 0;
global.fetch = async function (url, options) {
if (url.endsWith('/login')) {
logins += 1;
return jsonResponse(200, { access_token: 'group-token', expires_in: 600 });
}
assert.equal(options.headers.Authorization, 'Bearer group-token');
return jsonResponse(200, { request: url });
};
const auth = { auth_source_id: 876543, auth_method: 'token_login', token_url: 'https://example.com/login', token_request_body_json: '{"client_secret":"group-secret"}' };
try {
const [event, donors] = await Promise.all([
fetchApiSourceResponse({ ...auth, id: 876544, api_url: 'https://example.com/event' }),
fetchApiSourceResponse({ ...auth, id: 876545, api_url: 'https://example.com/donors' })
]);
assert.equal(logins, 1);
assert.equal(JSON.parse(event.responseJson).request, 'https://example.com/event');
assert.equal(JSON.parse(donors.responseJson).request, 'https://example.com/donors');
await testApiSourceConnection({ ...auth, id: 876544, api_url: 'https://example.com/event' });
await fetchApiSourceResponse({ ...auth, id: 876545, api_url: 'https://example.com/donors' });
assert.equal(logins, 2);
} finally { global.fetch = originalFetch; }
});
test('connection test identifies a token endpoint 404 and redacts credentials', async () => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async function (url) {
calls.push(url);
return jsonResponse(404, { error: 'not_found', detail: 'No route for private-client-secret', access_token: 'server-token' });
};
try {
const result = await testApiSourceConnection({
api_url: 'https://example.com/campaign', auth_method: 'token_login',
token_url: 'https://example.com/wrong-token-url?key=hidden-query',
token_request_body_json: '{"client_secret":"private-client-secret"}'
});
assert.equal(result.ok, false);
assert.equal(result.steps.length, 1);
assert.equal(result.steps[0].stage, 'Authentication');
assert.equal(result.steps[0].status, 404);
assert.match(result.error, /Check the endpoint URL and API version/);
assert.match(result.error, /https:\/\/example.com\/wrong-token-url/);
assert.match(result.error, /not_found/);
assert.equal(calls.length, 1);
assert.doesNotMatch(JSON.stringify(result), /private-client-secret|server-token|hidden-query/);
} finally { global.fetch = originalFetch; }
});
test('token diagnostics preserve null/empty refresh tokens and OAuth metadata but mask real tokens', async () => {
const originalFetch = global.fetch;
let refreshToken = null;
global.fetch = async function (url) {
if (url.endsWith('/login')) return jsonResponse(200, {
scope: 'public', access_token: 'private-access-token', refresh_token: refreshToken,
expires_in: 7200, token_type: 'bearer', created_at: '2026-10-20T22:04:47Z'
});
return jsonResponse(200, { data: [] });
};
const source = {
api_url: 'https://example.com/data', auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: '{"client_secret":"private-client-secret","grant_type":"client_credentials","scope":"public"}'
};
try {
for (const value of [null, '', 'private-refresh-token']) {
refreshToken = value;
const result = await testApiSourceConnection(source);
assert.equal(result.ok, true);
const response = JSON.parse(result.steps[0].response);
assert.equal(response.scope, 'public');
assert.equal(response.token_type, 'bearer');
assert.equal(response.expires_in, 7200);
assert.equal(response.refresh_token, value ? '[REDACTED]' : value);
assert.equal(response.access_token, '[REDACTED]');
assert.doesNotMatch(JSON.stringify(result), /private-access-token|private-refresh-token|private-client-secret/);
}
} finally { global.fetch = originalFetch; }
});
test('connection tests use fresh authentication and check the items path', async () => {
const originalFetch = global.fetch;
let logins = 0;
global.fetch = async function (url) {
if (url.endsWith('/login')) {
logins += 1;
return jsonResponse(200, { access_token: 'private-access-token', refresh_token: 'private-refresh-token' });
}
return jsonResponse(200, { data: [{ name: 'Campaign', total: 42 }], token: 'private-access-token' });
};
const source = {
id: 345678, api_url: 'https://example.com/campaign', auth_method: 'token_login',
token_url: 'https://example.com/login', token_request_body_json: '{"client_secret":"private-secret"}', items_path: 'data'
};
try {
await fetchApiSourceResponse(source);
const result = await testApiSourceConnection(source);
assert.equal(result.ok, true);
assert.equal(result.steps.length, 3);
assert.match(result.steps[2].message, /1 items/);
assert.ok(result.paths.includes('data.0.total'));
assert.doesNotMatch(JSON.stringify(result), /private-access-token|private-refresh-token|private-secret/);
await fetchApiSourceResponse(source);
assert.equal(logins, 2);
const missing = await testApiSourceConnection({ ...source, items_path: 'missing' });
assert.equal(missing.ok, false);
assert.match(missing.steps[2].message, /did not match/);
} finally { global.fetch = originalFetch; }
});
test('API source payload accepts POST request and token-login settings', () => {
const payload = buildApiSourcePayload({
body: {
@@ -42,6 +154,198 @@ test('API source payload accepts POST request and token-login settings', () => {
assert.equal(payload.tokenRefreshResponsePath, 'data.refreshToken');
});
test('connection test redacts custom token paths and reports invalid data JSON separately', async () => {
const originalFetch = global.fetch;
global.fetch = async function (url) {
if (url.endsWith('/login')) return jsonResponse(200, { session: { value: 'custom-private-token' } });
return { status: 200, ok: true, headers: { get: () => 'text/html' }, text: async () => '<html>Not JSON</html>' };
};
try {
const result = await testApiSourceConnection({
api_url: 'https://example.com/data', auth_method: 'token_login',
token_url: 'https://example.com/login', token_request_body_json: '{"secret":"private-secret"}',
token_response_path: 'session.value'
});
assert.equal(result.ok, false);
assert.equal(result.steps[0].ok, true);
assert.equal(result.steps[1].ok, true);
assert.equal(result.steps[2].stage, 'JSON / items extraction');
assert.equal(result.steps[2].ok, false);
assert.doesNotMatch(JSON.stringify(result), /custom-private-token|<html>/);
} finally { global.fetch = originalFetch; }
});
test('connection test routes enforce create/update permission and never persist data', async () => {
const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes');
const handlers = {};
const app = { get() {}, post(path, ...routeHandlers) { handlers[path] = routeHandlers; } };
registerRoutes(app, {
pool: new Proxy({}, { get() { throw new Error('Diagnostic route must not access the database'); } }),
common: {}, requirePermission(permission) { return permission; }
});
assert.equal(handlers['/data-sources/api-sources/test'][0], 'api-sources.create');
assert.equal(handlers['/data-sources/api-sources/:id/test'][0], 'api-sources.update');
const originalFetch = global.fetch;
global.fetch = async () => jsonResponse(200, { data: [{ total: 42 }] });
const res = {
headers: {}, statusCode: 200,
set(key, value) { this.headers[key] = value; return this; },
status(value) { this.statusCode = value; return this; },
json(value) { this.body = value; return this; }
};
try {
await handlers['/data-sources/api-sources/test'][1]({ body: { api_url: 'https://example.com/data', items_path: 'data' } }, res);
assert.equal(res.body.ok, true);
assert.equal(res.headers['Cache-Control'], 'no-store');
await handlers['/data-sources/api-sources/:id/test'][1]({ body: { api_url: 'invalid' } }, res);
assert.equal(res.statusCode, 400);
assert.match(res.body.error, /valid API URL/);
} finally { global.fetch = originalFetch; }
});
test('API source add and edit forms render connection diagnostics with the correct endpoint', () => {
const renderAdd = require('../src/web/routes/data-sources/api-sources/add');
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
const user = { permissionKeys: ['api-sources.create', 'api-sources.update'] };
const add = renderAdd({}, '', user);
const edit = renderEdit({ id: 42, name: 'Campaign' }, {}, '', user);
assert.match(add, /<p>Configure shared authentication and the requests for this API source\.<\/p>/);
assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/);
assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/);
assert.match(edit, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"/);
for (const html of [add, edit]) {
assert.match(html, /data-api-source-diagnostics hidden/);
assert.match(html, /data-api-source-copy-report disabled/);
assert.match(html, /data-api-source-test-status role="status"/);
assert.match(html, /class="card card-outline card-info admin-form-card mt-3" data-api-source-diagnostics/);
assert.match(html, /data-api-source-diagnostics[^>]*>\s*<div class="card-header bg-body-tertiary d-flex align-items-center row-gap-2 flex-wrap">/);
assert.match(html, /type="button"[^>]*data-api-source-dismiss-diagnostics[^>]*aria-label="Dismiss connection test"/);
assert.match(html, /class="btn btn-tool p-0 m-0" data-api-source-dismiss-diagnostics/);
const connectionActions = html.slice(html.indexOf('data-api-auth-test-url'), html.indexOf('data-api-source-auth-card'));
assert.match(connectionActions, />Save</);
assert.match(connectionActions, />Cancel</);
assert.match(connectionActions, />Delete</);
assert.match(connectionActions, /data-api-request-add/);
assert.match(html, /card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap/);
assert.match(html, /class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">\s*<button[^>]*data-api-request-remove/);
assert.match(html, /aria-label="Delete request"><i[^>]*><\/i>Delete<\/button>/);
assert.match(html, /class="form-check-input float-none ms-0 mt-0 flex-shrink-0" data-request-field="enabled"/);
assert.match(html, /class="col-12 col-lg-9"><label class="form-label w-100">Request name/);
assert.match(html, /class="col-12 col-lg-9"><label class="form-label w-100">API URL/);
assert.match(html, /api-source-auth-card collapsed-card" data-api-source-auth-card/);
assert.doesNotMatch(html, />Authentication details<\/h4>/);
assert.doesNotMatch(html, /data-api-requests-header/);
assert.match(html, /data-api-request-title>Request - /);
}
});
test('saved requests show visible full-width response trees with independent expand controls', () => {
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
const html = renderEdit({ id: 42, name: 'Campaign', requests: [
{ id: 43, name: 'Event', last_response_status: 200, last_response_json: '{"data":{"total":42}}' },
{ id: 44, name: 'Donors', last_response_status: 200, last_response_json: '{"data":[{"name":"Donor"}]}' }
] }, {}, '', { permissionKeys: ['api-sources.update'] });
assert.equal((html.match(/data-api-request-response/g) || []).length, 2);
assert.equal((html.match(/data-json-toggle-expand-all/g) || []).length, 2);
assert.equal((html.match(/data-json-toggle-collapse-all/g) || []).length, 2);
assert.doesNotMatch(html, /<summary>Latest response/);
assert.doesNotMatch(html, /max-height:24rem;overflow:auto" data-json-toggle-output/);
assert.match(html, /HTTP 200/);
assert.match(html, /data-api-request-title>Request - Event<\/h3>/);
assert.match(html, /data-api-request-title>Request - Donors<\/h3>/);
});
test('connection diagnostics follow the tested section and can be dismissed and reopened', async () => {
const fs = require('node:fs');
const vm = require('node:vm');
const handlers = {};
const placements = [];
let requestCard = null;
const connectionCard = { after() { placements.push('connection'); } };
const authCard = { hidden: false, after() { placements.push('authentication'); } };
let focusCount = 0;
const testButton = {
addEventListener(event, handler) { handlers.test = handler; },
getAttribute() { return '/data-sources/api-sources/test'; },
closest(selector) { return selector === '[data-api-request-card]' ? requestCard : connectionCard; },
focus() { focusCount += 1; }
};
const diagnostics = { hidden: true, setAttribute() {} };
const elements = {
'[data-api-source-auth-method]': { value: 'bearer', addEventListener() {} },
'[data-api-source-auth-card]': authCard,
'[data-api-source-test-url]': testButton,
'[data-api-source-diagnostics]': diagnostics,
'[data-api-source-test-status]': {},
'[data-api-source-test-results]': { replaceChildren() {} },
'[data-api-source-copy-report]': { addEventListener() {} },
'[data-api-source-dismiss-diagnostics]': { addEventListener(event, handler) { handlers.dismiss = handler; } }
};
const form = { querySelector(selector) { return elements[selector] || null; }, querySelectorAll() { return []; } };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/data-sources/api-source-form.js'), 'utf8'), {
document: { getElementById() { return form; } },
FormData: class { [Symbol.iterator]() { return [][Symbol.iterator](); } },
fetch: async () => ({ headers: { get: () => 'application/json' }, json: async () => ({ ok: true, steps: [] }) })
});
await handlers.test();
assert.equal(diagnostics.hidden, false);
assert.deepEqual(placements, ['authentication']);
handlers.dismiss();
assert.equal(diagnostics.hidden, true);
assert.equal(focusCount, 1);
requestCard = { after() { placements.push('request'); }, getAttribute() { return '43'; }, querySelectorAll() { return []; } };
await handlers.test();
assert.equal(diagnostics.hidden, false);
assert.equal(testButton.disabled, false);
assert.deepEqual(placements, ['authentication', 'request']);
requestCard = { after() { placements.push('another request'); }, getAttribute() { return '44'; }, querySelectorAll() { return []; } };
await handlers.test();
assert.deepEqual(placements, ['authentication', 'request', 'another request']);
requestCard = null;
authCard.hidden = true;
await handlers.test();
assert.equal(placements.at(-1), 'connection');
});
test('request deletion confirms the request name and preserves the card on cancel', () => {
const fs = require('node:fs');
const vm = require('node:vm');
const handlers = {};
const messages = [];
let confirmed = false;
let focusCount = 0;
let cards = [];
function createCard(name) {
const card = {
remove() { cards = cards.filter(value => value !== card); },
querySelector(selector) { return selector === '[data-api-request-remove]' ? button : { value: name }; }
};
const button = { disabled: false, hasAttribute() { return false; }, closest() { return card; } };
return card;
}
cards = [createCard('Event'), createCard('Donors')];
const addButton = { addEventListener() {}, focus() { focusCount += 1; } };
const list = { querySelectorAll() { return cards; }, addEventListener(event, handler) { handlers[event] = handler; } };
const elements = { '[data-api-request-list]': list, '[data-api-request-add]': addButton };
const form = { querySelector(selector) { return elements[selector] || null; }, querySelectorAll() { return []; }, addEventListener() {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/data-sources/api-source-form.js'), 'utf8'), {
document: { getElementById() { return form; } },
window: { confirm(message) { messages.push(message); return confirmed; } }
});
const remove = cards[1].querySelector('[data-api-request-remove]');
const event = { target: { closest(selector) { return selector === '[data-api-request-remove]' ? remove : null; } } };
handlers.click(event);
assert.equal(cards.length, 2);
assert.equal(focusCount, 0);
assert.match(messages[0], /Delete request "Donors"/);
assert.match(messages[0], /when you save/);
confirmed = true;
handlers.click(event);
assert.equal(cards.length, 1);
assert.equal(focusCount, 1);
assert.equal(cards[0].querySelector('[data-api-request-remove]').disabled, true);
});
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
const originalFetch = global.fetch;
const calls = [];
+30
View File
@@ -54,9 +54,39 @@ test('api source duplicate form model keeps info toast variant', () => {
assert.equal(model.showSaveSecondaryActions, true);
});
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;
const edit = buildApiSourceFormViewModel(source, '', null, {}, true).apiSource;
const duplicate = buildDuplicateApiSource(source, 'Copy of Tiltify');
for (const values of [add, edit, duplicate]) {
assert.equal(values.authMethod, 'token_login');
assert.equal(values.tokenUrl, source.token_url);
assert.equal(values.tokenResponsePath, 'data.token');
assert.equal(values.tokenHeaderPrefix, '');
assert.equal(values.updateIntervalValue, 30);
assert.equal(values.updateIntervalUnit, 'seconds');
assert.equal(values.enabled, 0);
}
});
test('API group duplication copies request settings without IDs or response state', () => {
const request = { id: 10, name: 'Donors', api_url: 'https://example.com/donors', request_method: 'GET', items_path: 'data', enabled: 1, update_interval_value: 30, update_interval_unit: 'seconds', last_response_json: '{"private":"old snapshot"}', last_pull_error: 'Old error' };
const duplicate = buildDuplicateApiSource({ id: 9, name: 'Tiltify', requests: [request] }, 'Copy of Tiltify');
assert.equal(duplicate.id, null);
assert.equal(duplicate.requests.length, 1);
assert.equal(duplicate.requests[0].api_url, request.api_url);
assert.equal(duplicate.requests[0].update_interval_value, 30);
assert.equal(duplicate.requests[0].id, undefined);
assert.equal(duplicate.requests[0].last_response_json, undefined);
assert.equal(duplicate.requests[0].last_pull_error, undefined);
assert.equal(request.id, 10);
});
test('api source list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'api-sources', 'list.hbs'), 'utf8');
assert.match(template, /\/data-sources\/api-sources\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
assert.match(template, /<h2>API sources<\/h2>\s*<p>Manage API sources, shared authentication, and request refresh schedules\.<\/p>/);
});
+3 -3
View File
@@ -71,9 +71,9 @@ test('playlist repeat migration upgrades an existing 2.15.1 installation', async
test('canvas size enabled migration upgrades an existing 2.15.2 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.2' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.15.3');
await pending[0].run(pool);
const canvasMigration = pending.find(migration => migration.version === '2.15.3');
assert.ok(canvasMigration);
await canvasMigration.run(pool);
assert.ok(pool.queries.some(([sql]) => sql.includes('ALTER TABLE c_canvas_sizes ADD COLUMN enabled')));
});