diff --git a/InfoLogger/lib/api.js b/InfoLogger/lib/api.js index e6e704cc3..e88e21819 100644 --- a/InfoLogger/lib/api.js +++ b/InfoLogger/lib/api.js @@ -12,11 +12,13 @@ * or submit itself to any jurisdiction. */ -const { InfoLoggerReceiver, MySQL } = require('@aliceo2/web-ui'); +const { InfoLoggerReceiver } = require('@aliceo2/web-ui'); const { StatusController } = require('./controller/StatusController.js'); +const { QueryController } = require('./controller/QueryController.js'); const { LiveService } = require('./services/LiveService.js'); const { QueryService } = require('./services/QueryService.js'); + const ProfileService = require('./ProfileService.js'); const JsonFileConnector = require('./JSONFileConnector.js'); @@ -26,12 +28,9 @@ const projPackage = require('./../package.json'); const config = require('./configProvider.js'); let liveService = null; -let sqlService = null; let queryService = null; module.exports.attachTo = async (http, ws) => { - const { QueryController } = await import('./controller/QueryController.mjs'); - if (config.infoLoggerServer) { const infoLoggerReceiver = new InfoLoggerReceiver(); liveService = new LiveService(ws, config.infoLoggerServer, infoLoggerReceiver); @@ -39,8 +38,7 @@ module.exports.attachTo = async (http, ws) => { } if (config.mysql) { - sqlService = new MySQL(config.mysql); - queryService = new QueryService(sqlService, config.mysql); + queryService = new QueryService(config.mysql); } const queryController = new QueryController(queryService); diff --git a/InfoLogger/lib/controller/QueryController.mjs b/InfoLogger/lib/controller/QueryController.js similarity index 82% rename from InfoLogger/lib/controller/QueryController.mjs rename to InfoLogger/lib/controller/QueryController.js index c5e928a46..89ca74f96 100644 --- a/InfoLogger/lib/controller/QueryController.mjs +++ b/InfoLogger/lib/controller/QueryController.js @@ -12,12 +12,12 @@ * or submit itself to any jurisdiction. */ -import { LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +const { LogManager, updateAndSendExpressResponseFromNativeError } = require('@aliceo2/web-ui'); /** * Gateway for all calls that are to query InfoLogger database */ -export class QueryController { +class QueryController { /** * Setup QueryController to be used in the API router * @param {SQLDataSource} queryService - service to be used to query information on the logs @@ -39,9 +39,14 @@ export class QueryController { async getLogs(req, res) { try { const { body: { criterias, options } } = req; + if (!criterias || Object.keys(criterias).length === 0) { + res.status(400).json({ error: 'Invalid query parameters provided' }); + return; + } const logs = await this._queryService.queryFromFilters(criterias, options); res.status(200).json(logs); } catch (error) { + this._logger.errorMessage(error.toString()); updateAndSendExpressResponseFromNativeError(res, error); } } @@ -62,9 +67,11 @@ export class QueryController { const stats = await this._queryService.queryGroupCountLogsBySeverity(runNumber); res.status(200).json(stats); } catch (error) { - this._logger.errorMessage(error.toString(), { level: 99, facility: 'ilg/query-ctrl', run: runNumber }); - res.status(502).json({ error: `Unable to serve query on stats for runNumber: ${runNumber}` }); + this._logger.errorMessage(error.toString()); + updateAndSendExpressResponseFromNativeError(res, error); } } } } + +exports.QueryController = QueryController; diff --git a/InfoLogger/lib/controller/StatusController.js b/InfoLogger/lib/controller/StatusController.js index 81688f907..d98b27a7b 100644 --- a/InfoLogger/lib/controller/StatusController.js +++ b/InfoLogger/lib/controller/StatusController.js @@ -81,7 +81,7 @@ class StatusController { const result = { 'infoLogger-gui': this._getProjectInfo(), infoLoggerServer: this._getLiveSourceStatus(ilgServerConfig ?? {}), - mysql: await this._getDataSourceStatus(dataSourceConfig ?? {}), + mysql: this._getDataSourceStatus(dataSourceConfig ?? {}), }; res.status(200).json(result); @@ -122,30 +122,22 @@ class StatusController { } /** - * Build object with information and status about data source + * Build object with information and latest known status about data source * @param {object} config used for retrieving data form data source * @param {string} config.host - host of the data source * @param {number} config.port - port of the data source * @param {string} config.database - database name * @returns {object} - information on statue of the data source */ - async _getDataSourceStatus({ host, port, database }) { - const dataSourceStatus = { + _getDataSourceStatus({ host, port, database }) { + return { host, port, database, + status: this?._querySource?.isAvailable + ? { ok: true } + : { ok: false, message: 'Data source is not available' }, }; - if (this._querySource) { - try { - await this._querySource.isConnectionUpAndRunning(); - dataSourceStatus.status = { ok: true }; - } catch (error) { - dataSourceStatus.status = { ok: false, message: error.message || error }; - } - } else { - dataSourceStatus.status = { ok: false, message: 'There was no data source set up' }; - } - return dataSourceStatus; } } diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index 642dcaa82..80436285a 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -12,28 +12,134 @@ * or submit itself to any jurisdiction. */ -const logger = require('@aliceo2/web-ui').LogManager - .getLogger(`${process.env.npm_config_log_label ?? 'ilg'}/sql`); +const mariadb = require('mariadb'); +const { LogManager } = require('@aliceo2/web-ui'); +const { fromSqlToNativeError } = require('../utils/fromSqlToNativeError'); class QueryService { /** * Query service that is to be used to map the InfoLogger parameters to SQL query and retrieve data - * @param {MySql} connection - mysql connection * @param {object} configMySql - mysql config */ - constructor(connection, configMySql) { - this.configMySql = configMySql; - this.connection = connection; + constructor(configMySql = {}) { + configMySql._user = configMySql?.user ?? 'gui'; + configMySql._password = configMySql?.password ?? ''; + configMySql._host = configMySql?.host ?? 'localhost'; + configMySql._port = configMySql?.port ?? 3306; + configMySql._database = configMySql?.database ?? 'info_logger'; + configMySql._connectionLimit = configMySql?.connectionLimit ?? 25; + this._timeout = configMySql?.timeout ?? 10000; + + this._pool = mariadb.createPool(configMySql); + this._isAvailable = false; + this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'ilg'}/query-service`); + } + + /** + * Method to test connection of mysql connector once initialized + * @param {number} timeout - timeout for the connection test + * @returns {Promise} - a promise that resolves if connection is successful + */ + async checkConnection(timeout = this._timeout) { + try { + await this._pool.query({ + sql: 'SELECT 1', + timeout, + }); + this._isAvailable = true; + } catch (error) { + this._isAvailable = false; + fromSqlToNativeError(error); + } + } + + /** + * Ask DB for a part of rows and the total count + * - total: how many rows available (limited to 1M) + * - more: true if has more than 1M rows + * - limit: options.limit or 100k + * - rows: the first `limit` rows + * - count: how many rows inside `rows` + * - time: how much did it take, in ms + * @param {object} filters - criteria like MongoDB + * @param {object} options - specific options for the query + * @param {number} options.limit - how many rows to get + * @returns {Promise.} - {total, more, limit, rows, count, time} + */ + async queryFromFilters(filters, options) { + const { limit = 100000 } = options; + const { criteria, values } = this._filtersToSqlConditions(filters); + const criteriaString = this._getCriteriaAsString(criteria); + + const requestRows = `SELECT * FROM \`messages\` ${criteriaString} ORDER BY \`TIMESTAMP\` LIMIT ?;`; + const startTime = Date.now(); // ms + + let rows = []; + try { + rows = await this._pool.query( + { + sql: requestRows, + timeout: this._timeout, + }, + [...values, limit], + ); + } catch (error) { + fromSqlToNativeError(error); + } + + const totalTime = Date.now() - startTime; // ms + return { + rows, + count: rows.length, + limit: limit, + time: totalTime, // ms + queryAsString: this._getSQLQueryAsString(criteriaString, limit), + }; } /** - * Method to check if mysql driver connection is up - * @returns {Promise} - resolves/rejects + * Given a runNumber, query logs for it and return a count of the logs grouped by severity + * @param {number|string} runNumber - number of the run for which the query should be performed + * @returns {Promise.} - object containing the count of logs grouped by severity */ - async isConnectionUpAndRunning() { - await this.connection.query('select timestamp from messages LIMIT 1000;'); - const url = `${this.configMySql.host}:${this.configMySql.port}/${this.configMySql.database}`; - logger.infoMessage(`Connected to infoLogger database ${url}`); + async queryGroupCountLogsBySeverity(runNumber) { + const groupByStatement = + 'SELECT severity, COUNT(*) FROM messages WHERE run=? and severity ' + + `in ('D', 'I', 'W', 'E', 'F') GROUP BY severity;`; + let data = []; + try { + data = await this._pool.query({ + sql: groupByStatement, + timeout: this._timeout, + }, [runNumber]); + } catch (error) { + fromSqlToNativeError(error); + } + const result = { D: 0, I: 0, W: 0, E: 0, F: 0 }; + + data.forEach((group) => { + result[group['severity']] = group['COUNT(*)']; + }); + return result; + } + + /** + * Method to fill criteria and return it as string + * @param {Array} criteria Array of criteria set by the user + * @returns {string} - criteria as string in SQL format + */ + _getCriteriaAsString(criteria) { + return criteria && criteria.length ? `WHERE ${criteria.join(' AND ')}` : ''; + } + + /** + * Get the SQL Query used as a string + * @param {string} criteriaVerbose - criteria as string in SQL format + * @param {number} limit - limit of number of messages + * @returns {string} - SQL Query as string + */ + _getSQLQueryAsString(criteriaVerbose, limit) { + return `SELECT * FROM \`messages\` ${criteriaVerbose} ORDER BY \`TIMESTAMP\` LIMIT ${limit}`; } /** @@ -150,7 +256,7 @@ class QueryService { criteria.push(`\`${field}\` IN (?)`); break; default: - logger.warn(`unknown operator ${operator}`); + this._logger.warn(`unknown operator ${operator}`); break; } } @@ -159,107 +265,11 @@ class QueryService { } /** - * Ask DB for a part of rows and the total count - * - total: how many rows available (limited to 1M) - * - more: true if has more than 1M rows - * - limit: options.limit or 100k - * - rows: the first `limit` rows - * - count: how many rows inside `rows` - * - time: how much did it take, in ms - * @param {object} filters - criteria like MongoDB - * @param {object} options - limit, etc. - * @returns {Promise.} - {total, more, limit, rows, count, time} - */ - async queryFromFilters(filters, options) { - if (!filters) { - throw new Error('filters parameter is mandatory'); - } - options = { limit: 100000, ...options }; - - const startTime = Date.now(); // ms - const { criteria, values } = this._filtersToSqlConditions(filters); - const criteriaString = this._getCriteriaAsString(criteria); - - const rows = await this._queryMessagesOnOptions(criteriaString, options, values) - .catch((error) => { - logger.error(error); - throw error; - }); - - const totalTime = Date.now() - startTime; // ms - logger.debug(`Query done in ${totalTime}ms`); - return { - rows, - count: rows.length, - limit: options.limit, - time: totalTime, // ms - queryAsString: this._getSQLQueryAsString(criteriaString, options.limit), - }; - } - - /** - * Given a runNumber, query logs for it and return a count of the logs grouped by severity - * @param {number|string} runNumber - number of the run for which the query should be performed - * @returns {Promise.} - object containing the count of logs grouped by severity + * Getter for the availability of the service + * @returns {boolean} - true if service is available, false otherwise */ - async queryGroupCountLogsBySeverity(runNumber) { - const groupByStatement = - 'SELECT severity, COUNT(*) FROM messages WHERE run=? and severity ' - + `in ('D', 'I', 'W', 'E', 'F') GROUP BY severity;`; - return this.connection.query(groupByStatement, [runNumber]).then((data) => { - const result = { - D: 0, - I: 0, - W: 0, - E: 0, - F: 0, - }; - - /** - * data is of structure: - * [ - * RowDataPacket { severity: 'E', 'COUNT(*)': 102 } - * ] - */ - data.forEach((group) => { - result[group['severity']] = group['COUNT(*)']; - }); - return result; - }); - } - - /** - * Method to fill criteria and return it as string - * @param {Array} criteria Array of criteria set by the user - * @returns {string} - criteria as string in SQL format - */ - _getCriteriaAsString(criteria) { - return criteria && criteria.length ? `WHERE ${criteria.join(' AND ')}` : ''; - } - - /** - * Get the SQL Query used as a string - * @param {string} criteriaVerbose - criteria as string in SQL format - * @param {number} limit - limit of number of messages - * @returns {string} - SQL Query as string - */ - _getSQLQueryAsString(criteriaVerbose, limit) { - return `SELECT * FROM \`messages\` ${criteriaVerbose} ORDER BY \`TIMESTAMP\` LIMIT ${limit}`; - } - - /** - * Method to retrieve the messages based on passed Options - * @param {string} criteriaString as a string - * @param {object} options containing limit on messages - * @param {Array} values of filter parameters - * @returns {Promise} rows - */ - _queryMessagesOnOptions(criteriaString, options, values) { - // The rows asked with a limit - const requestRows = `SELECT * FROM \`messages\` ${criteriaString} ORDER BY \`TIMESTAMP\` LIMIT ${options.limit}`; - - return this.connection.query(requestRows, values) - .then((data) => data); + get isAvailable() { + return this._isAvailable; } }; diff --git a/InfoLogger/lib/utils/fromSqlToNativeError.js b/InfoLogger/lib/utils/fromSqlToNativeError.js new file mode 100644 index 000000000..9bea97342 --- /dev/null +++ b/InfoLogger/lib/utils/fromSqlToNativeError.js @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +/* eslint-disable @stylistic/js/max-len */ + +const { NotFoundError, TimeoutError, UnauthorizedAccessError } = require('@aliceo2/web-ui'); + +/** + * The purpose is to translate MySQL errors to native JS errors + * Source: https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/c3a9e333243a1d92b22f4ca1e5a574ab0de77cea/lib/const/error-code.js#L1040 + * @param {SqlError} error - the error from a catch or callback + * @throws throws a native JS error + */ +const fromSqlToNativeError = (error) => { + const { code, errno, sqlMessage } = error; + const errorMessage = `SQL: [${code}, ${errno}] ${sqlMessage}`; + switch (code) { + case 'ER_NO_DB_ERROR': + throw new NotFoundError(errorMessage); + case 'ER_NO_SUCH_TABLE': + throw new NotFoundError(errorMessage); + case 'ER_STATEMENT_TIMEOUT': + throw new TimeoutError(errorMessage); + case 'ER_ACCESS_DENIED_ERROR': + throw new UnauthorizedAccessError(errorMessage); + default: + throw new Error(errorMessage); + } +}; + +module.exports.fromSqlToNativeError = fromSqlToNativeError; diff --git a/InfoLogger/package-lock.json b/InfoLogger/package-lock.json index c3a22ea3b..24eacb607 100644 --- a/InfoLogger/package-lock.json +++ b/InfoLogger/package-lock.json @@ -12,7 +12,8 @@ ], "license": "GPL-3.0", "dependencies": { - "@aliceo2/web-ui": "2.7.2" + "@aliceo2/web-ui": "2.7.2", + "mariadb": "3.3.1" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -977,6 +978,12 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1859,6 +1866,15 @@ "node": ">= 14" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3562,6 +3578,49 @@ "semver": "bin/semver.js" } }, + "node_modules/mariadb": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.3.1.tgz", + "integrity": "sha512-L8bh4iuZU3J8H7Co7rQ6OY9FDLItAN1rGy8kPA7Dyxo8AiHADuuONoypKKp1pE09drs6e5LR7UW9luLZ/A4znA==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.14", + "@types/node": "^20.11.17", + "denque": "^2.1.0", + "iconv-lite": "^0.6.3", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mariadb/node_modules/@types/node": { + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/mariadb/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mariadb/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -5399,9 +5458,7 @@ "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "optional": true + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/universalify": { "version": "2.0.1", diff --git a/InfoLogger/package.json b/InfoLogger/package.json index ad5289402..e0e67816e 100644 --- a/InfoLogger/package.json +++ b/InfoLogger/package.json @@ -30,7 +30,8 @@ }, "main": "index.js", "dependencies": { - "@aliceo2/web-ui": "2.7.2" + "@aliceo2/web-ui": "2.7.2", + "mariadb": "3.3.1" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/InfoLogger/test/lib/controller/mocha-QueryController.mjs b/InfoLogger/test/lib/controller/mocha-QueryController.mjs deleted file mode 100644 index 28e861a03..000000000 --- a/InfoLogger/test/lib/controller/mocha-QueryController.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. -*/ -/* eslint-disable max-len */ - -import assert from 'assert'; -import {QueryController} from '../../../lib/controller/QueryController.mjs'; -import {spy, stub} from 'sinon'; - -describe('QueryController test suite', () => { - describe('getQueryStats() - test suite', () => { - let res; - const ctrl = new QueryController(); - - beforeEach(() => { - res = { - status: stub().returnsThis(), - json: spy() - }; - }); - - it('should return error due to undefined being passes as runNumber', () => { - const ctrl = new QueryController(); - ctrl.getQueryStats({query: {runNumber: undefined}}, res); - - assert.ok(res.status.calledWith(400)); - assert.ok(res.json.calledWith({error: 'Invalid runNumber provided'})); - }); - it('should return error due to null being passes as runNumber', () => { - ctrl.getQueryStats({query: {runNumber: null}}, res); - assert.ok(res.status.calledWith(400)); - assert.ok(res.json.calledWith({error: 'Invalid runNumber provided'})); - - }); - it('should return error due to NaN being passes as runNumber', () => { - ctrl.getQueryStats({query: {runNumber: '22f2'}}, res); - assert.ok(res.status.calledWith(400)); - assert.ok(res.json.calledWith({error: 'Invalid runNumber provided'})); - }); - - it('should return error due to query service throwing error', async () => { - const ctrl = new QueryController({ - queryGroupCountLogsBySeverity: stub().rejects(new Error('Unable to connect to host')) - }); - await ctrl.getQueryStats({query: {runNumber: 555123}}, res); - assert.ok(res.status.calledWith(502)); - assert.ok(res.json.calledWith({error: 'Unable to serve query on stats for runNumber: 555123'})); - }); - - it('should successfully return stats for known severities', async () => { - const stats = { - D: 1223, - I: 432131, - W: 50, - E: 2, - F: 1, - } - const ctrl = new QueryController({ - queryGroupCountLogsBySeverity: stub().resolves(stats) - }); - await ctrl.getQueryStats({query: {runNumber: 555123}}, res); - assert.ok(res.status.calledWith(200)); - assert.ok(res.json.calledWith(stats)); - - }); - }); -}); diff --git a/InfoLogger/test/lib/controller/mocha-query-controller.test.js b/InfoLogger/test/lib/controller/mocha-query-controller.test.js new file mode 100644 index 000000000..877012255 --- /dev/null +++ b/InfoLogger/test/lib/controller/mocha-query-controller.test.js @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const { QueryController } = require('../../../lib/controller/QueryController'); +const { spy, stub } = require('sinon'); +const { TimeoutError } = require('@aliceo2/web-ui'); + +describe('QueryController test suite', () => { + describe('getQueryStats() - test suite', () => { + let res = {}; + const ctrl = new QueryController(); + + beforeEach(() => { + res = { + status: stub().returnsThis(), + json: spy(), + }; + }); + + it('should return error due to undefined being passes as runNumber', () => { + const ctrl = new QueryController(); + ctrl.getQueryStats({ query: { runNumber: undefined } }, res); + + assert.ok(res.status.calledWith(400)); + assert.ok(res.json.calledWith({ error: 'Invalid runNumber provided' })); + }); + it('should return error due to null being passes as runNumber', () => { + ctrl.getQueryStats({ query: { runNumber: null } }, res); + assert.ok(res.status.calledWith(400)); + assert.ok(res.json.calledWith({ error: 'Invalid runNumber provided' })); + }); + it('should return error due to NaN being passes as runNumber', () => { + ctrl.getQueryStats({ query: { runNumber: '22f2' } }, res); + assert.ok(res.status.calledWith(400)); + assert.ok(res.json.calledWith({ error: 'Invalid runNumber provided' })); + }); + + it('should return specific status code due to query service throwing TimeoutError', async () => { + const ctrl = new QueryController({ + queryGroupCountLogsBySeverity: stub().rejects(new TimeoutError('Unable to connect to host')), + }); + await ctrl.getQueryStats({ query: { runNumber: 555123 } }, res); + assert.ok(res.status.calledWith(408)); + assert.ok(res.json.calledWith({ + title: 'Timeout', + message: 'Unable to connect to host', + status: 408, + })); + }); + + it('should successfully return stats for known severities', async () => { + const stats = { + D: 1223, + I: 432131, + W: 50, + E: 2, + F: 1, + }; + const ctrl = new QueryController({ + queryGroupCountLogsBySeverity: stub().resolves(stats), + }); + await ctrl.getQueryStats({ query: { runNumber: 555123 } }, res); + assert.ok(res.status.calledWith(200)); + assert.ok(res.json.calledWith(stats)); + }); + }); + + describe('getLogs', () => { + let queryService = {}; + let queryController = {}; + let res = {}; + + beforeEach(() => { + queryService = { + queryFromFilters: stub(), + }; + queryController = new QueryController(queryService); + + res = { + status: stub().returnsThis(), + json: stub(), + }; + }); + it('should return 400 if criterias are missing or empty', async () => { + await queryController.getLogs({ body: { criterias: null } }, res); + + assert.ok(res.status.calledWith(400)); + assert.ok(res.json.calledWith({ error: 'Invalid query parameters provided' })); + }); + + it('should successfully return logs if queryFromFilters is successful', async () => { + const logs = [{ id: 1, message: 'log1' }]; + const body = { + criterias: { key: 'value' }, + options: { }, + }; + queryService.queryFromFilters.resolves(logs); + + await queryController.getLogs({ body }, res); + + assert.ok(queryService.queryFromFilters.calledWith(body.criterias, body.options)); + assert.ok(res.status.calledWith(200)); + assert.ok(res.json.calledWith(logs)); + }); + + it('should return specific timeout error when query times out', async () => { + const body = { + criterias: { key: 'value' }, + options: {}, + }; + queryService.queryFromFilters.rejects(new TimeoutError('QUERY TIMED OUT')); + + await queryController.getLogs({ body }, res); + + assert.ok(res.status.calledWith(408)); + assert.ok(res.json.calledWith({ title: 'Timeout', message: 'QUERY TIMED OUT', status: 408 })); + }); + }); +}); diff --git a/InfoLogger/test/lib/controller/mocha-status-controller.test.js b/InfoLogger/test/lib/controller/mocha-status-controller.test.js index 96e8c4b9a..afc23b834 100644 --- a/InfoLogger/test/lib/controller/mocha-status-controller.test.js +++ b/InfoLogger/test/lib/controller/mocha-status-controller.test.js @@ -85,7 +85,7 @@ describe('Status Service test suite', () => { port: 6103, database: 'INFOLOGGER', status: { - ok: false, message: 'There was no data source set up', + ok: false, message: 'Data source is not available', }, }; const mysql = await statusController._getDataSourceStatus(config.mysql); @@ -99,7 +99,7 @@ describe('Status Service test suite', () => { const info = { host: 'localhost', port: 6103, database: 'INFOLOGGER', status: { ok: true } }; const dataSource = { - isConnectionUpAndRunning: sinon.stub().resolves(), + isAvailable: true, }; statusController.querySource = dataSource; const mysql = await statusController._getDataSourceStatus(config.mysql); @@ -116,12 +116,12 @@ describe('Status Service test suite', () => { port: 6103, database: 'INFOLOGGER', status: { - ok: false, message: 'Could not connect', + ok: false, message: 'Data source is not available', }, }; const dataSource = { - isConnectionUpAndRunning: sinon.stub().rejects(new Error('Could not connect')), + isAvailable: false, }; statusController.querySource = dataSource; const mysql = await statusController._getDataSourceStatus(config.mysql); @@ -153,7 +153,7 @@ describe('Status Service test suite', () => { port: 6103, database: 'INFOLOGGER', status: { - ok: false, message: 'There was no data source set up', + ok: false, message: 'Data source is not available', }, }, infoLoggerServer: { diff --git a/InfoLogger/test/lib/services/mocha-QueryService.js b/InfoLogger/test/lib/services/mocha-query-service.test.js similarity index 56% rename from InfoLogger/test/lib/services/mocha-QueryService.js rename to InfoLogger/test/lib/services/mocha-query-service.test.js index 50f6759ea..21cebb18b 100644 --- a/InfoLogger/test/lib/services/mocha-QueryService.js +++ b/InfoLogger/test/lib/services/mocha-query-service.test.js @@ -15,10 +15,10 @@ const assert = require('assert'); const sinon = require('sinon'); const config = require('../../../config-default.js'); -const { QueryService } = require('./../../../lib/services/QueryService.js'); -const { MySQL } = require('@aliceo2/web-ui'); +const { QueryService } = require('../../../lib/services/QueryService.js'); +const { UnauthorizedAccessError, TimeoutError } = require('@aliceo2/web-ui'); -describe('QueryService', () => { +describe(`'QueryService' test suite`, () => { const filters = { timestamp: { since: -5, @@ -73,33 +73,34 @@ describe('QueryService', () => { }; const emptySqlDataSource = new QueryService(undefined, {}); - describe('Should check connection to mysql driver', () => { - it('should reject with error when connection with mysql driver fails', async () => { - const stub = sinon.createStubInstance( - MySQL, - { - query: sinon.stub().rejects(new Error('Unable to connect')), - }, - ); - const sqlDataSource = new QueryService(stub, config.mysql); + describe(`'checkConnection()' - test suite`, () => { + it('should reject with error when simple query fails', async () => { + const sqlDataSource = new QueryService(config.mysql); + sqlDataSource._isAvailable = true; + sqlDataSource._pool = { + query: sinon.stub().rejects({ + code: 'ER_ACCESS_DENIED_ERROR', + errno: 1045, + sqlMessage: 'Access denied', + }), + }; - await assert.rejects(async () => { - await sqlDataSource.isConnectionUpAndRunning(); - }, new Error('Unable to connect')); + await assert.rejects( + sqlDataSource.checkConnection(), + new UnauthorizedAccessError('SQL: [ER_ACCESS_DENIED_ERROR, 1045] Access denied'), + ); + assert.ok(sqlDataSource.isAvailable === false); }); it('should do nothing when checking connection with mysql driver and driver returns resolved Promise', async () => { - const stub = sinon.createStubInstance( - MySQL, - { - query: sinon.stub().resolves('Connection is fine'), - }, - ); - const sqlDataSource = new QueryService(stub, config.mysql); + const sqlDataSource = new QueryService(config.mysql); + sqlDataSource._isAvailable = false; + sqlDataSource._pool = { + query: sinon.stub().resolves(), + }; - await assert.doesNotReject(async () => { - await sqlDataSource.isConnectionUpAndRunning(); - }); + await assert.doesNotReject(sqlDataSource.checkConnection()); + assert.ok(sqlDataSource.isAvailable); }); }); @@ -188,72 +189,59 @@ describe('QueryService', () => { }); }); - it('should successfully return messages when querying mysql driver', async () => { - const stub = sinon.createStubInstance( - MySQL, - { - query: sinon.stub().resolves([{ severity: 'W' }, { severity: 'I' }]), - }, - ); - const sqlDataSource = new QueryService(stub, config.mysql); - const queryResult = await sqlDataSource._queryMessagesOnOptions('criteriaString', []); - assert.deepStrictEqual(queryResult, [{ severity: 'W' }, { severity: 'I' }]); - }); - - it('should throw an error when unable to query within private method due to rejected promise', async () => { - const stub = sinon.createStubInstance(MySQL, { query: sinon.stub().rejects() }); - const sqlDataSource = new QueryService(stub, config.mysql); - return assert.rejects(async () => { - await sqlDataSource._queryMessagesOnOptions('criteriaString', []); - }, new Error('Error')); - }); - - it('should throw an error when unable to query(API) due to rejected promise', async () => { - const stub = sinon.createStubInstance(MySQL, { query: sinon.stub().rejects() }); - const sqlDataSource = new QueryService(stub, config.mysql); - return assert.rejects(async () => { - await sqlDataSource.queryFromFilters(realFilters, { limit: 10 }); - }, new Error('Error')); - }); - - it('should throw an error if no filters are provided for querying', async () => { - await assert.rejects(async () => { - await emptySqlDataSource.queryFromFilters(undefined, undefined); - }, new Error('filters parameter is mandatory')); - }); + describe('queryFromFilters() - test suite', () => { + it('should throw an error when unable to query(API) due to rejected promise', async () => { + const sqlDataSource = new QueryService(config.mysql); + sqlDataSource._pool = { + query: sinon.stub().rejects({ + code: 'ER_ACCESS_DENIED_ERROR', + errno: 1045, + sqlMessage: 'Access denied', + }), + }; + await assert.rejects( + sqlDataSource.queryFromFilters(realFilters, { limit: 10 }), + new UnauthorizedAccessError('SQL: [ER_ACCESS_DENIED_ERROR, 1045] Access denied'), + ); + }); - it('should successfully return result when filters are provided for querying', async () => { - const criteriaString = 'WHERE `timestamp`>=? AND `timestamp`<=? AND ' + - '`hostname` = ? AND NOT(`hostname` = ? AND `hostname` IS NOT NULL) AND `severity` IN (?)'; - const requestRows = `SELECT * FROM \`messages\` ${criteriaString} ORDER BY \`TIMESTAMP\` LIMIT 10`; - const values = [1563794601.351, 1563794661.354, 'test', 'testEx', ['D', 'W']]; - const query = 'SELECT * FROM `messages` WHERE `timestamp`>=? AND `timestamp`<=? AND `hostname` = ? AND NOT(`hostname` = ? AND `hostname` IS NOT NULL) AND `severity` IN (?) ORDER BY `TIMESTAMP` LIMIT 10'; - const queryStub = sinon.stub(); - queryStub.withArgs(requestRows, values).resolves([]); - const stub = sinon.createStubInstance(MySQL, { query: queryStub }); + it('should successfully return result when filters are provided for querying', async () => { + const query = 'SELECT * FROM `messages` WHERE `timestamp`>=? AND `timestamp`<=? AND `hostname` = ? ' + + 'AND NOT(`hostname` = ? AND `hostname` IS NOT NULL) AND `severity` IN (?) ORDER BY `TIMESTAMP` LIMIT 10'; - const sqlDataSource = new QueryService(stub, config.mysql); - const result = await sqlDataSource.queryFromFilters(realFilters, { limit: 10 }); + const sqlDataSource = new QueryService(config.mysql); + sqlDataSource._pool = { + query: sinon.stub().resolves([ + { hostname: 'test', severity: 'W' }, + { hostname: 'test', severity: 'I' }, + ]), + }; + const result = await sqlDataSource.queryFromFilters(realFilters, { limit: 10 }); + delete result.time; - const expectedResult = { - rows: [], - count: 0, - limit: 10, - queryAsString: query, - }; - delete result.time; - assert.deepStrictEqual(result, expectedResult); + const expectedResult = { + rows: [ + { hostname: 'test', severity: 'W' }, + { hostname: 'test', severity: 'I' }, + ], + count: 2, + limit: 10, + queryAsString: query, + }; + assert.deepStrictEqual(result, expectedResult); + }); }); describe('queryGroupCountLogsBySeverity() - test suite', ()=> { - it('should successfully return stats when queried for all known severities even if none is returned by data service', async () => { - const sqlStub = { + it(`should successfully return stats when queried for all known severities + even if none is some are not returned by data service`, async () => { + const dataService = new QueryService(config.mysql); + dataService._pool = { query: sinon.stub().resolves([ { severity: 'E', 'COUNT(*)': 102 }, { severity: 'F', 'COUNT(*)': 1 }, ]), }; - const dataService = new QueryService(sqlStub, config.mysql); const data = await dataService.queryGroupCountLogsBySeverity(51234); assert.deepStrictEqual(data, { D: 0, @@ -264,22 +252,33 @@ describe('QueryService', () => { }); }); - it('should throw error if data service throws error', async () => { - const sqlStub = { - query: sinon.stub().rejects(new Error('Data Service went bad')), - }; - const dataService = new QueryService(sqlStub, config.mysql); + it('should throw error if data service throws SQL', async () => { + const dataService = new QueryService(config.mysql); + dataService._pool = + { + query: sinon.stub().rejects({ + code: 'ER_ACCESS_DENIED_ERROR', + errno: 1045, + sqlMessage: 'Access denied', + }), + }; - await assert.rejects(dataService.queryGroupCountLogsBySeverity(51234), new Error('Data Service went bad')); - }); + await assert.rejects( + dataService.queryGroupCountLogsBySeverity(51234), + new UnauthorizedAccessError('SQL: [ER_ACCESS_DENIED_ERROR, 1045] Access denied'), + ); - it('should throw error if data service throws error', async () => { - const sqlStub = { - query: sinon.stub().throws(new Error('Data Service went bad')), + dataService._pool = { + query: sinon.stub().rejects({ + code: 'ER_STATEMENT_TIMEOUT', + errno: 1045, + sqlMessage: 'query timed out', + }), }; - const dataService = new QueryService(sqlStub, config.mysql); - - await assert.rejects(dataService.queryGroupCountLogsBySeverity(51234), new Error('Data Service went bad')); + await assert.rejects( + dataService.queryGroupCountLogsBySeverity(51234), + new TimeoutError('SQL: [ER_STATEMENT_TIMEOUT, 1045] query timed out'), + ); }); }); }); diff --git a/InfoLogger/test/lib/utils/mocha-from-sql-to-native-error.js b/InfoLogger/test/lib/utils/mocha-from-sql-to-native-error.js new file mode 100644 index 000000000..3deb7d00e --- /dev/null +++ b/InfoLogger/test/lib/utils/mocha-from-sql-to-native-error.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +/* eslint-disable @stylistic/js/max-len */ + +const assert = require('assert'); +const { NotFoundError, TimeoutError, UnauthorizedAccessError } = require('@aliceo2/web-ui'); +const { fromSqlToNativeError } = require('../../../lib/utils/fromSqlToNativeError.js'); + +describe('fromSqlToNativeError', () => { + it('should throw NotFoundError for ER_NO_DB_ERROR', () => { + const error = { code: 'ER_NO_DB_ERROR', errno: 1049, sqlMessage: 'Unknown database' }; + assert.throws(() => fromSqlToNativeError(error), NotFoundError); + }); + + it('should throw NotFoundError for ER_NO_SUCH_TABLE', () => { + const error = { code: 'ER_NO_SUCH_TABLE', errno: 1146, sqlMessage: 'Table does not exist' }; + assert.throws(() => fromSqlToNativeError(error), NotFoundError); + }); + + it('should throw TimeoutError for ER_STATEMENT_TIMEOUT', () => { + const error = { code: 'ER_STATEMENT_TIMEOUT', errno: 3024, sqlMessage: 'Statement timeout' }; + assert.throws(() => fromSqlToNativeError(error), TimeoutError); + }); + + it('should throw UnauthorizedAccessError for ER_ACCESS_DENIED_ERROR', () => { + const error = { code: 'ER_ACCESS_DENIED_ERROR', errno: 1045, sqlMessage: 'Access denied' }; + assert.throws(() => fromSqlToNativeError(error), UnauthorizedAccessError); + }); + + it('should throw generic Error for unknown error code', () => { + const error = { code: 'UNKNOWN_ERROR', errno: 9999, sqlMessage: 'Unknown error' }; + assert.throws(() => fromSqlToNativeError(error), Error); + }); +}); \ No newline at end of file