Release 2.16.1
This commit is contained in:
1 parent
3514d514a3
commit
8ce8228091
25 files changed
+1307
-613
No files matched your search
@@ -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; }
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user