diff --git a/QualityControl/Dockerfile b/QualityControl/Dockerfile index 9adda01a2..94061ef34 100644 --- a/QualityControl/Dockerfile +++ b/QualityControl/Dockerfile @@ -38,7 +38,7 @@ RUN apk add --no-cache \ freetype=2.13.2-r0 \ freetype-dev=2.13.2-r0 \ harfbuzz=8.5.0-r0 \ - ca-certificates=20250619-r0 + ca-certificates=20250911-r0 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser diff --git a/QualityControl/common/library/enums/objectOptions.enum.js b/QualityControl/common/library/enums/objectOptions.enum.js new file mode 100644 index 000000000..161f4dedd --- /dev/null +++ b/QualityControl/common/library/enums/objectOptions.enum.js @@ -0,0 +1,30 @@ +export const ObjectOption = { + lego: 'lego', + colz: 'colz', + lcolz: 'lcolz', + text: 'text', + logx: 'logx', + logy: 'logy', + logz: 'logz', + gridx: 'gridx', + gridy: 'gridy', + gridz: 'gridz', + stat: 'stat', +}; + +export const drawingOptions = [ + ObjectOption.lego, + ObjectOption.colz, + ObjectOption.lcolz, + ObjectOption.text, +]; + +export const displayHints = [ + ObjectOption.logx, + ObjectOption.logy, + ObjectOption.logz, + ObjectOption.gridx, + ObjectOption.gridy, + ObjectOption.gridz, + ObjectOption.stat, +]; diff --git a/QualityControl/config-default.js b/QualityControl/config-default.js index 8d635505c..d005b902e 100644 --- a/QualityControl/config-default.js +++ b/QualityControl/config-default.js @@ -49,6 +49,8 @@ export const config = { timezone: '+00:00', logging: false, retryThrottle: 5000, + //forceSeed: true, --- ONLY IN DEVELOPMENT --- + //drop: true, --- ONLY IN DEVELOPMENT --- }, bookkeeping: { url: 'http://localhost:4000', // local insance diff --git a/QualityControl/docker-compose.coverage-ci.yml b/QualityControl/docker-compose.coverage-ci.yml index 982794d88..bcc70f2ad 100644 --- a/QualityControl/docker-compose.coverage-ci.yml +++ b/QualityControl/docker-compose.coverage-ci.yml @@ -1,5 +1,8 @@ services: test_app: + depends_on: + database: + condition: service_healthy build: context: . target: coverage-ci diff --git a/QualityControl/docker-compose.coverage-local.yml b/QualityControl/docker-compose.coverage-local.yml index 0a763f3a9..a30f8e3fe 100644 --- a/QualityControl/docker-compose.coverage-local.yml +++ b/QualityControl/docker-compose.coverage-local.yml @@ -1,5 +1,8 @@ services: test_app: + depends_on: + database: + condition: service_healthy build: context: . target: coverage-local diff --git a/QualityControl/docker-compose.dev.yml b/QualityControl/docker-compose.dev.yml index 6995f6e68..c1213d2ff 100644 --- a/QualityControl/docker-compose.dev.yml +++ b/QualityControl/docker-compose.dev.yml @@ -1,5 +1,8 @@ services: application: + depends_on: + database: + condition: service_healthy build: context: . target: development diff --git a/QualityControl/docker-compose.test.yml b/QualityControl/docker-compose.test.yml index 6fb2b5e4b..5f6f2ad7b 100644 --- a/QualityControl/docker-compose.test.yml +++ b/QualityControl/docker-compose.test.yml @@ -1,5 +1,8 @@ services: test_app: + depends_on: + database: + condition: service_healthy build: context: . target: test diff --git a/QualityControl/docker-compose.yml b/QualityControl/docker-compose.yml index b17faf624..23a365192 100644 --- a/QualityControl/docker-compose.yml +++ b/QualityControl/docker-compose.yml @@ -20,6 +20,12 @@ services: read_only: true source: ./docker/database/populate target: /docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 1m30s + timeout: 30s + retries: 5 + start_period: 30s volumes: database-data: diff --git a/QualityControl/docs/Configuration.md b/QualityControl/docs/Configuration.md index 9d00c16b1..aeea38b6b 100644 --- a/QualityControl/docs/Configuration.md +++ b/QualityControl/docs/Configuration.md @@ -98,4 +98,5 @@ The application requires the following database configuration parameters: | `timezone` | Time zone used for all date/time values in the database connection. | | `logging` | Enables or disables SQL query logging (useful for debugging). | | `retryThrottle` | Time in milliseconds to wait before retrying a failed database connection. | -| `migrationSeed` | *(Optional)* Set to `true` to execute seeders that populate the database with mock data. | +| `drop` | *(only in dev)* Set to `true` to drop all tables before the database migration. | +| `forceSeed` | *(only in dev)* Set to `true` to execute seeders that populate the database with mock data. | diff --git a/QualityControl/jsconfig.json b/QualityControl/jsconfig.json index 78aad40f5..bd8dbfd45 100644 --- a/QualityControl/jsconfig.json +++ b/QualityControl/jsconfig.json @@ -9,5 +9,7 @@ "public/**/*.js", "lib/**/*.js", "test/**/*.js", - "lib/database/migrations/*.mjs" ] + "lib/database/migrations/*.mjs", + "lib/database/seeders/*.mjs" + ] } diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index ed38adea6..449846f83 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -12,41 +12,49 @@ * or submit itself to any jurisdiction. */ +// Core dependencies import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { readFileSync } from 'fs'; +// External dependencies import { LogManager } from '@aliceo2/web-ui'; import { openFile, toJSON } from 'jsroot'; import { Kafka, logLevel } from 'kafkajs'; +// Services import { CcdbService } from './services/ccdb/CcdbService.js'; import { IntervalsService } from './services/Intervals.service.js'; import { StatusService } from './services/Status.service.js'; -import { JsonFileService } from './services/JsonFileService.js'; import { QcObjectService } from './services/QcObject.service.js'; import { FilterService } from './services/FilterService.js'; -import { BookkeepingService } from './services/BookkeepingService.js'; +import { LayoutService } from './services/layout/LayoutService.js'; +import { UserService } from './services/layout/UserService.js'; +import { RunModeService } from './services/RunModeService.js'; import { AliEcsSynchronizer } from './services/external/AliEcsSynchronizer.js'; +import { BookkeepingService } from './services/external/BookkeepingService.js'; +//Controllers import { LayoutController } from './controllers/LayoutController.js'; import { StatusController } from './controllers/StatusController.js'; import { ObjectController } from './controllers/ObjectController.js'; import { FilterController } from './controllers/FilterController.js'; import { UserController } from './controllers/UserController.js'; +//Database import { config } from './config/configProvider.js'; -import { LayoutRepository } from './repositories/LayoutRepository.js'; -import { UserRepository } from './repositories/UserRepository.js'; -import { ChartRepository } from './repositories/ChartRepository.js'; import { initDatabase } from './database/index.js'; import { SequelizeDatabase } from './database/SequelizeDatabase.js'; +import { setupRepositories } from './database/repositories/index.js'; + +//Middleware factories import { objectGetByIdValidationMiddlewareFactory } from './middleware/objects/objectGetByIdValidationMiddlewareFactory.js'; import { objectsGetValidationMiddlewareFactory } from './middleware/objects/objectsGetValidationMiddlewareFactory.js'; import { objectGetContentsValidationMiddlewareFactory } from './middleware/objects/objectGetContentsValidationMiddlewareFactory.js'; -import { RunModeService } from './services/RunModeService.js'; + +//DTOs import { KafkaConfigDto } from './dtos/KafkaConfigurationDto.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`; @@ -59,15 +67,12 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`; export const setupQcModel = async (eventEmitter) => { const logger = LogManager.getLogger(LOG_FACILITY); + // Load package metadata const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJSON = JSON.parse(readFileSync(`${__dirname}/../package.json`)); - const jsonFileService = new JsonFileService(config.dbFile || `${__dirname}/../db.json`); - if (config.database) { - initDatabase(new SequelizeDatabase(config?.database || {})); - } - + // Kafka setup (optional) if (config?.kafka?.enabled) { try { const validConfig = await KafkaConfigDto.validateAsync(config.kafka); @@ -85,47 +90,77 @@ export const setupQcModel = async (eventEmitter) => { } } - const layoutRepository = new LayoutRepository(jsonFileService); - const userRepository = new UserRepository(jsonFileService); - const chartRepository = new ChartRepository(jsonFileService); + // Database setup + const databaseConfig = config.database || {}; + if (!databaseConfig || Object.keys(databaseConfig).length === 0) { + logger.errorMessage('Database configuration is not provided. The application cannot be initialized'); + return; + } - const userController = new UserController(userRepository); - const layoutController = new LayoutController(layoutRepository); + const sequelizeDatabase = new SequelizeDatabase(databaseConfig); + initDatabase(sequelizeDatabase, { forceSeed: config?.database?.forceSeed, drop: config?.database?.drop }); + const repositories = setupRepositories(sequelizeDatabase); + const { + userRepository, + layoutRepository, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, + } = repositories; + + // Services + const layoutService = new LayoutService( + layoutRepository, + userRepository, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, + ); + const userService = new UserService(userRepository); const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); - const statusController = new StatusController(statusService); const ccdbService = CcdbService.setup(config.ccdb); statusService.dataService = ccdbService; - const qcObjectService = new QcObjectService(ccdbService, chartRepository, { openFile, toJSON }); + const qcObjectService = new QcObjectService(ccdbService, layoutService, { openFile, toJSON }); qcObjectService.refreshCache(); const intervalsService = new IntervalsService(); - const bookkeepingService = new BookkeepingService(config.bookkeeping); const filterService = new FilterService(bookkeepingService, config); const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter); - const objectController = new ObjectController(qcObjectService, runModeService); + // Controllers + const userController = new UserController(userService); + const layoutController = new LayoutController(layoutService); + const statusController = new StatusController(statusService); + const objectController = new ObjectController(qcObjectService, runModeService); const filterController = new FilterController(filterService, runModeService); + // Middleware const objectGetByIdValidation = objectGetByIdValidationMiddlewareFactory(filterService); const objectsGetValidation = objectsGetValidationMiddlewareFactory(filterService); const objectGetContentsValidation = objectGetContentsValidationMiddlewareFactory(filterService); + // Interval tasks initializeIntervals(intervalsService, qcObjectService, filterService, runModeService); + // Return API return { - userController, layoutController, + userController, statusService, statusController, objectController, intervalsService, filterController, - layoutRepository, - jsonFileService, + layoutService, + userService, objectGetByIdValidation, objectsGetValidation, objectGetContentsValidation, diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 2b5d3751f..e23cf81d3 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -15,12 +15,20 @@ import { setupQcModel } from './QCModel.js'; import { minimumRoleMiddleware } from './middleware/minimumRole.middleware.js'; import { UserRole } from './../common/library/userRole.enum.js'; + +// Middlewares import { layoutOwnerMiddleware } from './middleware/layouts/layoutOwner.middleware.js'; import { layoutIdMiddleware } from './middleware/layouts/layoutId.middleware.js'; -import { layoutServiceMiddleware } from './middleware/layouts/layoutService.middleware.js'; import { statusComponentMiddleware } from './middleware/status/statusComponent.middleware.js'; import { runStatusFilterMiddleware } from './middleware/filters/runStatusFilter.middleware.js'; import { runModeMiddleware } from './middleware/filters/runMode.middleware.js'; +import { getLayoutsMiddleware } from './middleware/layouts/layoutsGet.middleware.js'; +import { + validateCreateLayoutMiddleware, + validatePatchLayoutMiddleware, + validateUpdateLayoutMiddleware, +} from './middleware/layouts/layoutValidate.middleware.js'; +import { validateUserSession } from './middleware/validateUser.middleware.js'; /** * Adds paths and binds websocket to instance of HttpServer passed @@ -41,22 +49,22 @@ export const setup = async (http, ws, eventEmitter) => { */ const { layoutController, - objectController, - statusController, - statusService, userController, - layoutRepository, - jsonFileService, + statusService, + statusController, + objectController, filterController, + layoutService, + userService, objectGetByIdValidation, objectsGetValidation, objectGetContentsValidation, } = await setupQcModel(eventEmitter); statusService.ws = ws; + /* -------------------Objects ------------------------- */ http.get('/object/:id', objectGetByIdValidation, objectController.getObjectById.bind(objectController)); http.get('/object', objectGetContentsValidation, objectController.getObjectContent.bind(objectController)); - http.get( '/objects', objectsGetValidation, @@ -64,32 +72,37 @@ export const setup = async (http, ws, eventEmitter) => { objectController.getObjects.bind(objectController), ); - http.get('/layouts', layoutController.getLayoutsHandler.bind(layoutController)); - http.get('/layout/:id', layoutController.getLayoutHandler.bind(layoutController)); + /* ------------------- Layouts ------------------------ */ + http.get('/layouts', getLayoutsMiddleware, layoutController.getLayoutsHandler.bind(layoutController)); + http.get('/layout/:id', layoutIdMiddleware(layoutService), layoutController.getLayoutHandler.bind(layoutController)); http.get('/layout', layoutController.getLayoutByNameHandler.bind(layoutController)); - http.post('/layout', layoutController.postLayoutHandler.bind(layoutController)); + http.post( + '/layout', + validateCreateLayoutMiddleware, + layoutController.postLayoutHandler.bind(layoutController), + ); http.put( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), - layoutOwnerMiddleware(layoutRepository), + layoutIdMiddleware(layoutService), + layoutOwnerMiddleware(layoutService, userService), + validateUpdateLayoutMiddleware, layoutController.putLayoutHandler.bind(layoutController), ); http.patch( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), + validatePatchLayoutMiddleware, + layoutIdMiddleware(layoutService), minimumRoleMiddleware(UserRole.GLOBAL), layoutController.patchLayoutHandler.bind(layoutController), ); http.delete( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), - layoutOwnerMiddleware(layoutRepository), + layoutIdMiddleware(layoutService), + layoutOwnerMiddleware(layoutService, userService), layoutController.deleteLayoutHandler.bind(layoutController), ); + /* ------------------- Status ------------------------- */ http.get('/status/gui', statusController.getQCGStatus.bind(statusController), { public: true }); http.get( '/status/:service', @@ -98,8 +111,10 @@ export const setup = async (http, ws, eventEmitter) => { { public: true }, ); - http.get('/checkUser', userController.addUserHandler.bind(userController)); + /* ------------------- Users -------------------------- */ + http.get('/checkUser', validateUserSession, userController.addUserHandler.bind(userController)); + /* ------------------- Filters ------------------------ */ http.get('/filter/configuration', filterController.getFilterConfigurationHandler.bind(filterController)); http.get( '/filter/run-status/:runNumber', diff --git a/QualityControl/lib/config/database.js b/QualityControl/lib/config/database.js index b56e9399d..bfc4fccb2 100644 --- a/QualityControl/lib/config/database.js +++ b/QualityControl/lib/config/database.js @@ -29,5 +29,7 @@ export function getDbConfig(config) { timezone: config.timezone ?? '+00:00', logging: config.logging ?? false, retryThrottle: config.retryThrottle ?? 5000, + forceSeed: config.forceSeed ?? false, + drop: config.drop ?? false, }; }; diff --git a/QualityControl/lib/controllers/LayoutController.js b/QualityControl/lib/controllers/LayoutController.js index c014da497..642d4ca65 100644 --- a/QualityControl/lib/controllers/LayoutController.js +++ b/QualityControl/lib/controllers/LayoutController.js @@ -15,22 +15,20 @@ 'use strict'; import assert from 'assert'; -import { LayoutDto, LayoutsGetDto } from './../dtos/LayoutDto.js'; -import { LayoutPatchDto } from './../dtos/LayoutPatchDto.js'; import { InvalidInputError, LogManager, - NotFoundError, updateAndSendExpressResponseFromNativeError, } from '@aliceo2/web-ui'; +import { LayoutAdapter } from './adapters/layout-adapter.js'; /** - * @typedef {import('../repositories/LayoutRepository.js').LayoutRepository} LayoutRepository + * @typedef {import('../services/layout/LayoutService.js').LayoutService} LayoutService */ -const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg'}/layout-ctrl`); +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-ctrl`; /** * Gateway for all HTTP requests with regards to QCG Layouts @@ -38,49 +36,41 @@ const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg' export class LayoutController { /** * Setup Layout Controller: - * @param {LayoutRepository} layoutRepository - The repository for layout data + * @param {LayoutService} layoutService - The repository for layout data */ - constructor(layoutRepository) { - assert(layoutRepository, 'Missing layout repository'); + constructor(layoutService) { + assert(layoutService, 'Missing layout service'); /** - * @type {LayoutRepository} + * @type {LayoutService} */ - this._layoutRepository = layoutRepository; + this._layoutService = layoutService; + + this._logger = LogManager.getLogger(LOG_FACILITY); } /** * HTTP GET endpoint for retrieving a list of layouts - * * Can be filtered by "owner_id" or "objectPath" using filter.objectPath - * * if no owner_id is provided, all layouts will be fetched; * @param {Request} req - HTTP request object with information on owner_id + * @param {string} [req.query.owner_id] - Optional owner_id to filter layouts by + * @param {string} [req.query.filter] - Optional filter object as JSON string + * @param {string} [req.query.fields] - Optional comma-separated list of fields to include in the response * @param {Response} res - HTTP response object to provide layouts information * @returns {undefined} */ async getLayoutsHandler(req, res) { - let fields = undefined; - let owner_id = undefined; - let filter = undefined; - + const { owner_id, filter, fields } = req.query; try { - const validated = await LayoutsGetDto.validateAsync(req.query); - ({ fields, owner_id, filter } = validated); - } catch (error) { - const responseError = error.isJoi ? - new InvalidInputError(`Invalid query parameters: ${error.details[0].message}`) : - new Error('Unable to process request'); - - logger.errorMessage(`Error validating query parameters: ${error}`); - return updateAndSendExpressResponseFromNativeError(res, responseError); - } - - try { - const layouts = await this._layoutRepository.listLayouts({ fields, filter: { ...filter, owner_id } }); - return res.status(200).json(layouts); + const layouts = + await this._layoutService.getLayoutsByFilters({ ...filter, owner_id }); + const adaptedLayouts = layouts.map((layout) => LayoutAdapter.adaptLayoutForExpressAPI(layout, fields)); + res.status(200).json(adaptedLayouts); + return; } catch (error) { - logger.errorMessage(`Error retrieving layouts: ${error}`); - return updateAndSendExpressResponseFromNativeError(res, new Error('Unable to retrieve layouts')); + this._logger.errorMessage(`Error retrieving layouts: ${error.message || error}`); + updateAndSendExpressResponseFromNativeError(res, error); + return; } } @@ -91,16 +81,12 @@ export class LayoutController { * @returns {undefined} */ async getLayoutHandler(req, res) { - const { id } = req.params; - if (!id.trim()) { - updateAndSendExpressResponseFromNativeError(res, new InvalidInputError('Missing parameter "id" of layout')); - } else { - try { - const layout = await this._layoutRepository.readLayoutById(id); - res.status(200).json(layout); - } catch (error) { - updateAndSendExpressResponseFromNativeError(res, error); - } + try { + const adaptedLayout = LayoutAdapter.adaptLayoutForExpressAPI(req.layout); + res.status(200).json(adaptedLayout); + } catch (error) { + this._logger.errorMessage(`Error retrieving layout by ID: ${error.message || error}`); + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -126,9 +112,11 @@ export class LayoutController { return; } try { - const layout = await this._layoutRepository.readLayoutByName(layoutName); - res.status(200).json(layout); + const layout = await this._layoutService.getLayoutByName(layoutName); + const adaptedLayout = LayoutAdapter.adaptLayoutForExpressAPI(layout); + res.status(200).json(adaptedLayout); } catch (error) { + this._logger.errorMessage(`Error retrieving layout by name: ${error.message || error}`); updateAndSendExpressResponseFromNativeError(res, error); } } @@ -142,30 +130,11 @@ export class LayoutController { * @returns {undefined} */ async putLayoutHandler(req, res) { - const { id } = req.params; - let layoutProposed = {}; try { - layoutProposed = await LayoutDto.validateAsync(req.body); - } catch (error) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Failed to update layout: ${error?.details?.[0]?.message || ''}`), - ); - return; - } - try { - const layouts = await this._layoutRepository.listLayouts({ name: layoutProposed.name }); - const layoutExistsWithName = layouts.every((layout) => layout.id !== layoutProposed.id); - if (layouts.length > 0 && layoutExistsWithName) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Proposed layout name: ${layoutProposed.name} already exists`), - ); - return; - } - const layout = await this._layoutRepository.updateLayout(id, layoutProposed); - res.status(201).json({ id: layout }); + const updatedLayoutId = await this._layoutService.putLayout(req.params.id, req.body); + res.status(200).json({ id: updatedLayoutId }); } catch (error) { + this._logger.errorMessage(`Error updating layout: ${error.message || error}`); updateAndSendExpressResponseFromNativeError(res, error); } } @@ -177,12 +146,12 @@ export class LayoutController { * @returns {undefined} */ async deleteLayoutHandler(req, res) { - const { id } = req.params; try { - const result = await this._layoutRepository.deleteLayout(id); + const result = await this._layoutService.removeLayout(req.params.id); res.status(200).json(result); - } catch { - updateAndSendExpressResponseFromNativeError(res, new Error(`Unable to delete layout with id: ${id}`)); + } catch (error) { + this._logger.errorMessage(`Error updating layout: ${error.message || error}`); + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -193,29 +162,13 @@ export class LayoutController { * @returns {undefined} */ async postLayoutHandler(req, res) { - let layoutProposed = {}; - try { - layoutProposed = await LayoutDto.validateAsync(req.body); - } catch (error) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Failed to validate layout: ${error?.details[0]?.message || ''}`), - ); - return; - } + const layoutProposed = req.body; try { - const layouts = await this._layoutRepository.listLayouts({ name: layoutProposed.name }); - if (layouts.length > 0) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Proposed layout name: ${layoutProposed.name} already exists`), - ); - return; - } - const result = await this._layoutRepository.createLayout(layoutProposed); + const result = await this._layoutService.postLayout(layoutProposed); res.status(201).json(result); - } catch { - updateAndSendExpressResponseFromNativeError(res, new Error('Unable to create new layout')); + } catch (error) { + this._logger.errorMessage(`Error creating layout: ${error.message || error}`); + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -226,28 +179,12 @@ export class LayoutController { * @returns {undefined} */ async patchLayoutHandler(req, res) { - const { id } = req.params; - let layout = {}; try { - layout = await LayoutPatchDto.validateAsync(req.body); + const patchedLayoutId = await this._layoutService.patchLayout(req.params.id, req.body); + res.status(200).json({ id: patchedLayoutId }); } catch (error) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Failed to validate layout: ${error?.details[0]?.message || ''}`), - ); - return; - } - try { - this._layoutRepository.readLayoutById(id); - } catch { - updateAndSendExpressResponseFromNativeError(res, new NotFoundError(`Unable to find layout with id: ${id}`)); - return; - } - try { - const updatedLayoutId = await this._layoutRepository.updateLayout(id, layout); - res.status(201).json({ id: updatedLayoutId }); - } catch { - updateAndSendExpressResponseFromNativeError(res, new Error(`Unable to update layout with id: ${id}`)); + this._logger.errorMessage(`Error patching layout: ${error.message || error}`); + updateAndSendExpressResponseFromNativeError(res, error); return; } } diff --git a/QualityControl/lib/controllers/UserController.js b/QualityControl/lib/controllers/UserController.js index 9c67da4ca..b39bfab70 100644 --- a/QualityControl/lib/controllers/UserController.js +++ b/QualityControl/lib/controllers/UserController.js @@ -18,7 +18,7 @@ import { LogLevel, LogManager } from '@aliceo2/web-ui'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/user-controller`; /** - * @typedef {import('../repositories/UserRepository.js').UserRepository} UserRepository + * @typedef {import('../services/layout/UserService.js').UserService} UserService */ /** @@ -27,19 +27,19 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/user-controll export class UserController { /** * Creates an instance of UserController. - * @param {UserRepository} userRepository - An instance of UserRepository to interact with user data. - * @throws {Error} Throws an error if the UserRepository is not provided. + * @param {UserService} userService - An instance of UserService to interact with user data. + * @throws {Error} Throws an error if the UserService is not provided. */ - constructor(userRepository) { - assert(userRepository, 'Missing User Repository'); + constructor(userService) { + assert(userService, 'Missing User Service'); this._logger = LogManager.getLogger(LOG_FACILITY); /** - * User repository for interacting with user data. - * @type {UserRepository} + * User service for interacting with user data. + * @type {UserService} * @private */ - this._userRepository = userRepository; + this._userService = userService; } /** @@ -52,40 +52,13 @@ export class UserController { const { personid: id, name, username } = req.session; try { - this._validateUser(username, name, id); - await this._userRepository.createUser({ id, name, username }); + await this._userService.createNewUser({ id, name, username }); res.status(200).json({ ok: true }); } catch (err) { - if (err.stack) { - this._logger.trace(err); - } - this._logger.errorMessage('Unable to add user to memory', { + this._logger.errorMessage(`Unable to add user to memory: ${err.message || err}`, { level: LogLevel.SUPPORT, }); res.status(502).json({ ok: false, message: 'Unable to add user to memory' }); } } - - /** - * Validate that user's parameters contains all the mandatory fields - * @param {string} username - expected username - * @param {string} name - expected name of the user - * @param {number} id - cernid of the user - * @returns {undefined} - * @throws {Error} - */ - _validateUser(username, name, id) { - if (!username) { - throw new Error('username of the user is mandatory'); - } - if (!name) { - throw new Error('name of the user is mandatory'); - } - if (id === null || id === undefined || id === '') { - throw new Error('id of the user is mandatory'); - } - if (isNaN(id)) { - throw new Error('id of the user must be a number'); - } - } } diff --git a/QualityControl/lib/controllers/adapters/layout-adapter.js b/QualityControl/lib/controllers/adapters/layout-adapter.js new file mode 100644 index 000000000..45f8fc7f0 --- /dev/null +++ b/QualityControl/lib/controllers/adapters/layout-adapter.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +export class LayoutAdapter { + /** + * Transforms a backend layout object into the format + * expected by the Express API frontend. + * @param {object} layout Adapted layout object in frontend format. + * @param {string[]} [fields] Optional list of fields to include in the returned object. + * @returns {object} The layout object in frontend format. + * @throws {Error} If the layout cannot be adapted. + */ + static adaptLayoutForExpressAPI(layout, fields) { + try { + const layoutAdapted = { + id: layout.id, + name: layout.name, + owner_id: layout.owner.id, + owner_name: layout.owner.name, + description: layout.description, + displayTimestamp: layout.display_timestamp, + autoTabChange: layout.auto_tab_change_interval, + tabs: layout.tabs.map((tab) => ({ + id: tab.id, + name: tab.name, + columns: tab.column_count, + objects: tab.gridTabCells.map((cell) => ({ + id: cell.chart.id, + x: cell.col || 0, + y: cell.row || 0, + h: cell.row_span || 1, + w: cell.col_span || 1, + name: cell.chart.object_name, + options: cell.chart.chartOptions.map((chartOption) => chartOption.option.name), + autoSize: false, + ignoreDefaults: cell.chart.ignore_defaults || false, + })), + })), + isOfficial: layout.is_official, + collaborators: [], + }; + if (Array.isArray(fields) && fields.length > 0) { + const filteredLayout = {}; + for (const field of fields) { + if (field in layoutAdapted) { + filteredLayout[field] = layoutAdapted[field]; + } + } + return filteredLayout; + } + + return layoutAdapted; + } catch (error) { + throw new Error(`Error adapting layout: ${error.message}`); + } + } +} diff --git a/QualityControl/lib/database/SequelizeDatabase.js b/QualityControl/lib/database/SequelizeDatabase.js index 9542e4ff2..d32e00e6f 100644 --- a/QualityControl/lib/database/SequelizeDatabase.js +++ b/QualityControl/lib/database/SequelizeDatabase.js @@ -19,7 +19,7 @@ import { fileURLToPath } from 'url'; import { createUmzug } from './umzug.js'; import { getDbConfig } from '../config/database.js'; import models from './models/index.js'; -import { SequelizeStorage } from 'umzug'; +import { memoryStorage, SequelizeStorage } from 'umzug'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/database`; @@ -29,6 +29,8 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/database`; export class SequelizeDatabase { constructor(config) { this._logger = LogManager.getLogger(LOG_FACILITY); + const __filename = fileURLToPath(import.meta.url); + this.__dirname = dirname(__filename); if (!config) { this._logger.warnMessage('No configuration provided for SequelizeDatabase. Using default configuration.'); @@ -99,23 +101,60 @@ export class SequelizeDatabase { async migrate() { this._logger.debugMessage('Executing pending migrations...'); try { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); const umzug = createUmzug( this.sequelize, - join(__dirname, 'migrations'), + join(this.__dirname, 'migrations'), new SequelizeStorage({ sequelize: this.sequelize, }), ); + const pendingMigrations = await umzug.pending(); await umzug.up(); - this._logger.infoMessage('Migrations completed successfully.'); + this._logger.infoMessage(pendingMigrations.length > 0 + ? `Executed ${pendingMigrations.length} pending migrations` + : 'No pending migrations to execute'); } catch (error) { this._logger.errorMessage(`Error executing migrations: ${error}`); throw error; } } + /** + * Executes seed files to populate the database with initial data. + * @returns {Promise} + */ + async seed() { + try { + const umzug = createUmzug( + this.sequelize, + join(this.__dirname, 'seeders'), + memoryStorage(), + ); + await umzug.up(); + this._logger.infoMessage('Seeders executed successfully'); + } catch (error) { + this._logger.errorMessage(`Error while executing seeders: ${error}`); + return Promise.reject(error); + } + } + + /** + * Drops all tables in the database. + * @returns {Promise} + */ + async dropAllTables() { + this._logger.warnMessage('Dropping all tables!'); + + try { + await this.sequelize.getQueryInterface().dropAllTables(); + } catch (error) { + this._logger.errorMessage(`Error while dropping all tables: ${error}`); + return Promise.reject(error); + } + + this._logger.infoMessage('Dropped all tables!'); + } + /** * Gets the models. * @returns {object} The models. diff --git a/QualityControl/lib/database/index.js b/QualityControl/lib/database/index.js index 7553cdfc6..319b6ac76 100644 --- a/QualityControl/lib/database/index.js +++ b/QualityControl/lib/database/index.js @@ -13,19 +13,36 @@ */ import { LogManager } from '@aliceo2/web-ui'; +import { isRunningInDevelopment, isRunningInProduction, isRunningInTest } from '../utils/environment.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/database`; /** * Initializes the database connection and runs migrations. * @param {object} sequelizeDatabase - The Sequelize database instance. + * @param {object} options - Options for database initialization. + * @param {boolean} [options.forceSeed=false] - Whether to force seeding the database. + * @param {boolean} [options.drop=false] - Whether to drop existing tables before migration (development only). * @returns {Promise} A promise that resolves when the database is initialized. */ -export const initDatabase = async (sequelizeDatabase) => { +export const initDatabase = async (sequelizeDatabase, { forceSeed = false, drop = false }) => { const _logger = LogManager.getLogger(LOG_FACILITY); try { - await sequelizeDatabase.connect(); - await sequelizeDatabase.migrate(); + if (isRunningInTest) { + await sequelizeDatabase.dropAllTables(); + await sequelizeDatabase.migrate(); + await sequelizeDatabase.seed(); + } else if (isRunningInDevelopment) { + if (drop) { + await sequelizeDatabase.dropAllTables(); + } + await sequelizeDatabase.migrate(); + if (forceSeed) { + await sequelizeDatabase.seed(); + } + } else if (isRunningInProduction) { + await sequelizeDatabase.migrate(); + } } catch (error) { _logger.errorMessage(`Failed to initialize database: ${error.message}`); } diff --git a/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs b/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs index e97b52ab0..d934a942f 100644 --- a/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs +++ b/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs @@ -23,7 +23,6 @@ export const up = async (queryInterface, Sequelize) => { type: Sequelize.INTEGER, allowNull: false, primaryKey: true, - autoIncrement: true, }, username: { type: Sequelize.STRING(250), @@ -56,6 +55,7 @@ export const up = async (queryInterface, Sequelize) => { name: { type: Sequelize.STRING(40), allowNull: false, + unique: true, }, description: { type: Sequelize.STRING(100), diff --git a/QualityControl/lib/database/models/GridTabCell.js b/QualityControl/lib/database/models/GridTabCell.js index a98d14867..6ca389ce2 100644 --- a/QualityControl/lib/database/models/GridTabCell.js +++ b/QualityControl/lib/database/models/GridTabCell.js @@ -74,8 +74,8 @@ export default (sequelize) => { createdAt: 'created_at', updatedAt: 'updated_at', uniqueKeys: { - unique_grid_tab_cells: { - fields: ['chart_id', 'row', 'col', 'tab_id'], + unique_chart_per_cell: { + fields: ['chart_id', 'row', 'col'], }, }, }); diff --git a/QualityControl/lib/database/models/Layout.js b/QualityControl/lib/database/models/Layout.js index 3068bc25b..f667998fb 100644 --- a/QualityControl/lib/database/models/Layout.js +++ b/QualityControl/lib/database/models/Layout.js @@ -28,6 +28,7 @@ export default (sequelize) => { name: { type: STRING(40), allowNull: false, + unique: true, }, description: { type: STRING(100), diff --git a/QualityControl/lib/database/models/User.js b/QualityControl/lib/database/models/User.js index e6c67a6bc..760cf4c26 100644 --- a/QualityControl/lib/database/models/User.js +++ b/QualityControl/lib/database/models/User.js @@ -24,7 +24,6 @@ export default (sequelize) => { type: INTEGER, primaryKey: true, allowNull: false, - autoIncrement: true, }, username: { type: STRING(250), diff --git a/QualityControl/lib/database/queries/getLayoutIdsWithChartObjectName.sql b/QualityControl/lib/database/queries/getLayoutIdsWithChartObjectName.sql new file mode 100644 index 000000000..896c6deca --- /dev/null +++ b/QualityControl/lib/database/queries/getLayoutIdsWithChartObjectName.sql @@ -0,0 +1,6 @@ + SELECT DISTINCT l.id AS layout_id + FROM layouts l + JOIN tabs t ON t.layout_id = l.id + JOIN grid_tab_cells gtc ON gtc.tab_id = t.id + JOIN charts c ON c.id = gtc.chart_id + WHERE c.object_name LIKE :objectPath diff --git a/QualityControl/lib/database/repositories/BaseRepository.js b/QualityControl/lib/database/repositories/BaseRepository.js new file mode 100644 index 000000000..0ea6a57d1 --- /dev/null +++ b/QualityControl/lib/database/repositories/BaseRepository.js @@ -0,0 +1,39 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +/** @typedef {import('sequelize').Model} Model */ + +/** + * Parent class for repositories + */ +export class BaseRepository { + /** + * Creates an instance of the BaseRepository class + * @param {Model} model Sequelize model to be used by the repository + * @throws {Error} Throws an error if model is not provided. + */ + constructor(model) { + if (!model) { + throw new Error('A Sequelize model must be provided to BaseRepository.'); + } + this._model = model; + } + + /** + * The Sequelize model associated with this repository. + * @returns {Model} the Sequelize model + */ + get model() { + return this._model; + } +} diff --git a/QualityControl/lib/database/repositories/ChartOptionsRepository.js b/QualityControl/lib/database/repositories/ChartOptionsRepository.js new file mode 100644 index 000000000..0dc483a2c --- /dev/null +++ b/QualityControl/lib/database/repositories/ChartOptionsRepository.js @@ -0,0 +1,62 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} ChartOptionAttributes + * @property {number} chart_id chart ID + * @property {number} option_id - option ID + */ + +/** + * Repository for managing chart options. + */ +export class ChartOptionsRepository extends BaseRepository { + constructor(chartOptionModel) { + super(chartOptionModel); + } + + /** + * Creates a new chart option. + * @param {Partial} optionData - Data for the new chart option. + * @param {object} options - Additional options for the creation (e.g. transaction). + * @returns {Promise} The created chart option. + */ + async createChartOption(optionData, options = {}) { + return this.model.create(optionData, { ...options }); + } + + /** + * Finds all chart options by chart ID. + * @param {number} chartId - Chart identifier. + * @param {object} options - Additional options for the query (e.g. transaction). + * @returns {Promise} List of chart options. + */ + async findChartOptionsByChartId(chartId, options = {}) { + return this.model.findAll({ where: { chart_id: chartId }, ...options }); + } + + /** + * Deletes a chart option by chart and option ID. + * @param {object} params - identifiers + * @param {number} params.chartId - Chart identifier. + * @param {number} params.optionId - Option identifier. + * @param {object} options - Additional options for the deletion (e.g. transaction). + * @returns {Promise} Number of deleted records. + */ + async deleteChartOption(params, options = {}) { + const { chartId, optionId } = params; + return this.model.destroy({ where: { chart_id: chartId, option_id: optionId }, ...options }); + } +} diff --git a/QualityControl/lib/database/repositories/ChartRepository.js b/QualityControl/lib/database/repositories/ChartRepository.js new file mode 100644 index 000000000..7a287ed2b --- /dev/null +++ b/QualityControl/lib/database/repositories/ChartRepository.js @@ -0,0 +1,72 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} ChartAttributes + * @property {string} id id of the chart + * @property {string} object_name name of the object + * @property {boolean} ignore_defaults whether to ignore defaults + */ + +/** + * Repository for managing chart options. + */ +export class ChartRepository extends BaseRepository { + constructor(chartModel) { + super(chartModel); + } + + /** + * Finds a chart by its ID. + * @param {string} chartId id of the chart + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} The chart or null if not found. + */ + async findChartById(chartId, options = {}) { + return this.model.findByPk(chartId, { ...options }); + } + + /** + * Creates a new chart. + * @param {Partial} chartData new chart data + * @param {object} options additional options for the creation (e.g. transaction) + * @returns {Promise} The created chart. + */ + async createChart(chartData, options = {}) { + return this.model.create(chartData, { ...options }); + } + + /** + * Updates an existing chart. + * @param {string} chartId id of the chart to update + * @param {Partial} updateData new chart data + * @param {object} options additional options for the update (e.g. transaction) + * @returns {Promise} Number of updated rows (0 or 1). + */ + async updateChart(chartId, updateData, options = {}) { + const [updatedCount] = await this.model.update(updateData, { where: { id: chartId }, ...options }); + return updatedCount; + } + + /** + * Deletes a chart. + * @param {string} chartId id of the chart + * @param {object} options additional options for the deletion (e.g. transaction) + * @returns {Promise} Number of deleted rows (0 or 1). + */ + async deleteChart(chartId, options = {}) { + return this.model.destroy({ where: { id: chartId }, ...options }); + } +} diff --git a/QualityControl/lib/database/repositories/GridTabCellRepository.js b/QualityControl/lib/database/repositories/GridTabCellRepository.js new file mode 100644 index 000000000..e970e922b --- /dev/null +++ b/QualityControl/lib/database/repositories/GridTabCellRepository.js @@ -0,0 +1,89 @@ +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} GridTabCellAttributes + * @property {number} id - auto-incremented ID + * @property {string} chart_id - ID of the associated chart + * @property {number} row - position in the grid + * @property {number} col - position in the grid + * @property {string} tab_id - ID of the associated tab + * @property {number} [row_span] - optional row span + * @property {number} [col_span] - optional column span + * @property {Date} created_at - timestamp when the record was created + * @property {Date} updated_at - timestamp when the record was last updated + */ + +/** + * Repository for managing grid tab cells. + */ +export class GridTabCellRepository extends BaseRepository { + constructor(gridTabCellModel) { + super(gridTabCellModel); + } + + /** + * Finds all grid tab cells by tab ID. + * @param {string} tabId id of the tab + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} List of grid tab cells + */ + async findByTabId(tabId, options = {}) { + return this.model.findAll({ where: { tab_id: tabId }, ...options }); + } + + /** + * Finds grid tab cells as a plain object by chart ID. + * @param {string} chartId id of the chart + * @returns {Promise} List of grid tab cells with associated tab and chart details + */ + async findObjectByChartId(chartId) { + const include = [ + { + association: 'tab', + include: [{ association: 'layout', attributes: ['name'] }], + attributes: ['name'], + }, + { + association: 'chart', + include: [ + { + association: 'chartOptions', + include: [ + { + association: 'option', + attributes: ['name'], + }, + ], + }, + ], + attributes: ['object_name', 'ignore_defaults'], + }, + ]; + + return this.model.findOne({ where: { chart_id: chartId }, include }); + } + + /** + * Creates a new grid tab cell. + * @param {Partial} cellData new data + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} Created grid tab cell + */ + async createGridTabCell(cellData, options = {}) { + return this.model.create(cellData, { ...options }); + } + + /** + * Updates a grid tab cell by ID. + * @param {number} id ID of the grid tab cell to update + * @param {Partial} updateData updated data + * @param {object} options additional options for the update (e.g. transaction) + * @returns {Promise} Number of updated rows + */ + async updateGridTabCell(id, updateData, options = {}) { + const { chartId, tabId } = id; + const [updatedCount] = + await this.model.update(updateData, { where: { chart_id: chartId, tab_id: tabId }, ...options }); + return updatedCount; + } +} diff --git a/QualityControl/lib/database/repositories/LayoutRepository.js b/QualityControl/lib/database/repositories/LayoutRepository.js new file mode 100644 index 000000000..004cf15f6 --- /dev/null +++ b/QualityControl/lib/database/repositories/LayoutRepository.js @@ -0,0 +1,188 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { InvalidInputError } from '@aliceo2/web-ui'; +import { BaseRepository } from './BaseRepository.js'; +import { Op, UniqueConstraintError } from 'sequelize'; +import path from 'path'; +import fs from 'fs'; + +/** + * @typedef {object} LayoutAttributes + * @property {string} id - UUID + * @property {string} name - unique name of the layout + * @property {string} [description] - optional description of the layout + * @property {boolean} display_timestamp - whether to display the timestamp + * @property {number} auto_tab_change_interval - interval for automatic tab change in seconds + * @property {string} owner_username - username of the owner + * @property {boolean} is_official - whether the layout is official + * @property {Date} created_at - timestamp when the layout was created + * @property {Date} updated_at - timestamp when the layout was last updated + */ + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const getLayoutsByChartQueryPath = path.join(__dirname, '../queries/getLayoutIdsWithChartObjectName.sql'); + +/** + * Repository for managing layouts. + */ +export class LayoutRepository extends BaseRepository { + constructor(layoutModel) { + super(layoutModel); + + // Build common include structure for all find queries + this._layoutInfoToInclude = [ + { + association: 'tabs', + required: true, + include: [ + { + association: 'gridTabCells', + include: [ + { + association: 'chart', + include: [ + { + association: 'chartOptions', + include: [{ association: 'option' }], + }, + ], + }, + ], + }, + ], + }, + { + association: 'owner', + attributes: ['id', 'username', 'name'], + }, + ]; + this.getLayoutsByChartQuery = fs.readFileSync(getLayoutsByChartQueryPath, 'utf8'); + } + + /** + * Finds a layout by its ID + * @param {string} id id of the layout + * @returns {Promise} Layout found or null + */ + async findLayoutById(id) { + return this.model.findByPk(id, { include: this._layoutInfoToInclude }); + } + + /** + * Finds a layout by its name + * @param {string} name name of the layout + * @returns {Promise} Layout found or null + */ + async findLayoutByName(name) { + return this.model.findOne({ + where: { name }, + include: this._layoutInfoToInclude, + }); + } + + /** + * Finds layouts + * @returns {Promise} Array of layouts found + */ + async findAllLayouts() { + return this.model.findAll({ + include: this._layoutInfoToInclude, + }); + } + + /** + * Finds layouts by filters using Op.and and optionally selects specific fields. + * @param {object} filters key-value pairs to filter the layouts + * @returns {Promise} Array of layouts found + */ + async findLayoutsByFilters(filters) { + const { objectPath } = filters || {}; + const whereClause = {}; + if (objectPath) { + const layoutIds = await this._getLayoutIdsByObjectPath(objectPath); + + if (!layoutIds.length) { + return []; + } + whereClause.id = { [Op.in]: layoutIds }; + } + return this.model.findAll({ + where: whereClause, + include: this._layoutInfoToInclude, + }); + } + + /** + * Helper function to get layout IDs by object path + * @param {string} objectPath partial object path to search for + * @returns {Promise} Array of layout IDs + */ + async _getLayoutIdsByObjectPath(objectPath) { + const results = await this.model.sequelize.query(this.getLayoutsByChartQuery, { + replacements: { objectPath: `%${objectPath}%` }, + type: this.model.sequelize.QueryTypes.SELECT, + }); + return results.map((r) => r.layout_id); + } + + /** + * Creates a new layout + * @param {Partial} layoutData new layout + * @param {object} options Sequelize create options (e.g. transaction) + * @returns {Promise} The created layout + * @throws {InvalidInputError} If a layout with the same unique fields (e.g., name) already exists + * @throws {Error} If an error occurs creating the layout + */ + async createLayout(layoutData, options = {}) { + try { + const newLayout = await this.model.create(layoutData, { ...options }); + return newLayout; + } catch (error) { + if (error instanceof UniqueConstraintError) { + const field = error.errors?.[0]?.path || 'name'; + throw new InvalidInputError(`A layout with the same ${field} already exists.`); + } + throw error; + } + } + + /** + * Updates an existing layout by ID + * @param {string} id id of the layout to update + * @param {Partial} updateData updated layout + * @param {object} options Sequelize update options (e.g. transaction) + * @returns {Promise} Number of updated rows + */ + async updateLayout(id, updateData, options = {}) { + try { + const [updatedCount] = await this.model.update(updateData, { where: { id }, ...options }); + return updatedCount; + } catch (error) { + if (error instanceof UniqueConstraintError) { + const field = error.errors?.[0]?.path || 'name'; + throw new InvalidInputError(`A layout with the same ${field} already exists.`); + } + throw error; + } + } + + /** + * Deletes a layout by ID + * @param {string} id Id of the layout to delete + * @returns {Promise} Number of deleted rows + */ + async deleteLayout(id) { + return this.model.destroy({ where: { id } }); + } +} diff --git a/QualityControl/lib/database/repositories/OptionRepository.js b/QualityControl/lib/database/repositories/OptionRepository.js new file mode 100644 index 000000000..b24f08cb8 --- /dev/null +++ b/QualityControl/lib/database/repositories/OptionRepository.js @@ -0,0 +1,43 @@ +/** + * @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. + */ + +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} OptionAttributes + * @property {string} id - UUID + * @property {string} name - unique name of the option + * @property {string} type - type of the option (e.g., string, number, boolean) + * @property {Date} created_at - timestamp when the option was created + * @property {Date} updated_at - timestamp when the option was last updated + */ + +/** + * Repository class for managing Option entities. + */ +export class OptionRepository extends BaseRepository { + constructor(optionModel) { + super(optionModel); + } + + /** + * Retrieves option by name + * @param {string} name The name of the option + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} The option found or null if not found + */ + async findOptionByName(name, options = {}) { + return this.model.findOne({ where: { name }, ...options }); + } +} diff --git a/QualityControl/lib/database/repositories/TabRepository.js b/QualityControl/lib/database/repositories/TabRepository.js new file mode 100644 index 000000000..88cc5ed9b --- /dev/null +++ b/QualityControl/lib/database/repositories/TabRepository.js @@ -0,0 +1,86 @@ +/** + * @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. + */ + +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} TabAttributes + * @property {string} id - UUID + * @property {string} name - name of the tab + * @property {string} layout_id - ID of the associated layout + * @property {number} column_count - number of columns in the tab + * @property {Date} created_at - timestamp when the tab was created + * @property {Date} updated_at - timestamp when the tab was last updated + */ + +/** + * Repository for managing tabs. + */ +export class TabRepository extends BaseRepository { + constructor(tabModel) { + super(tabModel); + } + + /** + * Finds all tabs by layout ID + * @param {string} layoutId id of the layout + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} List of tabs found + */ + async findTabsByLayoutId(layoutId, options = {}) { + return this.model.findAll({ where: { layout_id: layoutId }, ...options }); + } + + /** + * Finds a tab by its ID + * @param {string} id id of the tab + * @param {object} options additional options for the query (e.g. transaction) + * @returns {Promise} The tab or null if not found + */ + async findTabById(id, options = {}) { + return this.model.findByPk(id, options); + } + + /** + * Creates a new tab + * @param {Partial} tabData new tab + * @param {object} options - Sequelize options (e.g., transaction) + * @returns {Promise} The created tab + */ + async createTab(tabData, options = {}) { + return this.model.create(tabData, options); + } + + /** + * Updates an existing tab by ID + * @param {string} id id of the tab + * @param {Partial} updateData updated tab + * @param {object} options - Sequelize options (e.g., transaction) + * @returns {Promise} Number of updated rows + */ + async updateTab(id, updateData, options = {}) { + const [updatedCount] = await this.model.update(updateData, { where: { id }, ...options }); + return updatedCount; + } + + /** + * Deletes a tab by ID + * @param {string} id id of the tab + * @param {object} options - Sequelize options (e.g., transaction) + * @returns {Promise} Number of deleted rows + */ + async deleteTab(id, options = {}) { + return this.model.destroy({ where: { id }, ...options }); + } +} diff --git a/QualityControl/lib/database/repositories/UserRepository.js b/QualityControl/lib/database/repositories/UserRepository.js new file mode 100644 index 000000000..691b858b8 --- /dev/null +++ b/QualityControl/lib/database/repositories/UserRepository.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { BaseRepository } from './BaseRepository.js'; + +/** + * @typedef {object} UserAttributes + * @property {string} id - UUID + * @property {string} username - unique username + * @property {string} name - full name of the user + * @property {Date} created_at - date of creation + * @property {Date} updated_at - date of last update + */ + +/** + * Repository for managing users. + */ +export class UserRepository extends BaseRepository { + /** + * Creates an instance of the UserRepository + * @param {typeof User} userModel - Sequelize User model + */ + constructor(userModel) { + super(userModel); + } + + /** + * Retrieves a user based on given filters. + * @param {object} filters - An object containing the criteria to search for. + * @returns {Promise} A promise that resolves to the user object if found, otherwise null. + */ + async findUser(filters) { + return await this.model.findOne({ + where: filters, + }); + } + + /** + * Creates a new user + * @param {Partial} userData new user to create + * @returns {Promise} The created user + */ + async createUser(userData) { + return this.model.create(userData); + } +} diff --git a/QualityControl/lib/database/repositories/index.js b/QualityControl/lib/database/repositories/index.js new file mode 100644 index 000000000..c04dc24dc --- /dev/null +++ b/QualityControl/lib/database/repositories/index.js @@ -0,0 +1,47 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { UserRepository } from './UserRepository.js'; +import { LayoutRepository } from './LayoutRepository.js'; +import { TabRepository } from './TabRepository.js'; +import { GridTabCellRepository } from './GridTabCellRepository.js'; +import { ChartRepository } from './ChartRepository.js'; +import { ChartOptionsRepository } from './ChartOptionsRepository.js'; +import { OptionRepository } from './OptionRepository.js'; + +/** + * Sets up and returns all repositories with their respective models. + * @param {Sequelize} sequelizeDatabase - The Sequelize instance containing the models. + * @param {object} sequelizeDatabase.models - The Sequelize models. + * @returns {object} An object containing all the repositories. + */ +export const setupRepositories = (sequelizeDatabase) => { + const { Layout, User, Tab, GridTabCell, Chart, ChartOption, Option } = sequelizeDatabase.models; + const userRepository = new UserRepository(User); + const layoutRepository = new LayoutRepository(Layout); + const tabRepository = new TabRepository(Tab); + const gridTabCellRepository = new GridTabCellRepository(GridTabCell); + const chartRepository = new ChartRepository(Chart); + const chartOptionRepository = new ChartOptionsRepository(ChartOption); + const optionRepository = new OptionRepository(Option); + + return { + userRepository, + layoutRepository, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, + }; +}; diff --git a/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs new file mode 100644 index 000000000..ddbc1ac9e --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs @@ -0,0 +1,30 @@ +/** + * @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. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('users', [ + { + id: 0, + name: 'Anonymous', + username: 'anonymous' }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('users', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs new file mode 100644 index 000000000..0397d1a20 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs @@ -0,0 +1,58 @@ +/** + * @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. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('layouts', [ + { + id: '671b8c22402408122e2f20dd', + name: 'test', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + { + id: '671b95883d23cd0d67bdc787', + name: 'a-test', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + { + id: '671b961f3d23cd0d67bdc78a', + name: 'SYNTHETIC', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + { + id: '671b95a8e4f3f70f2f5e4b1a', + name: 'SYNTHETIC_proton-proton', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('layouts', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs new file mode 100644 index 000000000..98c5287e5 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs @@ -0,0 +1,61 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('tabs', [ + { + id: '671b8c227b3227b0c603c29d', + name: 'main', + layout_id: '671b8c22402408122e2f20dd', + column_count: 2, + }, + { + id: '671b8c5aa66868891b977311', + name: 'test-tab', + layout_id: '671b8c22402408122e2f20dd', + column_count: 3, + }, + { + id: '671b95884312f03458f1d9ca', + name: 'main', + layout_id: '671b95883d23cd0d67bdc787', + column_count: 2, + }, + { + id: '671b958b8a5cfb52ee9ef2a1', + name: 'a', + layout_id: '671b95883d23cd0d67bdc787', + column_count: 2, + }, + { + id: '671b961f9f1e4e0f4c5b8c3d', + name: 'main', + layout_id: '671b961f3d23cd0d67bdc78a', + column_count: 2, + }, + { + id: '671b95a8f0e4f70f2f5e4b1b', + name: 'main', + layout_id: '671b95a8e4f3f70f2f5e4b1a', + column_count: 2, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('tabs', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs new file mode 100644 index 000000000..f31781631 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs @@ -0,0 +1,55 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('charts', [ + { + id: '671b8c25d5b49dbf80e81926', + object_name: 'qc/TPC/QO/CheckOfTrack_Trending', + ignore_defaults: false, + }, + { + id: '671b8c256cdd70443c1cd709', + object_name: 'qc/MCH/QO/DataDecodingCheck', + ignore_defaults: false, + }, + { + id: '671b8c266dd77d73874f4e90', + object_name: 'qc/MCH/QO/MFTRefCheck', + ignore_defaults: false, + }, + { + id: '671b8c2bcc75ce6053c67874', + object_name: 'qc/MCH/MO/Pedestals/ST5/DE1006/BadChannels_XY_B_1006', + ignore_defaults: false, + }, + { + id: '671b8c604deeb0f548863a8c', + object_name: 'qc/MCH/MO/Pedestals/BadChannelsPerDE', + ignore_defaults: false, + }, + { + id: '6724a6bd1b2bad3d713cc4ee', + object_name: 'qc/test/object/1', + ignore_defaults: false, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('charts', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs new file mode 100644 index 000000000..365d0d406 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs @@ -0,0 +1,73 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('grid_tab_cells', [ + { + chart_id: '671b8c25d5b49dbf80e81926', + row: 0, + col: 0, + tab_id: '671b8c227b3227b0c603c29d', + row_span: 1, + col_span: 1, + }, + { + chart_id: '671b8c256cdd70443c1cd709', + row: 0, + col: 1, + tab_id: '671b8c227b3227b0c603c29d', + row_span: 1, + col_span: 1, + }, + { + chart_id: '671b8c266dd77d73874f4e90', + row: 0, + col: 2, + tab_id: '671b8c227b3227b0c603c29d', + row_span: 1, + col_span: 1, + }, + { + chart_id: '671b8c2bcc75ce6053c67874', + row: 1, + col: 0, + tab_id: '671b8c227b3227b0c603c29d', + row_span: 1, + col_span: 1, + }, + { + chart_id: '671b8c604deeb0f548863a8c', + row: 0, + col: 0, + tab_id: '671b8c5aa66868891b977311', + row_span: 1, + col_span: 1, + }, + { + chart_id: '6724a6bd1b2bad3d713cc4ee', + row: 0, + col: 0, + tab_id: '671b95884312f03458f1d9ca', + row_span: 1, + col_span: 1, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('grid_tab_cells', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs new file mode 100644 index 000000000..9b76fc5bd --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs @@ -0,0 +1,64 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ +'use strict'; + +/** + * @typedef {import('sequelize').QueryInterface} QueryInterface + */ + +/** + * Seed chart options + * @param {QueryInterface} queryInterface - The query interface to perform database operations + */ +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('chart_options', [ + { + chart_id: '671b8c25d5b49dbf80e81926', + option_id: 1, + }, + { + chart_id: '671b8c25d5b49dbf80e81926', + option_id: 2, + }, + { + chart_id: '671b8c256cdd70443c1cd709', + option_id: 1, + }, + { + chart_id: '671b8c266dd77d73874f4e90', + option_id: 3, + }, + { + chart_id: '671b8c2bcc75ce6053c67874', + option_id: 4, + }, + { + chart_id: '671b8c604deeb0f548863a8c', + option_id: 5, + }, + { + chart_id: '671b8c604deeb0f548863a8c', + option_id: 6, + }, + { + chart_id: '6724a6bd1b2bad3d713cc4ee', + option_id: 7, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('chart_options', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/dtos/UserSessionDto.js b/QualityControl/lib/dtos/UserSessionDto.js new file mode 100644 index 000000000..5f10fc845 --- /dev/null +++ b/QualityControl/lib/dtos/UserSessionDto.js @@ -0,0 +1,33 @@ +/** + * @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. + */ + +import Joi from 'joi'; + +/** + * Joi schema for validating user session + */ +export const UserSessionDto = Joi.object({ + personid: Joi.number() + .required() + .messages({ + 'any.required': 'id of the user is mandatory', + 'number.base': 'id of the user must be a number', + }), + username: Joi.string() + .required() + .messages({ 'any.required': 'username of the user is mandatory' }), + name: Joi.string() + .required() + .messages({ 'any.required': 'name of the user is mandatory' }), +}); diff --git a/QualityControl/lib/middleware/layouts/layoutId.middleware.js b/QualityControl/lib/middleware/layouts/layoutId.middleware.js index 4c15b321e..2a8109a29 100644 --- a/QualityControl/lib/middleware/layouts/layoutId.middleware.js +++ b/QualityControl/lib/middleware/layouts/layoutId.middleware.js @@ -15,32 +15,28 @@ import { InvalidInputError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; /** - * @typedef {import('../../repositories/LayoutRepository.js').LayoutRepository} LayoutRepository + * @typedef {import('../../database/repositories/LayoutRepository.js').LayoutRepository} LayoutRepository */ /** * Middleware that checks if the layout id is present in the request - * @param {LayoutRepository} layoutRepository - repository for getting/setting layout data - * @returns {function(req, res, next): Function} - middleware function - */ -export const layoutIdMiddleware = (layoutRepository) => - -/** - * Returned middleware method * @param {Express.Request} req - HTTP Request * @param {Express.Response} res - HTTP Response * @param {Express.Next} next - HTTP Next (check pass) + * @param layoutService + * @returns {Promise} Resolves when validation is done and next is called */ - async (req, res, next) => { - const { id = '' } = req.params ?? {}; - try { - if (!id) { - throw new InvalidInputError('The "id" parameter is missing from the request'); - } - await layoutRepository.readLayoutById(id); - next(); - } catch (error) { - updateAndSendExpressResponseFromNativeError(res, error); - return; +export const layoutIdMiddleware = (layoutService) => async (req, res, next) => { + try { + const { id } = req.params; + if (!id || id.trim() === '') { + throw new InvalidInputError('Layout id is required'); } - }; + const layout = await layoutService.getLayoutById(id); + req.layout = layout; + next(); + } catch (error) { + updateAndSendExpressResponseFromNativeError(res, error); + return; + } +}; diff --git a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js index 40380b748..c9d7b8589 100644 --- a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js +++ b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js @@ -15,15 +15,19 @@ import { NotFoundError, UnauthorizedAccessError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; /** - * @typedef {import('../../repositories/LayoutRepository.js').LayoutRepository} LayoutRepository + * @typedef {import('../../services/layout/LayoutService.js').LayoutService} LayoutService + * @typedef {import('../../services/layout/UserService.js').UserService} UserService */ /** * Middleware that checks if the requestor is the owner of the layout - * @param {LayoutRepository} layoutRepository - Repository for getting/setting layout data - * @returns {function(req, res, next): Function} - middleware function + * @param {LayoutService} layoutService Service that handles layouts business logic + * @param {UserService} userService Service that handles user business logic + * @returns {(req: Express.Request, + * res: Express.Response, + * next: Express.NextFunction) => Promise} - middleware function */ -export const layoutOwnerMiddleware = (layoutRepository) => +export const layoutOwnerMiddleware = (layoutService, userService) => /** * Returned middleware method @@ -34,14 +38,17 @@ export const layoutOwnerMiddleware = (layoutRepository) => async (req, res, next) => { try { const { id } = req.params; - const { personid = '', name = '' } = req.session ?? {}; - const { owner_name = '', owner_id = '' } = await layoutRepository.readLayoutById(id) ?? {}; - if (owner_id === '' || owner_name === '') { + const { personid = '', username = '' } = req.session ?? {}; + if (personid === '' || username === '') { + throw new UnauthorizedAccessError('Unable to retrieve session information'); + } + const { owner_username } = await layoutService.getLayoutById(id) ?? {}; + const ownerId = await userService.getOwnerIdByUsername(owner_username); + if (ownerId === '' || owner_username === '') { throw new NotFoundError('Unable to retrieve layout owner information'); - } else if (personid === '' || name === '') { - throw new NotFoundError('Unable to retrieve session information'); - } else if (owner_name !== name || owner_id !== personid) { - throw new UnauthorizedAccessError('Only the owner of the layout can delete it'); + } + if (owner_username !== username || ownerId !== personid) { + throw new UnauthorizedAccessError('Only the owner of the layout can make changes to this layout'); } next(); } catch (error) { diff --git a/QualityControl/lib/middleware/layouts/layoutService.middleware.js b/QualityControl/lib/middleware/layouts/layoutService.middleware.js deleted file mode 100644 index 9d554c6d5..000000000 --- a/QualityControl/lib/middleware/layouts/layoutService.middleware.js +++ /dev/null @@ -1,41 +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. - */ - -import { ServiceUnavailableError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; -import { JsonFileService } from '../../services/JsonFileService.js'; - -/** - * Middleware that checks if the layout service is correctly initialized - * @param {JSONFileConnector} dataService - service for getting/setting layout data - * @returns {function(req, res, next): Function} - middleware function - */ -export const layoutServiceMiddleware = (dataService) => - -/** - * Returned middleware method - * @param {Express.Request} req - HTTP Request - * @param {Express.Response} res - HTTP Response - * @param {Express.Next} next - HTTP Next (check pass) - */ - async (req, res, next) => { - try { - if (!dataService || !(dataService instanceof JsonFileService)) { - throw new ServiceUnavailableError('JSON File service is not available'); - } - next(); - } catch (error) { - updateAndSendExpressResponseFromNativeError(res, error); - return; - } - }; diff --git a/QualityControl/lib/middleware/layouts/layoutValidate.middleware.js b/QualityControl/lib/middleware/layouts/layoutValidate.middleware.js new file mode 100644 index 000000000..dc649c42e --- /dev/null +++ b/QualityControl/lib/middleware/layouts/layoutValidate.middleware.js @@ -0,0 +1,47 @@ +/** + * @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. + */ + +import { InvalidInputError, LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { LayoutDto } from '../../dtos/LayoutDto.js'; +import { LayoutPatchDto } from '../../dtos/LayoutPatchDto.js'; +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/validate-layout-mw`; + +/** + * Middleware for validating request bodies with context (create/update/etc.) + * @param {string} action Action being performed (e.g., 'create', 'update') + * @param {object} dto Joi schema for validation + * @returns {( + * req: Express.Request, + * res: Express.Response, + * next: Express.NextFunction) => Promise} Middleware function + */ +const validateLayoutWithAction = (action, dto) => async (req, res, next) => { + const logger = LogManager.getLogger(LOG_FACILITY); + try { + const validated = await dto.validateAsync(req.body); + req.body = { ...validated }; + next(); + } catch (error) { + logger.errorMessage(`Error validating layout [${action}]: ${error.message || error}`); + const responseError = error.isJoi + ? new InvalidInputError(`Invalid body for ${action}: ${error.details[0].message}`) + : new Error(`Unable to ${action} layout`); + updateAndSendExpressResponseFromNativeError(res, responseError); + return; + } +}; + +export const validateCreateLayoutMiddleware = validateLayoutWithAction('create', LayoutDto); +export const validateUpdateLayoutMiddleware = validateLayoutWithAction('update', LayoutDto); +export const validatePatchLayoutMiddleware = validateLayoutWithAction('patch', LayoutPatchDto); diff --git a/QualityControl/lib/middleware/layouts/layoutsGet.middleware.js b/QualityControl/lib/middleware/layouts/layoutsGet.middleware.js new file mode 100644 index 000000000..8d84f386b --- /dev/null +++ b/QualityControl/lib/middleware/layouts/layoutsGet.middleware.js @@ -0,0 +1,40 @@ +/** + * @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. + */ + +import { InvalidInputError, LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { LayoutsGetDto } from '../../dtos/LayoutDto.js'; +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/get-layout-mw`; + +/** + * Middleware that checks if the query is valid in the request + * @param {Express.Request} req - HTTP Request + * @param {Express.Response} res - HTTP Response + * @param {Express.Next} next - HTTP Next (check pass) + * @returns {Promise} Resolves when validation is done and next is called + */ +export const getLayoutsMiddleware = async (req, res, next) => { + const logger = LogManager.getLogger(LOG_FACILITY); + try { + const validated = await LayoutsGetDto.validateAsync(req.query); + req.query = validated; + next(); + } catch (error) { + logger.errorMessage(`Error validating layout: ${error.message || error}`); + const responseError = error.isJoi ? + new InvalidInputError(`Invalid query parameters: ${error.details[0].message}`) : + new Error('Unable to process request'); + updateAndSendExpressResponseFromNativeError(res, responseError); + return; + } +}; diff --git a/QualityControl/lib/middleware/validateUser.middleware.js b/QualityControl/lib/middleware/validateUser.middleware.js new file mode 100644 index 000000000..410ec7c16 --- /dev/null +++ b/QualityControl/lib/middleware/validateUser.middleware.js @@ -0,0 +1,42 @@ +/** + * @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. + */ + +import { InvalidInputError, LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { UserSessionDto } from '../dtos/UserSessionDto.js'; +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/validate-user-mw`; + +/** + * Middleware for validating the user's session. + * @param {Express.Request} req HTTP request object, containing a session with user info. + * @param {Express.Response} res - HTTP Response + * @param {Express.Next} next - HTTP Next (check pass) + * @returns {Promise} Resolves when validation is done and next is called + */ +export const validateUserSession = async (req, res, next) => { + const logger = LogManager.getLogger(LOG_FACILITY); + try { + const validated = await UserSessionDto + .options({ allowUnknown: true }) + .validateAsync(req.session); + req.query = validated; + next(); + } catch (error) { + logger.errorMessage(`Error validating user: ${error.message || error}`); + const responseError = error.isJoi + ? new InvalidInputError(`Invalid user: ${error.details[0].message}`) + : new Error('Unable to validate user'); + updateAndSendExpressResponseFromNativeError(res, responseError); + return; + } +}; diff --git a/QualityControl/lib/repositories/BaseRepository.js b/QualityControl/lib/repositories/BaseRepository.js deleted file mode 100644 index 1a75a38ef..000000000 --- a/QualityControl/lib/repositories/BaseRepository.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import assert from 'assert'; - -/** - * @typedef {import('../services/JsonFileService.js').JsonFileService} JsonFileService - */ - -export class BaseRepository { - /** - * Initializes the Json File Service. - * @param {JsonFileService} jsonFileService - Service to interact with the JSON database. - * @throws {Error} Throws an error if jsonFileService is not provided. - */ - constructor(jsonFileService) { - assert(jsonFileService, 'Missing service for retrieving layout data'); - this._jsonFileService = jsonFileService; - } -} diff --git a/QualityControl/lib/repositories/ChartRepository.js b/QualityControl/lib/repositories/ChartRepository.js deleted file mode 100644 index 6b3824108..000000000 --- a/QualityControl/lib/repositories/ChartRepository.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { BaseRepository } from './BaseRepository.js'; - -/** - * ChartRepository class to handle CRUD operations for Charts. - */ -export class ChartRepository extends BaseRepository { - /** - * Return an object by its id that is saved within a layout - * @param {string} id - id of the object to retrieve - * @returns {{object: object, layoutName: string}} - object configuration stored - */ - getObjectById(id) { - if (!id) { - throw new Error('Missing mandatory parameter: id'); - } - for (const layout of this._jsonFileService.data.layouts) { - for (const tab of layout.tabs) { - for (const object of tab.objects) { - if (object.id === id) { - return { object, layoutName: layout.name, tabName: tab.name }; - } - } - } - } - throw new Error(`Object with ${id} could not be found`); - } -} diff --git a/QualityControl/lib/repositories/LayoutRepository.js b/QualityControl/lib/repositories/LayoutRepository.js deleted file mode 100644 index 266245016..000000000 --- a/QualityControl/lib/repositories/LayoutRepository.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { NotFoundError } from '@aliceo2/web-ui'; -import { BaseRepository } from './BaseRepository.js'; - -/** - * LayoutRepository class to handle CRUD operations for Layouts. - */ -export class LayoutRepository extends BaseRepository { - /** - * Retrieves a filtered list of layouts with optional field selection - * @param {object} [options] - Filtering and field selection options - * @param {string} [options.name] - Filter layouts by exact name match - * @param {Array} [options.fields] - Array of field names to include in each returned layout object - * @param {object} [options.filter] - Filter layouts by containing filter.objectPath, case insensitive - * @returns {Array} Array of layout objects matching the filters, containing only the specified fields - */ - listLayouts({ name, fields = [], filter } = {}) { - const { layouts } = this._jsonFileService.data; - const filteredLayouts = this._filterLayouts(layouts, { ...filter, name }); - - if (fields.length === 0) { - return filteredLayouts; - } - return filteredLayouts.map((layout) => { - const layoutObj = {}; - fields.forEach((field) => { - layoutObj[field] = layout[field]; - }); - return layoutObj; - }); - } - - /** - * Filters layouts by filter object - * @param {Array} layouts - Array of layouts to filter - * @param {object} filter - Filtering object - * @param {number} [filter.owner_id] - owner id to filter by - * @param {string} [filter.name] - name to filter by - * @param {string} [filter.objectPath] - object path prefix for potential objects to be contained by layout - * @returns {Array} Filtered layouts. - */ - _filterLayouts(layouts, { owner_id, name, objectPath } = {}) { - const objectPathLowerCase = objectPath?.toLowerCase(); - return layouts.filter((layout) => { - if (owner_id !== undefined && layout.owner_id !== owner_id) { - return false; - } - if (name !== undefined && layout.name !== name) { - return false; - } - if (objectPathLowerCase) { - const hasMatchingObject = layout.tabs?.some((tab) => - tab.objects?.some((obj) => - obj.name?.toLowerCase().includes(objectPathLowerCase))); - if (!hasMatchingObject) { - return false; - } - } - return true; - }); - } - - /** - * Retrieve a layout by its id or throws an error - * @param {string} layoutId - layout id - * @returns {Layout} - layout object - * @throws {NotFoundError} - if the layout is not found - */ - readLayoutById(layoutId) { - const foundLayout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId); - if (!foundLayout) { - throw new NotFoundError(`layout (${layoutId}) not found`); - } - return foundLayout; - } - - /** - * Given a string, representing layout name, retrieve the layout if it exists - * @param {string} layoutName - name of the layout to retrieve - * @returns {Layout} - object with layout information - * @throws - */ - readLayoutByName(layoutName) { - const layout = this._jsonFileService.data.layouts.find((layout) => layout.name === layoutName); - if (!layout) { - throw new NotFoundError(`Layout (${layoutName}) not found`); - } - return layout; - } - - /** - * Create a layout - * @param {Layout} newLayout - layout object to be saved - * @returns {object} Empty details - */ - async createLayout(newLayout) { - if (!newLayout.id) { - throw new Error('layout id is mandatory'); - } - if (!newLayout.name) { - throw new Error('layout name is mandatory'); - } - - const layout = this._jsonFileService.data.layouts.find((layout) => layout.id === newLayout.id); - if (layout) { - throw new Error(`layout with this id (${layout.id}) already exists`); - } - this._jsonFileService.data.layouts.push(newLayout); - await this._jsonFileService.writeToFile(); - return newLayout; - } - - /** - * Update a single layout by its id - * @param {string} layoutId - id of the layout to be updated - * @param {LayoutDto} newData - layout new data - * @returns {string} id of the layout updated - */ - async updateLayout(layoutId, newData) { - const layout = this.readLayoutById(layoutId); - Object.assign(layout, newData); - await this._jsonFileService.writeToFile(); - return layoutId; - } - - /** - * Delete a single layout by its id - * @param {string} layoutId - id of the layout to be removed - * @returns {string} id of the layout deleted - */ - async deleteLayout(layoutId) { - const layout = this.readLayoutById(layoutId); - const index = this._jsonFileService.data.layouts.indexOf(layout); - this._jsonFileService.data.layouts.splice(index, 1); - await this._jsonFileService.writeToFile(); - return layoutId; - } - - /** - * Return an object by its id that is saved within a layout - * @param {string} id - id of the object to retrieve - * @returns {{object: object, layoutName: string}} - object configuration stored - */ - getObjectById(id) { - if (!id) { - throw new Error('Missing mandatory parameter: id'); - } - for (const layout of this._jsonFileService.data.layouts) { - for (const tab of layout.tabs) { - for (const object of tab.objects) { - if (object.id === id) { - return { object, layoutName: layout.name, tabName: tab.name }; - } - } - } - } - throw new Error(`Object with ${id} could not be found`); - } -} diff --git a/QualityControl/lib/repositories/UserRepository.js b/QualityControl/lib/repositories/UserRepository.js deleted file mode 100644 index 79567873b..000000000 --- a/QualityControl/lib/repositories/UserRepository.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { BaseRepository } from './BaseRepository.js'; - -/** - * UserRepository class to handle CRUD operations for Users. - */ -export class UserRepository extends BaseRepository { - /** - * Check if a user is saved and if not, add it to the in-memory list and db. - * @param {object} user - Data of the user to be added. - * @returns {Promise} - */ - async createUser(user) { - const { data } = this._jsonFileService; - this._validateUser(user); - - const isUserPresent = data.users.some((userEl) => user.id === userEl.id && user.name === userEl.name); - - if (!isUserPresent) { - data.users.push(user); - await this._jsonFileService.writeToFile(); - } - } - - /** - * Validate that a user JSON contains all the mandatory fields - * @param {JSON} user - data of the user to be added - * @returns {undefined} - * @throws {Error} - */ - _validateUser(user) { - if (!user) { - throw new Error('User Object is mandatory'); - } - if (!user.username) { - throw new Error('Field username is mandatory'); - } - if (!user.name) { - throw new Error('Field name is mandatory'); - } - if (user.id === null || user.id === undefined || user.id === '') { - throw new Error('Field id is mandatory'); - } - if (isNaN(user.id)) { - throw new Error('Field id must be a number'); - } - } -} diff --git a/QualityControl/lib/services/QcObject.service.js b/QualityControl/lib/services/QcObject.service.js index b6d405ad1..5a64d7611 100644 --- a/QualityControl/lib/services/QcObject.service.js +++ b/QualityControl/lib/services/QcObject.service.js @@ -18,7 +18,7 @@ import QCObjectDto from '../dtos/QCObjectDto.js'; import QcObjectIdentificationDto from '../dtos/QcObjectIdentificationDto.js'; /** - * @typedef {import('../repositories/ChartRepository.js').ChartRepository} ChartRepository + * @typedef {import('./layout/LayoutService.js').LayoutService} LayoutService */ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/obj-service`; @@ -31,19 +31,19 @@ export class QcObjectService { /** * Setup service constructor and initialize needed dependencies * @param {CcdbService} dbService - CCDB service to retrieve raw information about the QC objects - * @param {ChartRepository} chartRepository - service to be used for retrieving configurations on saved layouts + * @param {LayoutService} layoutService - service to be used for retrieving configurations on saved layouts * @param {RootService} rootService - root library to be used for interacting with ROOT Objects */ - constructor(dbService, chartRepository, rootService) { + constructor(dbService, layoutService, rootService) { /** * @type {CcdbService} */ this._dbService = dbService; /** - * @type {ChartRepository} + * @type {LayoutService} */ - this._chartRepository = chartRepository; + this._layoutService = layoutService; /** * @type {RootService} @@ -181,19 +181,20 @@ export class QcObjectService { * @param {number|null} options.validFrom - timestamp in ms * @param {object} options.filters - filter as string to be sent to CCDB * @returns {Promise} - QC objects with information CCDB and root - * @throws {Error} - if object with specified id is not found */ async retrieveQcObjectByQcgId({ qcObjectId, id, validFrom = undefined, filters = {} }) { - const result = this._chartRepository.getObjectById(qcObjectId); - if (!result) { - throw new Error(`Object with id ${qcObjectId} not found`); - } - const { object, layoutName, tabName } = result; - const { name, options = {}, ignoreDefaults = false } = object; + const object = await this._layoutService.getObjectById(qcObjectId); + const { tab, chart } = object; + const { name: tabName, layout } = tab; + const { name: layoutName } = layout; + const { object_name: name, ignore_defaults: ignoreDefaults, chartOptions } = chart; + const layoutDisplayOptions = + chartOptions?.length > 0 ? chartOptions.map((chartOption) => chartOption.option.name) : []; + const qcObject = await this.retrieveQcObject({ path: name, validFrom, id, filters }); return { ...qcObject, - layoutDisplayOptions: options, + layoutDisplayOptions, layoutName, tabName, ignoreDefaults, diff --git a/QualityControl/lib/services/UserService.js b/QualityControl/lib/services/UserService.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/external/BookkeepingService.js similarity index 97% rename from QualityControl/lib/services/BookkeepingService.js rename to QualityControl/lib/services/external/BookkeepingService.js index 561e97fc0..b0075e836 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/external/BookkeepingService.js @@ -12,8 +12,8 @@ * or submit itself to any jurisdiction. */ -import { RunStatus } from '../../common/library/runStatus.enum.js'; -import { httpGetJson } from '../utils/httpRequests.js'; +import { RunStatus } from '../../../common/library/runStatus.enum.js'; +import { httpGetJson } from '../../utils/httpRequests.js'; import { LogManager } from '@aliceo2/web-ui'; const GET_BKP_DATABASE_STATUS_PATH = '/api/status/database'; diff --git a/QualityControl/lib/services/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js new file mode 100644 index 000000000..c063e0f50 --- /dev/null +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -0,0 +1,240 @@ +/** + * @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. + */ + +import { InvalidInputError, LogManager, NotFoundError } from '@aliceo2/web-ui'; +import { UserService } from './UserService.js'; +import { normalizeLayout } from './helpers/normalizeLayout.js'; +import { ChartOptionsSynchronizer } from './helpers/chartOptionsSynchronizer.js'; +import { GridTabCellSynchronizer } from './helpers/gridTabCellSynchronizer.js'; +import { TabSynchronizer } from './helpers/tabSynchronizer.js'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-svc`; + +/** + * @typedef {import('../../database/repositories/LayoutRepository').LayoutRepository.js} LayoutRepository + * @typedef {import('../../database/repositories/UserRepository').UserRepository.js} UserRepository + * @typedef {import('../../database/repositories/TabRepository').TabRepository.js} TabRepository + * @typedef {import('../../database/repositories/GridTabCellRepository').GridTabCellRepository.js} GridTabCellRepository + * @typedef {import('../../database/repositories/ChartRepository').ChartRepository.js} ChartRepository + * @typedef { + * import('../../database/repositories/ChartOptionsRepository').ChartOptionsRepository.js + * } ChartOptionsRepository + * @typedef {import('../../database/repositories/OptionRepository').OptionRepository.js} OptionRepository + */ + +/** + * Class that handles the business logic for the layouts + */ +export class LayoutService { + /** + * Creates an instance of the LayoutService class + * @param {LayoutRepository} layoutRepository Repository that handles the datbase operations for the layouts + * @param {UserRepository} userRepository Repository that handles the datbase operations for the users + * @param {TabRepository} tabRepository Repository that handles the datbase operations for the tabs + * @param {GridTabCellRepository} gridTabCellRepository + * Repository that handles the datbase operations for the grid tab cells + * @param {ChartRepository} chartRepository Repository that handles the datbase operations for the charts + * @param {ChartOptionsRepository} chartOptionsRepository + * Repository that handles the datbase operations for the chart options + * @param {OptionRepository} optionRepository Repository that handles the datbase operations for the options + */ + constructor( + layoutRepository, + userRepository, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionsRepository, + optionRepository, + ) { + this._logger = LogManager.getLogger(LOG_FACILITY); + + // repositories + this._layoutRepository = layoutRepository; + this._tabRepository = tabRepository; + this._gridTabCellRepository = gridTabCellRepository; + this._chartRepository = chartRepository; + this._chartOptionRepository = chartOptionsRepository; + this._optionRepository = optionRepository; + + // services + this._userService = new UserService(userRepository); + + // helpers + this._chartOptionsSynchronizer = new ChartOptionsSynchronizer(this._chartOptionRepository, this._optionRepository); + this._gridTabCellSynchronizer = new GridTabCellSynchronizer( + this._gridTabCellRepository, + this._chartRepository, + this._chartOptionsSynchronizer, + ); + this._tabSynchronizer = new TabSynchronizer(this._tabRepository, this._gridTabCellSynchronizer); + } + + /** + * Retrieves a filtered list of layouts + * @param {object} [filters={}] - Filter criteria for layouts. + * @returns {Promise>} Array of layout objects matching the filters + */ + async getLayoutsByFilters(filters = {}) { + if (Number.isInteger(filters?.owner_id)) { + const ownerId = parseInt(filters.owner_id, 10); + const owner_username = await this._userService.getUsernameById(ownerId); + filters.owner_username = owner_username; + } + delete filters.owner_id; + const layouts = await this._layoutRepository.findLayoutsByFilters(filters); + + return layouts; + } + + /** + * Finds a layout by its ID + * @param {string} id - Layout ID + * @throws {NotFoundError} If no layout is found with the given ID + * @returns {Promise} The layout found + */ + async getLayoutById(id) { + const layoutFound = await this._layoutRepository.findLayoutById(id); + if (!layoutFound) { + throw new NotFoundError(`Layout with id: ${id} was not found`); + } + return layoutFound; + } + + /** + * Finds a layout by its name + * @param {string} name - Layout name + * @throws {NotFoundError} If no layout is found with the given name + * @returns {Promise} The layout found + */ + async getLayoutByName(name) { + const layoutFound = await this._layoutRepository.findLayoutByName(name); + if (!layoutFound) { + throw new NotFoundError(`Layout with name: ${name} was not found`); + } + return layoutFound; + } + + /** + * Gets a single object by its ID + * @param {*} id - Object ID + * @returns {Promise} The object found + * @throws {InvalidInputError} If the ID is not provided + * @throws {NotFoundError} If no object is found with the given ID + * @throws {Error} If an error occurs during the operation + */ + async getObjectById(id) { + try { + if (!id) { + throw new InvalidInputError('Id must be provided'); + } + const object = await this._gridTabCellRepository.findObjectByChartId(id); + if (!object) { + throw new NotFoundError(`Object with id ${id} not found`); + } + return object?.toJSON(); + } catch (error) { + this._logger.errorMessage(`Error getting object by ID: ${error?.message || error}`); + throw error; + } + } + + /** + * Updates an existing layout by ID + * @param {string} id - Layout ID + * @param {Partial} updateData - Fields to update + * @returns {Promise} Layout ID of the updated layout + * @throws {Error} If an error occurs updating the layout + */ + async putLayout(id, updateData) { + const transaction = await this._layoutRepository.model.sequelize.transaction(); + try { + const normalizedLayout = await normalizeLayout(updateData, {}, true, this._userService); + await this._updateLayout(id, normalizedLayout, transaction); + if (updateData.tabs) { + await this._tabSynchronizer.sync(id, updateData.tabs, transaction); + } + await transaction.commit(); + return id; + } catch (error) { + await transaction.rollback(); + this._logger.trace(error); + this._logger.errorMessage(`Error in putLayout: ${error.message || error}`); + throw error; + } + } + + /** + * Partially updates an existing layout by ID + * @param {string} id - Layout ID + * @param {Partial} updateData - Fields to update + * @returns {Promise} Layout ID of the updated layout + * @throws {Error} If an error occurs updating the layout + */ + async patchLayout(id, updateData) { + const normalizedLayout = await normalizeLayout(updateData, {}, false, this._userService); + await this._updateLayout(id, normalizedLayout); + return id; + } + + /** + * Updates a layout in the database + * @param {string} layoutId - ID of the layout to update + * @param {Partial} updateData - Data to update + * @param {object} [transaction] - Optional transaction object + * @throws {NotFoundError} If no layout is found with the given ID + * @returns {Promise} + */ + async _updateLayout(layoutId, updateData, transaction) { + const updatedCount = await this._layoutRepository.updateLayout(layoutId, updateData, { transaction }); + if (updatedCount === 0) { + throw new NotFoundError(`Layout with id ${layoutId} not found`); + } + } + + /** + * Removes a layout by ID + * @param {string} id - Layout ID + * @throws {NotFoundError} If no layout is found with the given ID + * @returns {Promise} + */ + async removeLayout(id) { + const deletedCount = await this._layoutRepository.deleteLayout(id); + if (deletedCount === 0) { + throw new NotFoundError(`Layout with id ${id} not found`); + } + } + + /** + * Creates a new layout + * @param {Partial} layoutData - Data for the new layout + * @throws {InvalidInputError} If a layout with the same unique fields (e.g., name) already exists + * @returns {Promise} The created layout + */ + async postLayout(layoutData) { + const transaction = await this._layoutRepository.model.sequelize.transaction(); + try { + const normalizedLayout = await normalizeLayout(layoutData, {}, true, this._userService); + const newLayout = await this._layoutRepository.createLayout(normalizedLayout, { transaction }); + if (layoutData.tabs && layoutData.tabs.length) { + await this._tabSynchronizer.sync(newLayout.id, layoutData.tabs, transaction); + } + await transaction.commit(); + return newLayout; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} diff --git a/QualityControl/lib/services/layout/UserService.js b/QualityControl/lib/services/layout/UserService.js new file mode 100644 index 000000000..3b0b2dc72 --- /dev/null +++ b/QualityControl/lib/services/layout/UserService.js @@ -0,0 +1,105 @@ +/** + * @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. + */ + +import { InvalidInputError, LogManager, NotFoundError } from '@aliceo2/web-ui'; +import { UniqueConstraintError } from 'sequelize'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/user-svc`; + +/** + * Class that handles the business logic for the users + */ +export class UserService { + /** + * Creates an instance of the UserService class + * @param {BaseRepository} userRepository Repository that handles the datbase operations for the users + */ + constructor(userRepository) { + this._logger = LogManager.getLogger(LOG_FACILITY); + this._userRepository = userRepository; + } + + /** + * Creates a new user + * @param {Partial} user - Data for the new user + * @param {string} user.username - Username of the new user + * @param {string} user.name - Name of the new user + * @param {string} user.personid - ID of the new user + * @throws {InvalidInputError} If a user with the same unique fields already exists + * @returns {Promise} + */ + async createNewUser(user) { + const { username, name, personid } = user; + try { + const existingUser = await this._userRepository.findUser({ + username: username, + name: name, + }); + + if (!existingUser || existingUser.length === 0) { + const newUser = { + id: personid, + username: username, + name: name, + }; + await this._userRepository.createUser(newUser); + } + } catch (error) { + if (error instanceof UniqueConstraintError) { + const field = error.errors?.[0]?.path || 'username'; + throw new InvalidInputError(`A user with the same ${field} already exists.`); + } + this._logger.errorMessage(`Error creating user: ${error.message || error}`); + throw error; + } + } + + /** + * Retrieves a user bi his username + * @param {string} id id of the owner of the layout + * @returns {string} the owner's username + * @throws {NotFoundError} null if user was not found + */ + async getUsernameById(id) { + try { + const user = await this._userRepository.findUser({ id }); + if (!user) { + throw new NotFoundError(`User with ID ${id} not found`); + } + return user.username; + } catch (error) { + this._logger.errorMessage(`Error fetching username by ID: ${error.message || error}`); + throw error; + } + } + + /** + * Retrieves a user id by his username + * @param {string} username the username of the owner + * @returns {string} the owner's id + * @throws {NotFoundError} if user was not found + */ + async getOwnerIdByUsername(username) { + try { + const user = await this._userRepository.findUser({ username }); + if (!user) { + throw new NotFoundError(`User with username ${username} not found`); + } + return user.id; + } catch (error) { + this._logger.errorMessage(`Error fetching owner ID by username: ${error.message || error}`); + throw error; + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js new file mode 100644 index 000000000..cbf4d21b2 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js @@ -0,0 +1,94 @@ +/** + * @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. + */ + +import { LogManager } from '@aliceo2/web-ui'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/chart-options-synchronizer`; + +/** + * @typedef {import('../../../database/repositories/ChartOptionsRepository.js') + * .ChartOptionsRepository} ChartOptionsRepository + * @typedef {import('../../../database/repositories/OptionsRepository.js').OptionsRepository} OptionsRepository + */ + +export class ChartOptionsSynchronizer { + /** + * Creates an instance of ChartOptionsSynchronizer. + * @param {ChartOptionsRepository} chartOptionRepository Chart options repository + * @param {OptionsRepository} optionsRepository Options repository + */ + constructor(chartOptionRepository, optionsRepository) { + this._chartOptionRepository = chartOptionRepository; + this._optionsRepository = optionsRepository; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Synchronize chart options with the database. + * @param {object} chart Chart object + * @param {Array} chart.options Array of options + * @param {object} transaction Sequelize transaction + */ + async sync(chart, transaction) { + if (!(chart.options && chart.options.length)) { + return; + } + + let existingOptions = null; + let existingOptionIds = null; + let incomingOptions = null; + let incomingOptionIds = null; + + try { + existingOptions = await this._chartOptionRepository.findChartOptionsByChartId(chart.id, { transaction }); + existingOptionIds = existingOptions.map((co) => co.option_id); + incomingOptions = await Promise.all(chart.options.map((o) => + this._optionsRepository.findOptionByName(o, { transaction }))); + incomingOptionIds = incomingOptions.map((o) => o.id); + } catch (error) { + this._logger.errorMessage(`Failed to fetch chart options: ${error.message}`); + await transaction.rollback(); + throw error; + } + + const toDelete = existingOptionIds.filter((id) => !incomingOptionIds.includes(id)); + for (const optionId of toDelete) { + try { + await this._chartOptionRepository.deleteChartOption({ chartId: chart.id, optionId }, { transaction }); + } catch (error) { + this._logger.errorMessage(`Failed to delete chart option: ${error.message}`); + transaction.rollback(); + throw error; + } + } + + for (const option of incomingOptions) { + if (!existingOptionIds.includes(option.id)) { + try { + const createdOption = await this._chartOptionRepository.createChartOption( + { chart_id: chart.id, option_id: option.id }, + { transaction }, + ); + if (!createdOption) { + throw new Error('Option creation returned null'); + } + } catch (error) { + this._logger.errorMessage(`Failed to create chart option: ${error.message}`); + transaction.rollback(); + throw error; + } + } + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js new file mode 100644 index 000000000..ec4a9d200 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js @@ -0,0 +1,87 @@ +/** + * @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. + */ + +import { LogManager } from '@aliceo2/web-ui'; +import { mapObjectToChartAndCell } from './mapObjectToChartAndCell.js'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/grid-tab-cell-synchronizer`; + +/** + * Class to synchronize grid tab cells with the database. + */ +export class GridTabCellSynchronizer { + constructor(gridTabCellRepository, chartRepository, chartOptionsSynchronizer) { + this._gridTabCellRepository = gridTabCellRepository; + this._chartRepository = chartRepository; + this._chartOptionsSynchronizer = chartOptionsSynchronizer; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Synchronize grid tab cells with the database. + * @param {string} tabId Tab ID + * @param {Array} objects Array of objects to map to charts and cells + * @param {object} transaction Sequelize transaction + */ + async sync(tabId, objects, transaction) { + let existingCells = null; + try { + existingCells = await this._gridTabCellRepository.findByTabId(tabId, { transaction }); + } catch (error) { + this._logger.errorMessage(`Failed to fetch existing cells for tabId=${tabId}: ${error.message}`); + transaction.rollback(); + throw error; + } + const existingChartIds = existingCells.map((cell) => cell.chart_id); + const incomingChartIds = objects.map((obj) => obj.id); + + const toDelete = existingChartIds.filter((id) => !incomingChartIds.includes(id)); + for (const chartId of toDelete) { + try { + const deletedCount = await this._chartRepository.deleteChart(chartId, { transaction }); + if (deletedCount === 0) { + throw new Error('No chart deleted'); + } + } catch (error) { + this._logger.errorMessage(`Failed to delete chartId=${chartId}: ${error.message}`); + transaction.rollback(); + throw error; + } + } + for (const object of objects) { + try { + const { chart, cell } = mapObjectToChartAndCell(object, tabId); + if (existingChartIds.includes(chart.id)) { + const updatedRows = await this._chartRepository.updateChart(chart.id, chart, { transaction }); + const updatedCells = + await this._gridTabCellRepository.updateGridTabCell({ chartId: chart.id, tabId }, cell, { transaction }); + if (updatedRows === 0 || updatedCells === 0) { + throw new Error('Chart or cell not updated'); + } + } else { + const createdChart = await this._chartRepository.createChart(chart, { transaction }); + const createdCell = await this._gridTabCellRepository.createGridTabCell(cell, { transaction }); + if (!createdChart || !createdCell) { + throw new Error('Chart or cell not created'); + } + } + await this._chartOptionsSynchronizer.sync({ ...chart, options: object?.options }, transaction); + } catch (error) { + this._logger.errorMessage(`Failed to sync chart/cell for object id=${object.id}: ${error.message}`); + transaction.rollback(); + throw error; + } + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js b/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js new file mode 100644 index 000000000..1995ecb1b --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js @@ -0,0 +1,44 @@ +/** + * @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. + */ + +import { InvalidInputError } from '@aliceo2/web-ui'; + +/** + * Maps an input object to a chart and a cell + * @param {object} object - The input object + * @param {string} tabId - The ID of the tab + * @returns {object} An object containing the mapped chart and cell + */ +export function mapObjectToChartAndCell(object, tabId) { + if (!object || typeof object !== 'object' || !tabId) { + throw new InvalidInputError('Invalid input: object and tab id are required'); + } + const { id: chartId, x, y, h, w, name, ignoreDefaults } = object; + + return { + chart: { + id: chartId, + object_name: name, + ignore_defaults: ignoreDefaults, + }, + cell: { + tab_id: tabId, + chart_id: chartId, + row: x, + col: y, + row_span: h, + col_span: w, + }, + }; +} diff --git a/QualityControl/lib/services/layout/helpers/normalizeLayout.js b/QualityControl/lib/services/layout/helpers/normalizeLayout.js new file mode 100644 index 000000000..fe9586e09 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/normalizeLayout.js @@ -0,0 +1,63 @@ +/** + * @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. + */ + +import { LogManager } from '@aliceo2/web-ui'; + +/** + * @typedef {import('../UserService.js').UserService} UserService + */ + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-normalizer`; + +/** + * Helper to normalize layout data + * @param {object} patch - Partial layout data to be normalized + * @param {object} layout - Existing layout data (for patches) + * @param {boolean} isFull - Whether the patch is a full layout or a partial update + * @param {UserService} userService - Instance of the UserService to fetch user information + * @returns {Promise} - Normalized layout data + */ +export const normalizeLayout = async (patch, layout = {}, isFull = false, userService) => { + try { + const source = isFull ? { ...layout, ...patch } : patch; + const data = {}; + + if ('id' in source) { + data.id = source.id; + } + if ('name' in source) { + data.name = source.name; + } + if ('description' in source) { + data.description = source.description; + } + if ('displayTimestamp' in source) { + data.display_timestamp = source.displayTimestamp; + } + if ('autoTabChange' in source) { + data.auto_tab_change_interval = source.autoTabChange; + } + if ('isOfficial' in source) { + data.is_official = source.isOfficial; + } + if ('owner_id' in source) { + const username = await userService.getUsernameById(source.owner_id); + data.owner_username = username; + } + return data; + } catch (error) { + LogManager.getLogger(LOG_FACILITY).errorMessage(`Error normalizing layout: ${error.message || error}`); + throw new Error(`Error normalizing layout: ${error.message || error}`); + } +}; diff --git a/QualityControl/lib/services/layout/helpers/tabSynchronizer.js b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js new file mode 100644 index 000000000..60c4b3132 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js @@ -0,0 +1,82 @@ +/** + * @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 LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/tab-synchronizer`; +import { LogManager } from '@aliceo2/web-ui'; + +/** + * @typedef {import('../../database/repositories/TabRepository').TabRepository} TabRepository + * @typedef {import('./gridTabCellSynchronizer.js').GridTabCellSynchronizer} GridTabCellSynchronizer + */ + +export class TabSynchronizer { + /** + * Creates an instance of TabSynchronizer to synchronize tabs for a layout. + * @param {TabRepository} tabRepository - The repository for tab operations. + * @param {GridTabCellSynchronizer} gridTabCellSynchronizer - The synchronizer for grid tab cells. + * @returns {void} + */ + constructor(tabRepository, gridTabCellSynchronizer) { + this._tabRepository = tabRepository; + this._gridTabCellSynchronizer = gridTabCellSynchronizer; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Synchronizes the tabs for a given layout. + * @param {number} layoutId - The ID of the layout to synchronize tabs for. + * @param {Array} tabs - The array of tab objects to synchronize. + * @param {object} transaction - The database transaction object. + * @throws {Error} If an error occurs during synchronization. + * @returns {Promise} + */ + async sync(layoutId, tabs, transaction) { + const incomingIds = tabs.filter((t) => t.id).map((t) => t.id); + const existingTabs = await this._tabRepository.findTabsByLayoutId(layoutId, { transaction }); + const existingIds = existingTabs.map((t) => t.id); + + const idsToDelete = existingIds.filter((id) => !incomingIds.includes(id)); + for (const id of idsToDelete) { + try { + const deletedCount = await this._tabRepository.deleteTab(id, { transaction }); + if (deletedCount === 0) { + throw new Error(`Tab with id=${id} not found for deletion`); + } + } catch (error) { + this._logger.errorMessage(`Failed to delete tabId=${id}: ${error.message}`); + } + } + + for (const tab of tabs) { + tab.layout_id = layoutId; + try { + if (tab.id && existingIds.includes(tab.id)) { + await this._tabRepository.updateTab(tab.id, tab, { transaction }); + } else { + const tabRecord = await this._tabRepository.createTab(tab, { transaction }); + if (!tabRecord) { + throw new Error('Failed to create tab'); + } + } + if (tab.objects && tab.objects.length) { + await this._gridTabCellSynchronizer.sync(tab.id, tab.objects, transaction); + } + } catch (error) { + this._logger.errorMessage(`Failed to upsert tab (id=${tab.id ?? 'new'}): ${error.message}`); + await transaction.rollback(); + throw error; + } + } + } +} diff --git a/QualityControl/public/object/objectPropertiesSidebar.js b/QualityControl/public/object/objectPropertiesSidebar.js index 0a1b479d7..7e82e2e86 100644 --- a/QualityControl/public/object/objectPropertiesSidebar.js +++ b/QualityControl/public/object/objectPropertiesSidebar.js @@ -12,6 +12,7 @@ * or submit itself to any jurisdiction. */ +import { displayHints, drawingOptions } from './../library/enums/objectOptions.enum.js'; import { h } from '/js/src/index.js'; /** @@ -47,13 +48,7 @@ export default function objectPropertiesSidebar(model) { ]), ), h('.flex-row.flex-wrap', [ - btnOption(model, tabObject, 'lego'), - ' ', - btnOption(model, tabObject, 'colz'), - ' ', - btnOption(model, tabObject, 'lcolz'), - ' ', - btnOption(model, tabObject, 'text'), + drawingOptions.map((option) => btnOption(model, tabObject, option)), ' ', ]), h( @@ -63,23 +58,7 @@ export default function objectPropertiesSidebar(model) { h('.tooltiptext', 'Canvas options'), ]), ), - h('.flex-row', [ - btnOption(model, tabObject, 'logx'), - ' ', - btnOption(model, tabObject, 'logy'), - ' ', - btnOption(model, tabObject, 'logz'), - ' ', - ]), - h('.flex-row', [ - btnOption(model, tabObject, 'gridx'), - ' ', - btnOption(model, tabObject, 'gridy'), - ' ', - btnOption(model, tabObject, 'gridz'), - ' ', - ]), - h('.flex-row', [btnOption(model, tabObject, 'stat'), ' ']), + h('.flex-row.flex-wrap', [...displayHints.map((option) => btnOption(model, tabObject, option))]), ]), h('hr'), diff --git a/QualityControl/public/services/utils/jsonFetch.js b/QualityControl/public/services/utils/jsonFetch.js index 5a3c547ad..518fdeec9 100644 --- a/QualityControl/public/services/utils/jsonFetch.js +++ b/QualityControl/public/services/utils/jsonFetch.js @@ -28,12 +28,20 @@ export const jsonFetch = async (endpoint, options) => { return Promise.reject({ message: 'Connection to server failed, please try again' }); } try { - const result = response.status === 204 // case in which response is empty - ? null - : await response.json(); + if (response.status === 204) { + return null; + } + const contentType = response.headers.get('content-type') || ''; + let result = null; + if (!contentType.includes('application/json')) { + result = await response.json(); + } else { + const text = await response.text(); + result = text ? { message: text } : null; + } return response.ok ? result - : Promise.reject({ message: result.message || 'Unknown error received' }); + : Promise.reject({ message: result?.message || 'Unknown error received' }); } catch { return Promise.reject({ message: 'Parsing result from server failed' }); } diff --git a/QualityControl/test/api/layouts/api-get-layout.test.js b/QualityControl/test/api/layouts/api-get-layout.test.js index 068b338bb..589a840f8 100644 --- a/QualityControl/test/api/layouts/api-get-layout.test.js +++ b/QualityControl/test/api/layouts/api-get-layout.test.js @@ -15,8 +15,7 @@ import { suite, test } from 'node:test'; import { OWNER_TEST_TOKEN, URL_ADDRESS } from '../config.js'; import request from 'supertest'; -import { deepStrictEqual } from 'node:assert'; -import { LAYOUT_MOCK_4, LAYOUT_MOCK_5, LAYOUT_MOCK_6 } from '../../demoData/layout/layout.mock.js'; +import { deepStrictEqual, strictEqual } from 'node:assert'; export const apiGetLayoutsTests = () => { suite('GET /layouts', () => { @@ -40,11 +39,9 @@ export const apiGetLayoutsTests = () => { .get(`?token=${OWNER_TEST_TOKEN}&owner_id=${ownerId}`) .expect(200) .expect((res) => { - if (!Array.isArray(res.body)) { - throw new Error('Expected array of layouts'); - } - - deepStrictEqual(res.body, [LAYOUT_MOCK_4, LAYOUT_MOCK_5], 'Unexpected Layout structure was returned'); + res.body.forEach((layout) => { + strictEqual(layout.owner_id, ownerId, `Expected layout owner_id to be ${ownerId}`); + }); }); }); @@ -83,20 +80,16 @@ export const apiGetLayoutsTests = () => { await request(`${URL_ADDRESS}/api/layout/${layoutId}`) .get(`?token=${OWNER_TEST_TOKEN}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_6, 'Unexpected Layout structure was returned')); - }); - - test('should return 400 when id parameter is an empty string', async () => { - await request(`${URL_ADDRESS}/api/layout/ `) - .get(`?token=${OWNER_TEST_TOKEN}`) - .expect(400, { message: 'Missing parameter "id" of layout', status: 400, title: 'Invalid Input' }); + .expect((res) => { + deepStrictEqual(res.body.id, layoutId, 'Unexpected Layout structure was returned'); + }); }); test('should return 404 when layout is not found', async () => { const nonExistentId = 'nonexistent123'; await request(`${URL_ADDRESS}/api/layout/${nonExistentId}`) .get(`?token=${OWNER_TEST_TOKEN}`) - .expect(404, { message: 'layout (nonexistent123) not found', status: 404, title: 'Not Found' }); + .expect(404, { message: `Layout with id: ${nonExistentId} was not found`, status: 404, title: 'Not Found' }); }); }); @@ -106,29 +99,33 @@ export const apiGetLayoutsTests = () => { await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&name=${layoutName}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned')); + .expect((res) => { + deepStrictEqual(res.body.name, layoutName, 'Unexpected Layout structure was returned'); + }); }); test('should return layout by runDefinition', async () => { - const runDefinition = 'a-test'; + const runDefinition = 'SYNTHETIC'; await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&runDefinition=${runDefinition}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned')); + .expect((res) => deepStrictEqual( + res.body.name, + runDefinition, + 'Unexpected Layout structure was returned', + )); }); test('should return layout by runDefinition and pdpBeamType combination', async () => { - const runDefinition = 'rundefinition'; - const pdpBeamType = 'pdpBeamType'; + const runDefinition = 'SYNTHETIC'; + const pdpBeamType = 'proton-proton'; await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&runDefinition=${runDefinition}&pdpBeamType=${pdpBeamType}`) .expect(200) - .expect((res) => { - deepStrictEqual( - res.body.name, - `${runDefinition}_${pdpBeamType}`, - 'Expected layout name to be combination of runDefinition and pdpBeamType', - ); - }); + .expect((res) => deepStrictEqual( + res.body.name, + `${runDefinition}_${pdpBeamType}`, + 'Unexpected Layout structure was returned', + )); }); test('should return 400 when no query parameters are provided', async () => { @@ -141,7 +138,11 @@ export const apiGetLayoutsTests = () => { const nonExistentName = 'nonexistent-layout'; await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&name=${nonExistentName}`) - .expect(404, { message: `Layout (${nonExistentName}) not found`, status: 404, title: 'Not Found' }); + .expect(404, { + message: `Layout with name: ${nonExistentName} was not found`, + status: 404, + title: 'Not Found', + }); }); }); }; diff --git a/QualityControl/test/api/layouts/api-patch-layout.test.js b/QualityControl/test/api/layouts/api-patch-layout.test.js index 2bfb88014..672bb0b64 100644 --- a/QualityControl/test/api/layouts/api-patch-layout.test.js +++ b/QualityControl/test/api/layouts/api-patch-layout.test.js @@ -31,7 +31,7 @@ export const apiPatchLayoutTests = () => { await request(`${URL_ADDRESS}/api/layout/test`) .patch(`?token=${OWNER_TEST_TOKEN}`) .expect(404, { - message: 'layout (test) not found', + message: 'Layout with id: test was not found', status: 404, title: 'Not Found', }); @@ -54,7 +54,7 @@ export const apiPatchLayoutTests = () => { test: 'test', }) .expect(400, { - message: 'Failed to validate layout: "test" is not allowed', + message: 'Invalid body for patch: "test" is not allowed', status: 400, title: 'Invalid Input', }); @@ -66,7 +66,7 @@ export const apiPatchLayoutTests = () => { .send({ isOfficial: false, }) - .expect(201, { + .expect(200, { id: '671b8c22402408122e2f20dd', }); }); diff --git a/QualityControl/test/api/layouts/api-put-layout.test.js b/QualityControl/test/api/layouts/api-put-layout.test.js index 96dc333b5..e79904c0c 100644 --- a/QualityControl/test/api/layouts/api-put-layout.test.js +++ b/QualityControl/test/api/layouts/api-put-layout.test.js @@ -15,58 +15,46 @@ import { suite, test } from 'node:test'; import { OWNER_TEST_TOKEN, URL_ADDRESS, USER_TEST_TOKEN } from '../config.js'; import request from 'supertest'; -import { LAYOUT_MOCK_2, LAYOUT_MOCK_3 } from '../../demoData/layout/layout.mock.js'; +import { VALID_LAYOUT_FOR_UPDATE } from '../../demoData/layout/layout.mock.js'; export const apiPutLayoutTests = () => { suite('PUT /layout/:id', () => { - test('should return a 404 error if the id of the layout does not exist', async () => { - await request(`${URL_ADDRESS}/api/layout/test`) + test('should update the layout successfully', async () => { + await request(`${URL_ADDRESS}/api/layout/${VALID_LAYOUT_FOR_UPDATE.id}`) + .put(`?token=${OWNER_TEST_TOKEN}`) + .send(VALID_LAYOUT_FOR_UPDATE) + .expect(200); + }); + test('should return 400 for invalid layout ID', async () => { + await request(`${URL_ADDRESS}/api/layout/invalid-id`) .put(`?token=${OWNER_TEST_TOKEN}`) + .send(VALID_LAYOUT_FOR_UPDATE) .expect(404, { - message: 'layout (test) not found', + message: 'Layout with id: invalid-id was not found', status: 404, title: 'Not Found', }); }); - - test('should return a 403 error if the requestor is not allowed to edit', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + test('should return 403 if user is not the owner or admin', async () => { + await request(`${URL_ADDRESS}/api/layout/${VALID_LAYOUT_FOR_UPDATE.id}`) .put(`?token=${USER_TEST_TOKEN}`) + .send(VALID_LAYOUT_FOR_UPDATE) .expect(403, { - message: 'Only the owner of the layout can delete it', + message: 'Only the owner of the layout can make changes to this layout', status: 403, title: 'Unauthorized Access', }); }); - - test('should return a 400 error if the body is not provided', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + test('should return 400 for invalid layout data', async () => { + const invalidLayoutData = { ...VALID_LAYOUT_FOR_UPDATE, name: '' }; // name is required + await request(`${URL_ADDRESS}/api/layout/${VALID_LAYOUT_FOR_UPDATE.id}`) .put(`?token=${OWNER_TEST_TOKEN}`) + .send(invalidLayoutData) .expect(400, { - message: 'Failed to update layout: "id" is required', + message: 'Invalid body for update: "name" is not allowed to be empty', status: 400, title: 'Invalid Input', }); }); - - test('should return a 400 error if the name of the layout already exists', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) - .put(`?token=${OWNER_TEST_TOKEN}`) - .send(LAYOUT_MOCK_3) - .expect(400, { - message: 'Proposed layout name: a-test already exists', - status: 400, - title: 'Invalid Input', - }); - }); - - test('should update the layout successfully', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) - .put(`?token=${OWNER_TEST_TOKEN}`) - .send(LAYOUT_MOCK_2) - .expect(201, { - id: '671b8c22402408122e2f20dd', - }); - }); }); }; diff --git a/QualityControl/test/config.js b/QualityControl/test/config.js index 201bf9f54..bdaafb1fc 100644 --- a/QualityControl/test/config.js +++ b/QualityControl/test/config.js @@ -56,4 +56,17 @@ export const config = { }, brokers: ['localhost:9092'], }, + database: { + host: 'database', + port: '3306', + username: 'cern', + password: 'cern', + database: 'qcg', + charset: 'utf8mb4', + collate: 'utf8mb4_unicode_ci', + timezone: 'Etc/GMT+2', + logging: false, + maxRetries: 5, + retryThrottle: 5000, + }, }; diff --git a/QualityControl/test/demoData/layout/layout.mock.js b/QualityControl/test/demoData/layout/layout.mock.js index 041c01a3e..13cfa85c8 100644 --- a/QualityControl/test/demoData/layout/layout.mock.js +++ b/QualityControl/test/demoData/layout/layout.mock.js @@ -12,58 +12,50 @@ * or submit itself to any jurisdiction. */ -export const LAYOUT_MOCK_1 = { - id: 'mylayout', - name: 'something', - tabs: [{ name: 'tab', id: '1' }], - owner_id: 1, - owner_name: 'one', -}; - -export const LAYOUT_MOCK_2 = { - autoTabChange: 0, - collaborators: [], - description: '', - displayTimestamp: false, +export const LAYOUT_FROM_BACKEND = { id: '671b8c22402408122e2f20dd', name: 'test', - owner_id: 0, - owner_name: 'Anonymous', - tabs: [ - { - columns: 2, - id: 'test', - name: 'test', - objects: [], - }, - ], -}; - -export const LAYOUT_MOCK_3 = { - autoTabChange: 0, + owner: { + id: 0, + name: 'Anonymous', + }, + description: 'description', + display_timestamp: false, + auto_tab_change_interval: 0, + is_official: true, collaborators: [], - description: '', - displayTimestamp: false, - id: '671b8c22402408122e2f20dd', - name: 'a-test', - owner_id: 0, - owner_name: 'Anonymous', tabs: [ { - columns: 2, - id: 'test', - name: 'test', - objects: [], + id: '671b8c227b3227b0c603c29d', + name: 'main', + column_count: 2, + gridTabCells: [ + { + chart: { + id: '671b8c25d5b49dbf80e81926', + object_name: 'qc/MCH/QO/Aggregator/MCHQuality', + chartOptions: [ + { option: { name: 'option1' } }, + { option: { name: 'option2' } }, + ], + }, + row: 1, + col: 2, + row_span: 3, + col_span: 4, + }, + ], }, ], + }; -export const LAYOUT_MOCK_4 = { +export const LAYOUT_ADAPTED_FOR_FRONTEND_API = { id: '671b8c22402408122e2f20dd', name: 'test', owner_id: 0, owner_name: 'Anonymous', - description: '', + description: 'description', displayTimestamp: false, autoTabChange: 0, tabs: [ @@ -73,198 +65,37 @@ export const LAYOUT_MOCK_4 = { objects: [ { id: '671b8c25d5b49dbf80e81926', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/Aggregator/MCHQuality', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c256cdd70443c1cd709', - x: 1, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/DataDecodingCheck', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c266dd77d73874f4e90', x: 2, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/MFTRefCheck', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c2bcc75ce6053c67874', - x: 0, y: 1, - h: 1, - w: 1, - name: 'qc/MCH/MO/Pedestals/ST5/DE1006/BadChannels_XY_B_1006', - options: [], + h: 3, + w: 4, + name: 'qc/MCH/QO/Aggregator/MCHQuality', + options: ['option1', 'option2'], autoSize: false, ignoreDefaults: false, }, ], columns: 2, }, - { - id: '671b8c5aa66868891b977311', - name: 'test-tab', - objects: [ - { - id: '671b8c604deeb0f548863a8c', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/MO/Pedestals/BadChannelsPerDE', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - ], - columns: 3, - }, ], + isOfficial: true, collaborators: [], }; -export const LAYOUT_MOCK_5 = { - id: '671b95883d23cd0d67bdc787', - name: 'a-test', +export const VALID_LAYOUT_FOR_UPDATE = { + id: '671b95a8e4f3f70f2f5e4b1a', + name: 'SYNTHETIC_proton-proton', owner_id: 0, owner_name: 'Anonymous', - description: '', + description: 'updated-description', displayTimestamp: false, autoTabChange: 0, tabs: [ { - id: '671b95884312f03458f1d9ca', + id: '671b95a8f0e4f70f2f5e4b1b', name: 'main', - objects: [ - { - id: '6724a6bd1b2bad3d713cc4ee', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/test/object/1', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '6724a6bd1b2bad3d713cc4ee', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/test/object/1', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - ], columns: 2, - }, - { - id: '671b958b8a5cfb52ee9ef2a1', - name: 'a', objects: [], - columns: 2, - }, - ], - collaborators: [], -}; - -export const LAYOUT_MOCK_6 = { - id: '671b8c22402408122e2f20dd', - name: 'test', - owner_id: 0, - owner_name: 'Anonymous', - description: '', - displayTimestamp: false, - autoTabChange: 0, - tabs: [ - { - id: '671b8c227b3227b0c603c29d', - name: 'main', - objects: [ - { - id: '671b8c25d5b49dbf80e81926', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/Aggregator/MCHQuality', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c256cdd70443c1cd709', - x: 1, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/DataDecodingCheck', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c266dd77d73874f4e90', - x: 2, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/QO/MFTRefCheck', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - { - id: '671b8c2bcc75ce6053c67874', - x: 0, - y: 1, - h: 1, - w: 1, - name: 'qc/MCH/MO/Pedestals/ST5/DE1006/BadChannels_XY_B_1006', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - ], - columns: 2, - }, - { - id: '671b8c5aa66868891b977311', - name: 'test-tab', - objects: [ - { - id: '671b8c604deeb0f548863a8c', - x: 0, - y: 0, - h: 1, - w: 1, - name: 'qc/MCH/MO/Pedestals/BadChannelsPerDE', - options: [], - autoSize: false, - ignoreDefaults: false, - }, - ], - columns: 3, }, ], collaborators: [], diff --git a/QualityControl/test/lib/controllers/LayoutController.test.js b/QualityControl/test/lib/controllers/LayoutController.test.js index f6411ce91..2f990d75b 100644 --- a/QualityControl/test/lib/controllers/LayoutController.test.js +++ b/QualityControl/test/lib/controllers/LayoutController.test.js @@ -16,733 +16,213 @@ import { ok, throws, doesNotThrow, AssertionError } from 'node:assert'; import { suite, test, beforeEach } from 'node:test'; import sinon from 'sinon'; -import { LAYOUT_MOCK_1 } from './../../demoData/layout/layout.mock.js'; import { LayoutController } from './../../../lib/controllers/LayoutController.js'; -import { LayoutRepository } from '../../../lib/repositories/LayoutRepository.js'; -import { LayoutsGetDto } from '../../../lib/dtos/LayoutDto.js'; +import { LAYOUT_ADAPTED_FOR_FRONTEND_API, LAYOUT_FROM_BACKEND } from '../../demoData/layout/layout.mock.js'; export const layoutControllerTestSuite = async () => { - suite('Creating a new LayoutController instance', () => { - test('should throw an error if it is missing service for retrieving data', () => { - throws( - () => new LayoutController(undefined), - new AssertionError({ message: 'Missing layout repository', expected: true, operator: '==' }), - ); - }); - - test('should successfully initialize LayoutController', () => { - doesNotThrow(() => new LayoutController({})); - }); - }); - - suite('`getLayoutsHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - }); - - test('should respond with error if layout repository could not find layouts', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().rejects(new Error('Unable to connect')), - }); - const fields = ['id', 'name']; - - const req = { query: { fields: fields.join(','), token: 'fasdfsdfa' } }; - const layoutConnector = new LayoutController(jsonStub); - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to retrieve layouts', - status: 500, - title: 'Unknown Error', - }), 'Error message was incorrect'); - }); - - test('should log error when non-Joi validation error occurs', async () => { - const response = [{ id: 5, name: 'somelayout' }]; - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - - const req = { query: { fields: 'id,name', token: 'validtoken' } }; - - const error = new Error('Some unexpected error'); - - const originalValidate = LayoutsGetDto.validateAsync; - LayoutsGetDto.validateAsync = sinon.stub().rejects(error); - - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - LayoutsGetDto.validateAsync = originalValidate; - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to process request', - status: 500, - title: 'Unknown Error', - }), 'Error message was incorrect'); - }); - - test('should successfully return a list of layouts with required fields', async () => { - const response = [{ id: 5, name: 'somelayout' }]; - const fields = ['id', 'name']; - - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { query: { fields: fields.join(','), token: 'fasdfsdfa' } }; - const layoutConnector = new LayoutController(jsonStub); - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.status.calledWith(200), 'Response status was not 200'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); - ok( - jsonStub.listLayouts.calledWith({ fields, filter: { owner_id: undefined } }), - 'Fields were not passed correctly', - ); - }); - - test('should successfully return a list of layouts based on owner_id', async () => { - const response = [ - { user_id: 1, name: 'somelayout' }, - { user_id: 2, name: 'somelayout2' }, - ]; - const fields = 'name'; - - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { query: { owner_id: 1, token: 'fasdfsdfa', fields } }; - const layoutConnector = new LayoutController(jsonStub); - await layoutConnector.getLayoutsHandler(req, res); - ok(res.status.calledWith(200), 'Response status was not 200'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); - ok( - jsonStub.listLayouts.calledWith({ fields: [fields], filter: { owner_id: 1 } }), - 'Owner id was not used in data connector call', - ); - }); - - test('should return 400 when token is missing', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository); - const req = { - query: { - fields: 'id,name', - // token not included - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledOnce, 'Response was not sent'); - - const [[responseArg]] = res.json.args; - - ok(responseArg.message === 'Invalid query parameters: "token" is required', 'Error message incorrect'); - }); - - test('should return 400 when filter.objectPath contains an invalid type, number', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository); - const req = { - query: { - filter: { - objectPath: 12345, - }, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledOnce, 'Response was not sent'); - - ok(res.json.calledWith({ - message: 'Invalid query parameters: "Object path" must be a string', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test('should return layouts when filter.objectPath contains a valid value', async () => { - const response = [ - { user_id: 1, name: 'somelayout' }, - { user_id: 2, name: 'somelayout2' }, - ]; - - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { - query: { - filter: { - objectPath: 'qc/CPV/MO/NoiseOnFLP/BadChannelMapM2', - }, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.json.calledOnce, 'Response was not sent'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); - }); - - test('should return layouts when filter.objectPath contains a valid value, minus character', async () => { - const response = [ - { user_id: 1, name: 'somelayout' }, - { user_id: 2, name: 'somelayout2' }, - ]; - - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { - query: { - filter: { - objectPath: 'qc/CPV/MO/NoiseOn-FLP/BadChannelMapM2', - }, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.json.calledOnce, 'Response was not sent'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); - }); - - test('should return layouts when filter is present but contains no objectPath', async () => { - const response = [ - { user_id: 1, name: 'somelayout' }, - { user_id: 2, name: 'somelayout2' }, - ]; - - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { - query: { - filter: {}, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.json.calledOnce, 'Response was not sent'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); - }); - - test('should return 400 when filter.objectPath contains an invalid character: #', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository); - const req = { - query: { - filter: { - objectPath: 'qc/CPV/MO/Noise#OnFLP/BadChannelMapM2', - }, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - const message = 'Invalid query parameters: "Object path" with value ' + - '"qc/CPV/MO/Noise#OnFLP/BadChannelMapM2" fails to match the required pattern: /^[A-Za-z0-9_\\-/]+$/'; - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: message, - status: 400, - title: 'Invalid Input', - }), 'Error message is not as expected'); - }); - - test('should return 400 when fields contain invalid values', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository); - const req = { - query: { - fields: 'id,invalid_field', - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); - - await layoutConnector.getLayoutsHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledOnce, 'Response was not sent'); - - ok(res.json.calledWith({ - message: 'Invalid query parameters: "fields" contains invalid field: invalid_field', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - }); - - suite('`getLayoutHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - }); - test('should respond with 400 error if request did not contain layout id when requesting to read', async () => { - const req = { params: { id: ' ' } }; // empty token is the only way to realisticly cause this error - const layoutConnector = new LayoutController({}); - await layoutConnector.getLayoutHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Missing parameter "id" of layout', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test('should successfully return a layout specified by its id', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves([{ layout: 'somelayout' }]), - }); - const layoutConnector = new LayoutController(jsonStub); - const req = { params: { id: 'mylayout' } }; - await layoutConnector.getLayoutHandler(req, res); - - ok(res.status.calledWith(200), 'Response status was not 200'); - ok(res.json.calledWith([{ layout: 'somelayout' }]), 'A JSON defining a layout should have been sent back'); - ok(jsonStub.readLayoutById.calledWith('mylayout'), 'Layout id was not used in data connector call'); - }); - - test('should return error if data connector failed', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().rejects(new Error('Unable to read layout')), - }); - const layoutConnector = new LayoutController(jsonStub); - const req = { params: { id: 'mylayout' } }; - - await layoutConnector.getLayoutHandler(req, res); - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to read layout', - status: 500, - title: 'Unknown Error', - }), 'Error message was incorrect'); - ok(jsonStub.readLayoutById.calledWith('mylayout'), 'Layout id was not used in data connector call'); - }); - }); - - suite('`getLayoutByNameHandler` test suite', () => { + suite('LayoutController Test Suite', () => { + let req = {}; let res = {}; + let layoutServiceMock = null; + let layoutController = null; beforeEach(() => { + req = {}; res = { status: sinon.stub().returnsThis(), json: sinon.stub(), }; - }); - - test('should successfully return layout with name provided', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutByName: sinon.stub().resolves([{ name: 'somelayout', id: '1234' }]), - }); - const layoutConnector = new LayoutController(jsonStub); - const req = { query: { name: 'somelayout' } }; - await layoutConnector.getLayoutByNameHandler(req, res); - - ok(res.status.calledWith(200), 'Response status was not 200'); - ok( - res.json.calledWith([{ name: 'somelayout', id: '1234' }]), - 'A JSON defining a layout should have been sent back', - ); - }); - - test('should successfully return layout with runDefinition and pdpBeamType provided', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutByName: sinon.stub().resolves([{ name: 'calibration_pp', id: '1234' }]), - }); - const layoutConnector = new LayoutController(jsonStub); - const req = { query: { runDefinition: 'calibration', pdpBeamType: 'pp' } }; - await layoutConnector.getLayoutByNameHandler(req, res); - - ok(res.status.calledWith(200), 'Response status was not 200'); - ok( - res.json.calledWith([{ name: 'calibration_pp', id: '1234' }]), - 'A JSON defining a layout should have been sent back', - ); - ok(jsonStub.readLayoutByName.calledWith('calibration_pp'), 'Incorrect name for layout provided'); - }); - - test('should return error due to missing input values', async () => { - const layoutConnector = new LayoutController({}); - const req = { query: { pdpBeamType: 'pp' } }; - await layoutConnector.getLayoutByNameHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Missing query parameters', - status: 400, - title: 'Invalid Input', - }), 'Error message is not as expected'); - }); - }); - - suite('`putLayoutHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), + layoutServiceMock = { + getLayoutsByFilters: sinon.stub(), + getLayoutByName: sinon.stub(), }; + layoutController = new LayoutController(layoutServiceMock); }); - test('should successfully return the id of the updated layout', async () => { - const expectedMockWithDefaults = { - id: 'mylayout', - name: 'something', - tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }], - owner_id: 1, - owner_name: 'one', - collaborators: [], - displayTimestamp: false, - autoTabChange: 0, - }; - const jsonStub = sinon.createStubInstance(LayoutRepository, { - updateLayout: sinon.stub().resolves(expectedMockWithDefaults.id), - listLayouts: sinon.stub().resolves([]), - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), + suite('Creating a new LayoutController instance', () => { + test('should throw an error if it is missing service for retrieving data', () => { + throws( + () => new LayoutController(undefined), + new AssertionError({ message: 'Missing layout service', expected: true, operator: '==' }), + ); }); - const layoutConnector = new LayoutController(jsonStub); - - const req = { params: { id: 'mylayout' }, session: { personid: 1, name: 'one' }, body: LAYOUT_MOCK_1 }; - await layoutConnector.putLayoutHandler(req, res); - ok(res.status.calledWith(201), 'Response status was not 200'); - ok(res.json.calledWith({ id: expectedMockWithDefaults.id }), 'A layout id should have been sent back'); - ok( - jsonStub.updateLayout.calledWith('mylayout', expectedMockWithDefaults), - 'Layout id was not used in data connector call', - ); - }); - test('should return 400 code if new provided name already exists', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves([{ name: 'something' }]), - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), + test('should successfully initialize LayoutController', () => { + doesNotThrow(() => new LayoutController({})); }); - const layoutConnector = new LayoutController(jsonStub); - - const req = { params: { id: 'mylayout' }, session: { personid: 1, name: 'one' }, body: LAYOUT_MOCK_1 }; - await layoutConnector.putLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Proposed layout name: something already exists', - status: 400, - title: 'Invalid Input', - }), 'Error message is not the same'); }); - test('should return error if data connector failed to update layout', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - listLayouts: sinon.stub().resolves([]), - updateLayout: sinon.stub().rejects(new Error('Could not update layout')), + suite('`getLayoutsHandler()` tests', () => { + test('should successfully return a list of layouts', async () => { + req = { query: { owner_id: 'test-owner-id', filter: {} } }; + layoutServiceMock.getLayoutsByFilters = sinon.stub().resolves([LAYOUT_FROM_BACKEND]); + await layoutController.getLayoutsHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok(res.json.calledWith([LAYOUT_ADAPTED_FOR_FRONTEND_API]), 'A JSON defining a layout should have been sent back'); }); - const layoutConnector = new LayoutController(jsonStub); - const expectedMockWithDefaults = { - id: 'mylayout', - name: 'something', - tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }], - owner_id: 1, - owner_name: 'one', - collaborators: [], - displayTimestamp: false, - autoTabChange: 0, - }; - const req = { params: { id: LAYOUT_MOCK_1.id }, session: { personid: 1, name: 'one' }, body: LAYOUT_MOCK_1 }; - await layoutConnector.putLayoutHandler(req, res); - - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Could not update layout', - status: 500, - title: 'Unknown Error', - }), 'DataConnector error message is incorrect'); - ok( - jsonStub.updateLayout.calledWith('mylayout', expectedMockWithDefaults), - 'Layout id was not used in data connector call', - ); - }); - }); - - suite('`deleteLayoutHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - }); - - test('should successfully return the id of the deleted layout', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - deleteLayout: sinon.stub().resolves({ id: 'somelayout' }), + test('should successfully return a list of layouts with only requested fields', async () => { + req = { query: { owner_id: 'test-owner-id', filter: {}, fields: ['id', 'name'] } }; + layoutServiceMock.getLayoutsByFilters = sinon.stub().resolves([LAYOUT_FROM_BACKEND]); + await layoutController.getLayoutsHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok( + res.json.calledWith([{ id: LAYOUT_FROM_BACKEND.id, name: LAYOUT_FROM_BACKEND.name }]), + 'A JSON defining a layout should have been sent back with only requested fields', + ); }); - const layoutConnector = new LayoutController(jsonStub); - const req = { params: { id: 'somelayout' }, session: { personid: 1, name: 'one' } }; - await layoutConnector.deleteLayoutHandler(req, res); - ok(res.status.calledWith(200), 'Response status was not 200'); - ok(res.json.calledWith({ id: 'somelayout' }), 'A layout id should have been sent back'); - ok(jsonStub.deleteLayout.calledWith('somelayout'), 'Layout id was not used in data connector call'); - }); - - test('should return error if data connector failed to delete', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - deleteLayout: sinon.stub().rejects(new Error('Could not delete layout')), + test('should return error if service failed to retrieve layouts', async () => { + req = { query: { owner_id: 'test-owner-id', filter: {} } }; + layoutServiceMock.getLayoutsByFilters = sinon.stub().rejects(new Error('Unable to retrieve layouts')); + await layoutController.getLayoutsHandler(req, res); + ok(res.status.calledWith(500), 'Response status was not 500'); + ok(res.json.calledWith({ + message: 'Unable to retrieve layouts', + status: 500, + title: 'Unknown Error', + }), 'Error message was incorrect'); }); - const layoutConnector = new LayoutController(jsonStub); - const req = { params: { id: 'mylayout' }, session: { personid: 1, name: 'one' } }; - await layoutConnector.deleteLayoutHandler(req, res); - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to delete layout with id: mylayout', - status: 500, - title: 'Unknown Error', - }), 'DataConnector error message is incorrect'); - ok(jsonStub.deleteLayout.calledWith('mylayout'), 'Layout id was not used in data connector call'); }); - }); - - suite('`postLayoutHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - }); - - test('should respond with 400 error if request did not contain layout "id" when requesting to create', async () => { - const req = { body: {} }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "id" is required', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test( - 'should respond with 400 error if request did not contain layout "name" when requesting to create', - async () => { - const req = { body: { id: '1' } }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); + suite('`getLayoutByIdHandler()` tests', () => { + test('should successfully return a layout specified by its id', async () => { + req = { params: { id: 'test-layout-id' }, layout: LAYOUT_FROM_BACKEND }; + await layoutController.getLayoutHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok(res.json.calledWith(LAYOUT_ADAPTED_FOR_FRONTEND_API), 'A JSON defining a layout should have been sent back'); + }); + }); + suite('`getLayoutByNameHandler()` tests', () => { + test('should successfully call getLLayoutByName with name', async () => { + layoutServiceMock.getLayoutByName = sinon.stub().resolves(LAYOUT_FROM_BACKEND); + req = { query: { name: 'CALIBRATIONS' } }; + await layoutController.getLayoutByNameHandler(req, res); + ok( + layoutServiceMock.getLayoutByName.calledWith('CALIBRATIONS'), + 'Service was not called with correct parameters', + ); + }); + test('should successfully call getLayoutByName with runDefinition and pdpBeamType', async () => { + layoutServiceMock.getLayoutByName = sinon.stub().resolves(LAYOUT_FROM_BACKEND); + req = { query: { runDefinition: 'LHC18b', pdpBeamType: 'A' } }; + await layoutController.getLayoutByNameHandler(req, res); + ok( + layoutServiceMock.getLayoutByName.calledWith('LHC18b_A'), + 'Service was not called with correct parameters', + ); + }); + test('should successfully call getLayoutByName with only runDefinition', async () => { + layoutServiceMock.getLayoutByName = sinon.stub().resolves([LAYOUT_FROM_BACKEND]); + req = { query: { runDefinition: 'LHC18b' } }; + await layoutController.getLayoutByNameHandler(req, res); + ok( + layoutServiceMock.getLayoutByName.calledWith('LHC18b'), + 'Service was not called with correct parameters', + ); + }); + test('should return error if neither name nor runDefinition is provided', async () => { + req = { query: {} }; + await layoutController.getLayoutByNameHandler(req, res); ok(res.status.calledWith(400), 'Response status was not 400'); ok(res.json.calledWith({ - message: 'Failed to validate layout: "name" is required', + message: 'Missing query parameters', status: 400, title: 'Invalid Input', }), 'Error message was incorrect'); - }, - ); - - test('should respond with 400 error if request did not contain "tabs" when requesting to create', async () => { - const req = { body: { name: 'somelayout', id: '1' } }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "tabs" is required', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test('should respond with 400 error if request did not proper "tabs" when requesting to create', async () => { - const req = { body: { name: 'somelayout', tabs: [{ some: 'some' }], id: '1' } }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "tabs[0].id" is required', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test('should respond with 400 error if request did not contain "owner_id" when requesting to create', async () => { - const req = { body: { name: 'somelayout', tabs: [{ id: '1', name: 'tab' }], id: '1' } }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "owner_id" is required', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }); - - test( - 'should respond with 400 error if request did not contain "owner_name" when requesting to create', - async () => { - const req = { body: { name: 'somelayout', id: '1', owner_id: 123, tabs: [{ id: '123', name: 'tab' }] } }; - const layoutConnector = new LayoutController({}); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); + }); + test('should successfully return a layout specified by its name', async () => { + req = { query: { name: 'CALIBRATIONS' } }; + layoutServiceMock.getLayoutByName = sinon.stub().resolves(LAYOUT_FROM_BACKEND); + await layoutController.getLayoutByNameHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok(res.json.calledWith(LAYOUT_ADAPTED_FOR_FRONTEND_API), 'A JSON defining a layout should have been sent back'); + }); + }); + suite('`putLayoutHandler()` tests', () => { + test('should successfully update a layout specified by its id', async () => { + req = { params: { id: 'test-layout-id' }, body: {} }; + layoutServiceMock.putLayout = sinon.stub().resolves('test-layout-id'); + await layoutController.putLayoutHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok( + res.json.calledWith({ id: 'test-layout-id' }), + 'A JSON with the updated layout ID should have been sent back', + ); + }); + test('should return error if service failed to update layout', async () => { + req = { params: { id: 'test-layout-id' }, body: {} }; + layoutServiceMock.putLayout = sinon.stub().rejects(new Error('Unable to update layout')); + await layoutController.putLayoutHandler(req, res); + ok(res.status.calledWith(500), 'Response status was not 500'); ok(res.json.calledWith({ - message: 'Failed to validate layout: "owner_name" is required', - status: 400, - title: 'Invalid Input', + message: 'Unable to update layout', + status: 500, + title: 'Unknown Error', }), 'Error message was incorrect'); - }, - ); - - test('should respond with 400 error if request a layout already exists with provided name', async () => { - const req = { - body: { name: 'somelayout', id: '1', owner_name: 'admin', owner_id: 123, tabs: [{ id: '123', name: 'tab' }] }, - }; - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves([{ name: 'somelayout' }]), }); - const layoutConnector = new LayoutController(jsonStub); - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Proposed layout name: somelayout already exists', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); }); - - test('should successfully return created layout with default for missing values', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - createLayout: sinon.stub().resolves({ layout: 'somelayout' }), - listLayouts: sinon.stub().resolves([]), + suite('`deleteLayoutHandler()` tests', () => { + test('should successfully delete a layout specified by its id', async () => { + req = { params: { id: 'test-layout-id' } }; + layoutServiceMock.removeLayout = sinon.stub().resolves({ id: 'test-layout-id', deleted: true }); + await layoutController.deleteLayoutHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok( + res.json.calledWith({ id: 'test-layout-id', deleted: true }), + 'A JSON with the deletion result should have been sent back', + ); + }); + test('should return error if service failed to delete layout', async () => { + req = { params: { id: 'test-layout-id' } }; + layoutServiceMock.removeLayout = sinon.stub().rejects(new Error('Unable to delete layout')); + await layoutController.deleteLayoutHandler(req, res); + ok(res.status.calledWith(500), 'Response status was not 500'); + ok(res.json.calledWith({ + message: 'Unable to delete layout', + status: 500, + title: 'Unknown Error', + }), 'Error message was incorrect'); }); - const expected = { - id: '1', - name: 'somelayout', - owner_id: 1, - owner_name: 'admin', - tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }], - collaborators: [], - displayTimestamp: false, - autoTabChange: 0, - }; - const layoutConnector = new LayoutController(jsonStub); - const req = { - body: { id: '1', name: 'somelayout', owner_id: 1, owner_name: 'admin', tabs: [{ id: '123', name: 'tab' }] }, - }; - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(201), 'Response status was not 201'); - ok(res.json.calledWith({ layout: 'somelayout' }), 'A layout should have been sent back'); - ok(jsonStub.createLayout.calledWith(expected), 'New layout body was not used in data connector call'); }); - - test('should return error if data connector failed to create', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - createLayout: sinon.stub().rejects(new Error('Could not create layout')), - listLayouts: sinon.stub().resolves([]), + suite('`postLayoutHandler()` tests', () => { + test('should successfully create a new layout', async () => { + req = { body: LAYOUT_ADAPTED_FOR_FRONTEND_API }; + layoutServiceMock.postLayout = sinon.stub().resolves(LAYOUT_ADAPTED_FOR_FRONTEND_API); + await layoutController.postLayoutHandler(req, res); + ok(res.status.calledWith(201), 'Response status was not 201'); + ok(res.json.calledWith(LAYOUT_ADAPTED_FOR_FRONTEND_API), 'A JSON with the new layout should have been sent back'); }); - const layoutConnector = new LayoutController(jsonStub); - const req = { - body: { id: '1', name: 'somelayout', owner_id: 1, owner_name: 'admin', tabs: [{ id: '123', name: 'tab' }] }, - }; - const expected = { - id: '1', - name: 'somelayout', - owner_id: 1, - owner_name: 'admin', - tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }], - collaborators: [], - displayTimestamp: false, - autoTabChange: 0, - }; - await layoutConnector.postLayoutHandler(req, res); - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to create new layout', - status: 500, - title: 'Unknown Error', - }), 'DataConnector error message is incorrect'); - ok(jsonStub.createLayout.calledWith(expected), 'New layout body was not used in data connector call'); - }); - }); - - suite('`patchLayoutHandler()` test suite', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - }); - - test('should successfully update the official field of a layout', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - updateLayout: sinon.stub().resolves(LAYOUT_MOCK_1.id), + test('should return error if service failed to create a new layout', async () => { + req = { body: LAYOUT_ADAPTED_FOR_FRONTEND_API }; + layoutServiceMock.postLayout = sinon.stub().rejects(new Error('Unable to create layout')); + await layoutController.postLayoutHandler(req, res); + ok(res.status.calledWith(500), 'Response status was not 500'); + ok(res.json.calledWith({ + message: 'Unable to create layout', + status: 500, + title: 'Unknown Error', + }), 'Error message was incorrect'); }); - const layoutConnector = new LayoutController(jsonStub); - - const req = { params: { id: 'mylayout' }, session: { personid: 1 }, body: { isOfficial: true } }; - await layoutConnector.patchLayoutHandler(req, res); - ok(res.status.calledWith(201), 'Response status was not 201'); - ok(res.json.calledWith({ id: 'mylayout' })); - ok(jsonStub.updateLayout.calledWith('mylayout', { isOfficial: true })); - }); - - test('should return error due to invalid request body containing more than expected fields', async () => { - const layoutConnector = new LayoutController({}); - - const req = { params: { id: 'mylayout' }, session: { personid: 1 }, body: { isOfficial: true, missing: true } }; - await layoutConnector.patchLayoutHandler(req, res); - - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "missing" is not allowed', - status: 400, - title: 'Invalid Input', - })); }); - - test('should return error due to layout update operation failing', async () => { - const jsonStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - updateLayout: sinon.stub().rejects(new Error('Does not work')), + suite('`patchLayoutHandler()` tests', () => { + test('should successfully patch a layout specified by its id', async () => { + req = { params: { id: 'test-layout-id' }, body: {} }; + layoutServiceMock.patchLayout = sinon.stub().resolves('test-layout-id'); + await layoutController.patchLayoutHandler(req, res); + ok(res.status.calledWith(200), 'Response status was not 200'); + ok( + res.json.calledWith({ id: 'test-layout-id' }), + 'A JSON with the patched layout ID should have been sent back', + ); + }); + test('should return error if service failed to patch layout', async () => { + req = { params: { id: 'test-layout-id' }, body: {} }; + layoutServiceMock.patchLayout = sinon.stub().rejects(new Error('Unable to patch layout')); + await layoutController.patchLayoutHandler(req, res); + ok(res.status.calledWith(500), 'Response status was not 500'); + ok(res.json.calledWith({ + message: 'Unable to patch layout', + status: 500, + title: 'Unknown Error', + }), 'Error message was incorrect'); }); - const layoutConnector = new LayoutController(jsonStub); - - const req = { params: { id: 'mylayout' }, session: { personid: 1 }, body: { isOfficial: true } }; - await layoutConnector.patchLayoutHandler(req, res); - - ok(res.status.calledWith(500), 'Response status was not 500'); - ok(res.json.calledWith({ - message: 'Unable to update layout with id: mylayout', - status: 500, - title: 'Unknown Error', - })); - ok( - jsonStub.updateLayout.calledWith('mylayout', { isOfficial: true }), - 'Layout id was not used in data connector call', - ); }); }); }; diff --git a/QualityControl/test/lib/controllers/UserController.test.js b/QualityControl/test/lib/controllers/UserController.test.js index f223726f0..f8dc990fe 100644 --- a/QualityControl/test/lib/controllers/UserController.test.js +++ b/QualityControl/test/lib/controllers/UserController.test.js @@ -18,16 +18,16 @@ import sinon from 'sinon'; import { ok } from 'node:assert'; export const userControllerTestSuite = async () => { - let userRepositoryMock = null; + let userServiceMock = null; let userController = null; let reqMock = null; let resMock = null; beforeEach(() => { - userRepositoryMock = { - createUser: sinon.stub().resolves(), + userServiceMock = { + createNewUser: sinon.stub().resolves(), }; - userController = new UserController(userRepositoryMock); + userController = new UserController(userServiceMock); reqMock = { session: { personid: 123, @@ -45,12 +45,25 @@ export const userControllerTestSuite = async () => { sinon.restore(); }); + suite('constructor', () => { + test('should throw an error if UserService is not provided', () => { + try { + new UserController(); + } catch (err) { + ok(err instanceof Error); + ok(err.message === 'Missing User Service'); + } + }); + test('should create an instance of UserController when UserService is provided', () => { + const controller = new UserController(userServiceMock); + ok(controller instanceof UserController); + }); + }); suite('addUserHandler', () => { - test('should add a user successfully', async () => { + test('should call createNewUser of UserService with correct parameters', async () => { await userController.addUserHandler(reqMock, resMock); - - ok(userRepositoryMock.createUser.calledOnce); - ok(userRepositoryMock.createUser.calledWith({ + ok(userServiceMock.createNewUser.calledOnce); + ok(userServiceMock.createNewUser.calledWith({ id: 123, name: 'Test User', username: 'testuser', @@ -58,70 +71,12 @@ export const userControllerTestSuite = async () => { ok(resMock.status.calledWith(200)); ok(resMock.json.calledWith({ ok: true })); }); - - test('should handle errors during user creation', async () => { - const error = new Error('User creation failed'); - userRepositoryMock.createUser.rejects(error); - - await userController.addUserHandler(reqMock, resMock); - - ok(resMock.status.calledWith(502)); - ok(resMock.json.calledWith({ - ok: false, - message: 'Unable to add user to memory', - })); - }); - - test('should handle missing username', async () => { - reqMock.session.username = undefined; - - await userController.addUserHandler(reqMock, resMock); - - ok(userRepositoryMock.createUser.notCalled); - ok(resMock.status.calledWith(502)); - ok(resMock.json.calledWith({ - ok: false, - message: 'Unable to add user to memory', - })); - }); - - test('should handle missing name', async () => { - reqMock.session.name = undefined; - - await userController.addUserHandler(reqMock, resMock); - - ok(userRepositoryMock.createUser.notCalled); - ok(resMock.status.calledWith(502)); - ok(resMock.json.calledWith({ - ok: false, - message: 'Unable to add user to memory', - })); - }); - - test('should handle missing personid', async () => { - reqMock.session.personid = undefined; - + //502 if userService fails + test('should respond with 502 if UserService.createNewUser throws an error', async () => { + userServiceMock.createNewUser.rejects(new Error('DB error')); await userController.addUserHandler(reqMock, resMock); - - ok(userRepositoryMock.createUser.notCalled); ok(resMock.status.calledWith(502)); - ok(resMock.json.calledWith({ - ok: false, - message: 'Unable to add user to memory', - })); - }); - - test('should handle personid that is not a number', async () => { - reqMock.session.personid = 'abc'; - - await userController.addUserHandler(reqMock, resMock); - - ok(userRepositoryMock.createUser.notCalled); - ok(resMock.status.calledWith(502)); - ok(resMock.json.calledWith({ - ok: false, - message: 'Unable to add user to memory', - })); + ok(resMock.json.calledWith({ ok: false, message: 'Unable to add user to memory' })); }); }); }; diff --git a/QualityControl/test/lib/controllers/adapters/layout-adapter.test.js b/QualityControl/test/lib/controllers/adapters/layout-adapter.test.js new file mode 100644 index 000000000..0f501a0aa --- /dev/null +++ b/QualityControl/test/lib/controllers/adapters/layout-adapter.test.js @@ -0,0 +1,41 @@ +import { deepStrictEqual } from 'node:assert'; +import { suite, test } from 'node:test'; + +import { LAYOUT_FROM_BACKEND, LAYOUT_ADAPTED_FOR_FRONTEND_API } from '../../../demoData/layout/layout.mock.js'; +import { LayoutAdapter } from '../../../../lib/controllers/adapters/layout-adapter.js'; + +export const layoutAdapterTestSuite = async () => { + suite('LayoutAdapter Test Suite', () => { + test('should adapt layout correctly without fields filter', () => { + const adaptedLayout = LayoutAdapter.adaptLayoutForExpressAPI(LAYOUT_FROM_BACKEND); + deepStrictEqual(sortKeys(adaptedLayout), sortKeys(LAYOUT_ADAPTED_FOR_FRONTEND_API)); + }); + test('should adapt layout correctly with fields filter', () => { + const adaptedLayout = LayoutAdapter.adaptLayoutForExpressAPI(LAYOUT_FROM_BACKEND, ['id', 'name']); + deepStrictEqual( + sortKeys(adaptedLayout), + sortKeys({ id: LAYOUT_ADAPTED_FOR_FRONTEND_API.id, name: LAYOUT_ADAPTED_FOR_FRONTEND_API.name }), + ); + }); + }); +}; + +/** + * Recursively sorts the keys of an object. + * @param {object} obj The object to sort. + * @returns {object} A new object with sorted keys. + */ +function sortKeys(obj) { + if (Array.isArray(obj)) { + return obj.map(sortKeys); + } + if (obj && typeof obj === 'object') { + return Object.keys(obj) + .sort() + .reduce((res, key) => { + res[key] = sortKeys(obj[key]); + return res; + }, {}); + } + return obj; +} diff --git a/QualityControl/test/lib/database/repositories/ChartOptionsRepository.test.js b/QualityControl/test/lib/database/repositories/ChartOptionsRepository.test.js new file mode 100644 index 000000000..dd9d24007 --- /dev/null +++ b/QualityControl/test/lib/database/repositories/ChartOptionsRepository.test.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { ChartOptionsRepository } from '../../../../lib/database/repositories/ChartOptionsRepository.js'; + +/** + * Test suite for ChartOptionsRepository + */ +export const chartOptionsRepositoryTestSuite = () => { + suite('ChartOptionsRepository', () => { + let mockChartOptionsModel = null; + let chartOptionsRepository = null; + + beforeEach(() => { + mockChartOptionsModel = { + name: 'ChartOptions', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + bulkCreate: sinon.stub(), + }; + chartOptionsRepository = new ChartOptionsRepository(mockChartOptionsModel); + }); + + test('should create instance with chart options model', () => { + ok(chartOptionsRepository instanceof ChartOptionsRepository); + strictEqual(chartOptionsRepository.model, mockChartOptionsModel); + }); + + test('should create a new chart option', async () => { + const optionData = { chart_id: 1, option_id: 2 }; + const createdOption = { id: 1, ...optionData }; + mockChartOptionsModel.create.resolves(createdOption); + + const result = await chartOptionsRepository.createChartOption(optionData, { transaction: 'tx' }); + deepStrictEqual(result, createdOption); + ok(mockChartOptionsModel.create.calledOnceWith(optionData, { transaction: 'tx' })); + }); + + test('should find chart options by chart ID', async () => { + const chartId = 1; + const foundOptions = [{ chart_id: chartId, option_id: 2 }, { chart_id: chartId, option_id: 3 }]; + mockChartOptionsModel.findAll.resolves(foundOptions); + + const result = await chartOptionsRepository.findChartOptionsByChartId(chartId, { transaction: 'tx' }); + deepStrictEqual(result, foundOptions); + ok(mockChartOptionsModel.findAll.calledOnceWith({ where: { chart_id: chartId }, transaction: 'tx' })); + }); + + test('should delete a chart option', async () => { + const params = { chartId: 1, optionId: 2 }; + mockChartOptionsModel.destroy.resolves(1); + + const result = await chartOptionsRepository.deleteChartOption(params, { transaction: 'tx' }); + strictEqual(result, 1); + ok(mockChartOptionsModel.destroy.calledOnceWith({ + where: { chart_id: params.chartId, option_id: params.optionId }, transaction: 'tx' })); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/ChartRepository.test.js b/QualityControl/test/lib/database/repositories/ChartRepository.test.js new file mode 100644 index 000000000..d7c8dd1e4 --- /dev/null +++ b/QualityControl/test/lib/database/repositories/ChartRepository.test.js @@ -0,0 +1,84 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { ChartRepository } from '../../../../lib/database/repositories/ChartRepository.js'; + +/** + * Test suite for ChartRepository + */ +export const chartRepositoryTestSuite = () => { + suite('ChartRepository', () => { + let mockChartModel = null; + let chartRepository = null; + + beforeEach(() => { + mockChartModel = { + name: 'Chart', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + }; + chartRepository = new ChartRepository(mockChartModel); + }); + + test('should create instance with chart model', () => { + ok(chartRepository instanceof ChartRepository); + strictEqual(chartRepository.model, mockChartModel); + }); + + test('should find a chart by ID', async () => { + const chartId = 'chart1'; + const foundChart = { id: chartId, object_name: 'Test Chart', ignore_defaults: false }; + mockChartModel.findByPk.resolves(foundChart); + + const result = await chartRepository.findChartById(chartId, { transaction: 'tx' }); + deepStrictEqual(result, foundChart); + ok(mockChartModel.findByPk.calledOnceWith(chartId, { transaction: 'tx' })); + }); + + test('should create a new chart', async () => { + const chartData = { id: 'chart2', object_name: 'New Chart', ignore_defaults: true }; + const createdChart = { ...chartData }; + mockChartModel.create.resolves(createdChart); + + const result = await chartRepository.createChart(chartData, { transaction: 'tx' }); + deepStrictEqual(result, createdChart); + ok(mockChartModel.create.calledOnceWith(chartData, { transaction: 'tx' })); + }); + + test('should update an existing chart', async () => { + const chartId = 'chart1'; + const updateData = { object_name: 'Updated Chart', ignore_defaults: true }; + mockChartModel.update.resolves([1]); // Simulate one row updated + + const result = await chartRepository.updateChart(chartId, updateData, { transaction: 'tx' }); + strictEqual(result, 1); + ok(mockChartModel.update.calledOnceWith(updateData, { where: { id: chartId }, transaction: 'tx' })); + }); + + test('should delete a chart by ID', async () => { + const chartId = 'chart1'; + mockChartModel.destroy.resolves(1); // Simulate one row deleted + + const result = await chartRepository.deleteChart(chartId, { transaction: 'tx' }); + strictEqual(result, 1); + ok(mockChartModel.destroy.calledOnceWith({ where: { id: chartId }, transaction: 'tx' })); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/GridTabCellRepository.test.js b/QualityControl/test/lib/database/repositories/GridTabCellRepository.test.js new file mode 100644 index 000000000..89d669335 --- /dev/null +++ b/QualityControl/test/lib/database/repositories/GridTabCellRepository.test.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { GridTabCellRepository } from '../../../../lib/database/repositories/GridTabCellRepository.js'; + +/** + * Test suite for GridTabCellRepository + */ +export const gridTabCellRepositoryTestSuite = () => { + suite('GridTabCellRepository', () => { + let mockGridTabCellModel = null; + let gridTabCellRepository = null; + + beforeEach(() => { + mockGridTabCellModel = { + name: 'GridTabCell', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + bulkCreate: sinon.stub(), + }; + gridTabCellRepository = new GridTabCellRepository(mockGridTabCellModel); + }); + + test('should create instance with grid tab cell model', () => { + ok(gridTabCellRepository instanceof GridTabCellRepository); + strictEqual(gridTabCellRepository.model, mockGridTabCellModel); + }); + + test('should find grid tab cells by tab ID', async () => { + const tabId = 'tab123'; + const expectedCells = [{ id: 1, tab_id: tabId }, { id: 2, tab_id: tabId }]; + mockGridTabCellModel.findAll.resolves(expectedCells); + + const cells = await gridTabCellRepository.findByTabId(tabId); + + deepStrictEqual(cells, expectedCells); + ok(mockGridTabCellModel.findAll.calledOnceWith({ where: { tab_id: tabId } })); + }); + + test('should find object by chart ID', async () => { + const chartId = 'chart123'; + const expectedCell = { + id: 1, + chart_id: chartId, + tab: { + + name: 'Tab1', layout: { name: 'Layout1' }, + }, + chart: { object_name: 'Chart1', ignore_defaults: true, chartOptions: [] }, + }; + mockGridTabCellModel.findOne.resolves(expectedCell); + const cell = await gridTabCellRepository.findObjectByChartId(chartId); + deepStrictEqual(cell, expectedCell); + ok(mockGridTabCellModel.findOne.calledOnceWith({ where: { chart_id: chartId }, include: sinon.match.array })); + }); + + test('should create a new grid tab cell', async () => { + const newCellData = { tab_id: 'tab123', chart_id: 'chart123' }; + const createdCell = { id: 1, ...newCellData }; + mockGridTabCellModel.create.resolves(createdCell); + + const cell = await gridTabCellRepository.createGridTabCell(newCellData); + deepStrictEqual(cell, createdCell); + ok(mockGridTabCellModel.create.calledOnceWith(newCellData)); + }); + + test('should update a grid tab cell by chart ID and tab ID', async () => { + const chartId = 'chart123'; + const tabId = 'tab123'; + const updateData = { some_field: 'newValue' }; + const updatedCount = 1; + mockGridTabCellModel.update.resolves([updatedCount]); + + const result = await gridTabCellRepository.updateGridTabCell({ chartId, tabId }, updateData); + strictEqual(result, updatedCount); + ok(mockGridTabCellModel.update.calledOnceWith( + updateData, + { where: { chart_id: chartId, tab_id: tabId } }, + )); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/LayoutRepository.test.js b/QualityControl/test/lib/database/repositories/LayoutRepository.test.js new file mode 100644 index 000000000..72565c960 --- /dev/null +++ b/QualityControl/test/lib/database/repositories/LayoutRepository.test.js @@ -0,0 +1,146 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { LayoutRepository } from '../../../../lib/database/repositories/LayoutRepository.js'; +import { Op } from 'sequelize'; + +/** + * Test suite for LayoutRepository + */ +export const layoutRepositoryTestSuite = () => { + suite('LayoutRepository', () => { + let mockLayoutModel = null; + let layoutRepository = null; + + beforeEach(() => { + mockLayoutModel = { + name: 'Layout', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + }; + layoutRepository = new LayoutRepository(mockLayoutModel); + }); + + test('should create instance with layout model', () => { + ok(layoutRepository instanceof LayoutRepository); + strictEqual(layoutRepository.model, mockLayoutModel); + }); + + test('should find layout by ID', async () => { + const mockLayout = { id: '1', name: 'Test Layout' }; + mockLayoutModel.findByPk.resolves(mockLayout); + + const result = await layoutRepository.findLayoutById('1'); + + deepStrictEqual(result, mockLayout); + ok(mockLayoutModel.findByPk.calledOnceWith('1', { include: layoutRepository._layoutInfoToInclude })); + }); + + test('should find a layout by name', async () => { + const mockLayout = { id: '1', name: 'Unique Layout' }; + mockLayoutModel.findOne.resolves(mockLayout); + + const result = await layoutRepository.findLayoutByName('Unique Layout'); + + deepStrictEqual(result, mockLayout); + ok(mockLayoutModel.findOne.calledOnceWith({ + where: { name: 'Unique Layout' }, + include: layoutRepository._layoutInfoToInclude, + })); + }); + + test('should find all layouts', async () => { + const mockLayouts = [ + { id: '1', name: 'Layout 1' }, + { id: '2', name: 'Layout 2' }, + ]; + mockLayoutModel.findAll.resolves(mockLayouts); + + const result = await layoutRepository.findAllLayouts(); + + deepStrictEqual(result, mockLayouts); + ok(mockLayoutModel.findAll.calledOnceWith({ include: layoutRepository._layoutInfoToInclude })); + }); + + test('should find layouts by filters', async () => { + //mock _getLayoutIdsByObjectPath + sinon.stub(layoutRepository, '_getLayoutIdsByObjectPath').resolves(['1', '2']); + const mockLayouts = [ + { id: '1', name: 'Filtered Layout 1' }, + { id: '2', name: 'Filtered Layout 2' }, + ]; + const filters = { objectPath: 'ITS/MC/RT' }; + mockLayoutModel.findAll.resolves(mockLayouts); + + const result = await layoutRepository.findLayoutsByFilters(filters); + + deepStrictEqual(result, mockLayouts); + ok(layoutRepository._getLayoutIdsByObjectPath.calledOnceWith('ITS/MC/RT')); + ok(mockLayoutModel.findAll.calledOnce, 'Expected findAll to be called once'); + const [callArgs] = mockLayoutModel.findAll.getCall(0).args; + ok(callArgs.include, 'Expected include to be defined'); + strictEqual(callArgs.where.id[Op.in].length, 2, 'Expected where clause to filter by two IDs'); + }); + + test('should find layout by name', async () => { + const mockLayout = { id: '1', name: 'Unique Layout' }; + mockLayoutModel.findOne.resolves(mockLayout); + + const result = await layoutRepository.findLayoutByName('Unique Layout'); + + deepStrictEqual(result, mockLayout); + ok(mockLayoutModel.findOne.calledOnceWith({ + where: { name: 'Unique Layout' }, + include: layoutRepository._layoutInfoToInclude, + })); + }); + + test('should create a new layout', async () => { + const newLayout = { name: 'New Layout' }; + const createdLayout = { id: '2', ...newLayout }; + mockLayoutModel.create.resolves(createdLayout); + + const result = await layoutRepository.createLayout(newLayout); + deepStrictEqual(result, createdLayout); + ok(mockLayoutModel.create.calledOnceWith(newLayout, {})); + }); + + test('should update a layout', async () => { + const layoutId = '1'; + const updateData = { name: 'Updated Layout' }; + const [updateCount] = [1]; + mockLayoutModel.update.resolves([updateCount]); + + const result = await layoutRepository.updateLayout(layoutId, updateData); + strictEqual(result, updateCount); + ok(mockLayoutModel.update.calledOnceWith(updateData, { where: { id: layoutId } })); + }); + + test('should delete a layout', async () => { + const layoutId = '1'; + const deleteCount = 1; + mockLayoutModel.destroy.resolves(deleteCount); + + const result = await layoutRepository.deleteLayout(layoutId); + strictEqual(result, deleteCount); + ok(mockLayoutModel.destroy.calledOnceWith({ where: { id: layoutId } })); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/OptionRepository.test.js b/QualityControl/test/lib/database/repositories/OptionRepository.test.js new file mode 100644 index 000000000..ded52777e --- /dev/null +++ b/QualityControl/test/lib/database/repositories/OptionRepository.test.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { OptionRepository } from '../../../../lib/database/repositories/OptionRepository.js'; + +/** + * Test suite for OptionRepository + */ +export const optionRepositoryTestSuite = () => { + suite('OptionRepository', () => { + let mockOptionModel = null; + let optionRepository = null; + + beforeEach(() => { + mockOptionModel = { + findOne: sinon.stub(), + }; + optionRepository = new OptionRepository(mockOptionModel); + }); + + test('should create instance with option model', () => { + ok(optionRepository instanceof OptionRepository); + }); + + test('should find option by name', async () => { + const optionName = 'testOption'; + const mockOption = { id: 1, name: optionName, type: 'testType' }; + mockOptionModel.findOne.resolves(mockOption); + const result = await optionRepository.findOptionByName(optionName); + strictEqual(result, mockOption); + }); + + test('should return null if option not found', async () => { + const optionName = 'nonExistentOption'; + mockOptionModel.findOne.resolves(null); + const result = await optionRepository.findOptionByName(optionName); + strictEqual(result, null); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/TabRepository.test.js b/QualityControl/test/lib/database/repositories/TabRepository.test.js new file mode 100644 index 000000000..fe058c1df --- /dev/null +++ b/QualityControl/test/lib/database/repositories/TabRepository.test.js @@ -0,0 +1,112 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { TabRepository } from '../../../../lib/database/repositories/TabRepository.js'; + +/** + * Test suite for TabRepository + */ +export const tabRepositoryTestSuite = () => { + suite('TabRepository', () => { + let mockTabModel = null; + let tabRepository = null; + + beforeEach(() => { + mockTabModel = { + name: 'Tab', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + bulkCreate: sinon.stub(), + }; + tabRepository = new TabRepository(mockTabModel); + }); + + test('should create instance with tab model', () => { + ok(tabRepository instanceof TabRepository); + strictEqual(tabRepository.model, mockTabModel); + }); + + test('should inherit from BaseRepository', () => { + ok(tabRepository.model); + }); + + test('should handle tab creation', async () => { + const tabData = { layout_id: '1', name: 'Test Tab', order: 1 }; + const createdTab = { id: '1', ...tabData }; + mockTabModel.create.resolves(createdTab); + + const result = await tabRepository.model.create(tabData); + deepStrictEqual(result, createdTab); + ok(mockTabModel.create.calledWith(tabData)); + }); + + test('should handle tab retrieval by layout', async () => { + const mockTabs = [ + { id: '1', layout_id: '1', name: 'Tab 1', order: 1 }, + { id: '2', layout_id: '1', name: 'Tab 2', order: 2 }, + ]; + mockTabModel.findAll.resolves(mockTabs); + + const result = await tabRepository.model.findAll({ where: { layout_id: '1' } }); + deepStrictEqual(result, mockTabs); + ok(mockTabModel.findAll.calledWith({ where: { layout_id: '1' } })); + }); + + test('should handle bulk tab creation', async () => { + const tabsArray = [ + { layout_id: '1', name: 'Tab 1', order: 1 }, + { layout_id: '1', name: 'Tab 2', order: 2 }, + ]; + const createdTabs = tabsArray.map((tab, i) => ({ id: String(i + 1), ...tab })); + mockTabModel.bulkCreate.resolves(createdTabs); + + const result = await tabRepository.model.bulkCreate(tabsArray); + deepStrictEqual(result, createdTabs); + ok(mockTabModel.bulkCreate.calledWith(tabsArray)); + }); + + test('should handle tab updates', async () => { + const updateData = { name: 'Updated Tab', order: 3 }; + const updateResult = [1]; + mockTabModel.update.resolves(updateResult); + + const result = await tabRepository.model.update(updateData, { where: { id: '1' } }); + deepStrictEqual(result, updateResult); + ok(mockTabModel.update.calledWith(updateData, { where: { id: '1' } })); + }); + + test('should handle tab deletion by layout', async () => { + mockTabModel.destroy.resolves(2); + + const result = await tabRepository.model.destroy({ where: { layout_id: '1' } }); + strictEqual(result, 2); + ok(mockTabModel.destroy.calledWith({ where: { layout_id: '1' } })); + }); + + test('should handle single tab retrieval', async () => { + const mockTab = { id: '1', layout_id: '1', name: 'Test Tab', order: 1 }; + mockTabModel.findByPk.resolves(mockTab); + + const result = await tabRepository.model.findByPk('1'); + deepStrictEqual(result, mockTab); + ok(mockTabModel.findByPk.calledWith('1')); + }); + }); +}; diff --git a/QualityControl/test/lib/database/repositories/UserRepository.test.js b/QualityControl/test/lib/database/repositories/UserRepository.test.js new file mode 100644 index 000000000..9203f6567 --- /dev/null +++ b/QualityControl/test/lib/database/repositories/UserRepository.test.js @@ -0,0 +1,78 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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. + */ + +import { suite, test, beforeEach } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import sinon from 'sinon'; +import { UserRepository } from '../../../../lib/database/repositories/UserRepository.js'; + +/** + * Test suite for UserRepository + */ +export const userRepositoryTestSuite = () => { + suite('UserRepository', () => { + let mockUserModel = null; + let userRepository = null; + + beforeEach(() => { + mockUserModel = { + name: 'User', + findAll: sinon.stub(), + findByPk: sinon.stub(), + create: sinon.stub(), + update: sinon.stub(), + destroy: sinon.stub(), + findOne: sinon.stub(), + }; + userRepository = new UserRepository(mockUserModel); + }); + + test('should create instance with user model', () => { + ok(userRepository instanceof UserRepository); + strictEqual(userRepository.model, mockUserModel); + }); + + test('should find user by username', async () => { + const mockUser = { id: '1', username: 'testuser', name: 'Test User' }; + mockUserModel.findOne.resolves(mockUser); + + const filters = { username: 'testuser' }; + const result = await userRepository.findUser(filters); + + deepStrictEqual(result, mockUser); + ok(mockUserModel.findOne.calledOnceWith({ where: filters })); + }); + + test('should filter user by id', async () => { + const mockUser = { id: '1', username: 'testuser', name: 'Test User' }; + mockUserModel.findOne.resolves(mockUser); + + const filters = { id: '1' }; + const result = await userRepository.findUser(filters); + + deepStrictEqual(result, mockUser); + ok(mockUserModel.findOne.calledOnceWith({ where: filters })); + }); + + test(' should create a new user', async () => { + const newUser = { username: 'newuser', name: 'New User' }; + const createdUser = { id: '2', ...newUser }; + mockUserModel.create.resolves(createdUser); + + const result = await userRepository.createUser(newUser); + + deepStrictEqual(result, createdUser); + ok(mockUserModel.create.calledOnceWith(newUser)); + }); + }); +}; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js index 02d8a69b1..658a3f9b0 100644 --- a/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js +++ b/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js @@ -14,72 +14,62 @@ import { suite, test } from 'node:test'; import { ok } from 'node:assert'; -import sinon from 'sinon'; import { layoutIdMiddleware } from '../../../../lib/middleware/layouts/layoutId.middleware.js'; +import sinon from 'sinon'; import { NotFoundError } from '@aliceo2/web-ui'; -import { LayoutRepository } from '../../../../lib/repositories/LayoutRepository.js'; /** * Test suite for the middlewares involved in the ID check of the layout requests */ export const layoutIdMiddlewareTest = () => { - suite('Layout id middlewares', () => { - test('should return an "Invalid input" error if the layout id is not provided', () => { + suite('Layout id middleware', () => { + const mockLayoutService = { + getLayoutById: sinon.stub(), + }; + const middleware = layoutIdMiddleware(mockLayoutService); + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + test('should add the layout to the request object when id is valid', async () => { const req = { - params: { - id: null, - }, + params: { id: 'valid-id' }, }; - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), + const next = sinon.spy(); + const mockLayout = { id: 'valid-id', name: 'Test Layout' }; + mockLayoutService.getLayoutById.resolves(mockLayout); + + await middleware(req, res, next); + }); + test('should throw invalid input error when id is missing', async () => { + const req = { + params: {}, }; - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(LayoutRepository); - layoutIdMiddleware(dataServiceStub)(req, res, next); - ok(res.status.calledWith(400), 'The status code should be 400'); + const next = sinon.spy(); + await middleware(req, res, next); + ok(res.status.calledWith(400), `Expected status 400 but got ${res.status.firstCall.args[0]}`); ok(res.json.calledWith({ - message: 'The "id" parameter is missing from the request', + message: 'Layout id is required', status: 400, title: 'Invalid Input', - })); + }), 'Expected error message for missing id'); + ok(next.notCalled, 'Expected next not to be called'); }); - - test('should return a "Not found" error if the layout id does not exist', () => { - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().throwsException(new NotFoundError('Layout not found')), - }); + test('should throw invalid input error when layout is not found', async () => { const req = { - params: { - id: 'nonExistingId', - }, + params: { id: 'non-existent-id' }, }; - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub().returns(); - layoutIdMiddleware(dataServiceStub)(req, res, next); - ok(res.status.calledWith(404)); + const next = sinon.spy(); + mockLayoutService.getLayoutById.rejects(new NotFoundError('Layout with id: non-existent-id was not found')); + + await middleware(req, res, next); + ok(res.status.calledWith(404), `Expected status 400 but got ${res.status.firstCall.args[0]}`); ok(res.json.calledWith({ - message: 'Layout not found', + message: 'Layout with id: non-existent-id was not found', status: 404, title: 'Not Found', - })); - }); - - test('should successfully pass the check if the layout id is provided and exists', async () => { - const req = { - params: { - id: 'layoutId', - }, - }; - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves({}), - }); - await layoutIdMiddleware(dataServiceStub)(req, {}, next); - ok(next.called, 'It should call the next middleware'); + }), 'Expected error message for layout not found'); + ok(next.notCalled, 'Expected next not to be called'); }); }); }; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js index c5f22002a..85e0fa35c 100644 --- a/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js +++ b/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js @@ -12,111 +12,108 @@ * or submit itself to any jurisdiction. */ -import { suite, test } from 'node:test'; +import { beforeEach, suite, test } from 'node:test'; import { ok } from 'node:assert'; import sinon from 'sinon'; import { layoutOwnerMiddleware } from '../../../../lib/middleware/layouts/layoutOwner.middleware.js'; -import { LayoutRepository } from '../../../../lib/repositories/LayoutRepository.js'; /** * Test suite for the middleware that checks the owner of the layout */ export const layoutOwnerMiddlewareTest = () => { suite('Layout owner middleware', () => { - test('should return an "UnauthorizedAccessError" if the layout does not belong to the user', async () => { - const req = { - params: { id: 'layoutId' }, - session: { personid: 'notTheOwnerId', name: 'notTheOwnerName' }, - }; - const res = { + let req = null; + let res = null; + let next = null; + let layoutService = null; + let userService = null; + beforeEach(() => { + req = { params: { id: '1' }, session: { personid: '1', username: 'validUser' } }; + res = { status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), + json: sinon.stub().returnsThis(), }; - const next = sinon.stub(); // Do not call fake to catch unexpected execution - - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves({ owner_name: 'ownerName', owner_id: 'ownerId' }), - }); - - await layoutOwnerMiddleware(dataServiceStub)(req, res, next); - - sinon.assert.calledWith(res.status, 403); - sinon.assert.calledWith(res.json, sinon.match({ - message: 'Only the owner of the layout can delete it', + next = sinon.stub(); + layoutService = { + getLayoutById: sinon.stub().resolves({ owner_username: 'validUser' }), + }; + userService = { + getOwnerIdByUsername: sinon.stub().resolves('1'), + }; + }); + test('should throw unauthorized if session is missing', async () => { + req.session = null; + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); + ok(res.json.calledWith({ + message: 'Unable to retrieve session information', status: 403, title: 'Unauthorized Access', })); }); - - test('should return an "NotFound" error if the owner data of the layout is not accesible', async () => { - const req = { - params: { - id: 'layoutId', - }, - session: { - personid: 'ownerId', - name: 'ownerName', - }, - }; - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().returns(), + test('should throw unauthorized if session personid is missing', async () => { + req.session.personid = ''; + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); + ok(res.json.calledWith({ + message: 'Unable to retrieve session information', + status: 403, + title: 'Unauthorized Access', + })); + }); + test('should throw unauthorized if session username is missing', async () => { + req.session.username = ''; + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); + ok(res.json.calledWith({ + message: 'Unable to retrieve session information', + status: 403, + title: 'Unauthorized Access', + })); + }); + test('should throw not found if layout does not exist', async () => { + layoutService.getLayoutById = sinon.stub().resolves({ + owner_username: '', }); - await layoutOwnerMiddleware(dataServiceStub)(req, res, next); - ok(res.status.calledWith(404)); + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); ok(res.json.calledWith({ message: 'Unable to retrieve layout owner information', status: 404, title: 'Not Found', })); }); - test('should return an "NotFound" error if the session information is not accesible', async () => { - const req = { - params: { - id: 'layoutId', - }, - session: { - personid: '', - name: '', - }, - }; - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().returns({ owner_name: 'ownerName', owner_id: 'ownerId' }), - }); - await layoutOwnerMiddleware(dataServiceStub)(req, res, next); - ok(res.status.calledWith(404)); + test('should throw not found if owner_id is not found', async () => { + userService.getOwnerIdByUsername = sinon.stub().resolves(''); + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); ok(res.json.calledWith({ - message: 'Unable to retrieve session information', + message: 'Unable to retrieve layout owner information', status: 404, title: 'Not Found', })); }); - - test('should successfully pass the check if the layout belongs to the user', async () => { - const req = { - params: { - id: 'layoutId', - }, - session: { - personid: 'ownerId', - name: 'ownerName', - }, - }; - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves({ owner_name: 'ownerName', owner_id: 'ownerId' }), + test('should throw unauthorized if session personid does not match owner_id', async () => { + userService.getOwnerIdByUsername = sinon.stub().resolves('2'); + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); + ok(res.json.calledWith({ + message: 'Only the owner of the layout can make changes to this layout', + status: 403, + title: 'Unauthorized Access', + })); + }); + test('should throw unauthorized if session username does not match owner_username', async () => { + layoutService.getLayoutById = sinon.stub().resolves({ + owner_username: 'invalidUser', }); - await layoutOwnerMiddleware(dataServiceStub)(req, {}, next); - ok(next.called, 'The next() callback should be called'); + await layoutOwnerMiddleware(layoutService, userService)(req, res, next); + ok(res.json.called); + ok(res.json.calledWith({ + message: 'Only the owner of the layout can make changes to this layout', + status: 403, + title: 'Unauthorized Access', + })); }); }); }; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutService.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutService.middleware.test.js deleted file mode 100644 index 22ae861e6..000000000 --- a/QualityControl/test/lib/middlewares/layouts/layoutService.middleware.test.js +++ /dev/null @@ -1,70 +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. - */ - -import { suite, test } from 'node:test'; -import { ok } from 'node:assert'; -import sinon from 'sinon'; -import { layoutServiceMiddleware } from '../../../../lib/middleware/layouts/layoutService.middleware.js'; -import { JsonFileService } from '../../../../lib/services/JsonFileService.js'; - -/** - * Test suite for the middlewares that check the layout service is correctly initialized - */ -export const layoutServiceMiddlewareTest = () => { - suite('Layout service middlewares', () => { - test('should return a "Service Unavailable" error if the JSON File Service is not provided', () => { - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub().returns(); - layoutServiceMiddleware(null)({}, res, next); - ok(res.status.calledWith(503), 'The status code should be 503'); - ok(res.json.calledWith({ - message: 'JSON File service is not available', - status: 503, - title: 'Service Unavailable', - })); - }); - - test( - 'should return a "Service Unavailable" error if the JSON File Service is not an instance of JSONFileService', - () => { - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub().returns(); - const dataService = 'notAJsonFileService'; - layoutServiceMiddleware(dataService)({}, res, next); - ok(res.status.calledWith(503), 'The status code should be 503'); - ok(res.json.calledWith({ - message: 'JSON File service is not available', - status: 503, - title: 'Service Unavailable', - })); - }, - ); - - test( - 'should successfully pass the middleware if the JSON File Service is provided' - , () => { - const next = sinon.stub().returns(); - const dataServiceStub = sinon.createStubInstance(JsonFileService); - layoutServiceMiddleware(dataServiceStub)({}, {}, next); - ok(next.calledOnce, 'The next middleware should be called'); - }, - ); - }); -}; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutValidate.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutValidate.middleware.test.js new file mode 100644 index 000000000..090042a5b --- /dev/null +++ b/QualityControl/test/lib/middlewares/layouts/layoutValidate.middleware.test.js @@ -0,0 +1,235 @@ +import { ok, strictEqual } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; +import sinon from 'sinon'; +import { + validateCreateLayoutMiddleware, + validateUpdateLayoutMiddleware, + validatePatchLayoutMiddleware, +} from '../../../../lib/middleware/layouts/layoutValidate.middleware.js'; + +export const layoutValidateMiddlewareTestSuite = async () => { + let req = null; + let res = null; + let next = null; + let mockValidLayout = null; + beforeEach(() => { + req = { body: {} }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + }; + next = sinon.stub(); + mockValidLayout = { + id: 'validId', + name: 'validName', + owner_id: 1, + owner_name: 'validOwner', + tabs: [ + { + id: 'tab1', + name: 'Tab 1', + }, + ], + }; + }); + suite('LayoutDTO validations', () => { + //id name tabs and owner_id, owner_name are required + test('should throw if id is not provided', async () => { + delete mockValidLayout.id; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "id" is required', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if name is not provided', async () => { + delete mockValidLayout.name; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "name" is required', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if tabs is not provided', async () => { + delete mockValidLayout.tabs; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "tabs" is required', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if tabs is empty', async () => { + mockValidLayout.tabs = []; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "tabs" must contain at least 1 items', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if owner_id is not provided', async () => { + delete mockValidLayout.owner_id; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "owner_id" is required', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if owner_name is not provided', async () => { + delete mockValidLayout.owner_name; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "owner_name" is required', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if owner_name is not a string', async () => { + mockValidLayout.owner_name = 12345; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "owner_name" must be a string', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if description is too long', async () => { + mockValidLayout.description = 'a'.repeat(101); + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "description" length must be less than or equal to 100 characters long', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if collaborators is not an array', async () => { + mockValidLayout.collaborators = 'notAnArray'; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "collaborators" must be an array', + status: 400, + title: 'Invalid Input', + })); + }); + //displayTimestamp is not a boolean + test('should throw if displayTimestamp is not a boolean', async () => { + mockValidLayout.displayTimestamp = 'notABoolean'; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "displayTimestamp" must be a boolean', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if autoTabChange is not a number', async () => { + mockValidLayout.autoTabChange = 'notANumber'; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "autoTabChange" must be a number', + status: 400, + title: 'Invalid Input', + })); + }); + //invalid property in layout object + test('should throw if layout contains an invalid property', async () => { + mockValidLayout.invalidProperty = 'invalid'; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "invalidProperty" is not allowed', + status: 400, + title: 'Invalid Input', + })); + }); + //invalid property in tab object + test('should throw if a tab contains an invalid property', async () => { + mockValidLayout.tabs[0].invalidProperty = 'invalid'; + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for create: "tabs[0].invalidProperty" is not allowed', + status: 400, + title: 'Invalid Input', + })); + }); + //valid layout + test('should pass if layout is valid', async () => { + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(next.called, true); + }); + }); + suite('LayoutPatchDTO validations', () => { + test('should throw if layout is not an object', async () => { + req.body = 'notAnObject'; + await validatePatchLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for patch: "value" must be of type object', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if isOfficial is not a boolean', async () => { + req.body = { isOfficial: 'notABoolean' }; + await validatePatchLayoutMiddleware(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid body for patch: "isOfficial" must be a boolean', + status: 400, + title: 'Invalid Input', + })); + }); + }); + + suite('validate layout create middleware test suite', () => { + test('should call next if layout is valid', async () => { + req.body = { ...mockValidLayout }; + await validateCreateLayoutMiddleware(req, res, next); + strictEqual(next.called, true, 'next should be called'); + }); + }); + suite('validate layout update middleware test suite', () => { + test('should call next if layout is valid', async () => { + req.body = { ...mockValidLayout }; + await validateUpdateLayoutMiddleware(req, res, next); + strictEqual(next.called, true, 'next should be called'); + }); + }); + + suite('validate layout patch middleware test suite', () => { + test('should call next if layout is valid', async () => { + req.body = { isOfficial: true }; + await validatePatchLayoutMiddleware(req, res, next); + strictEqual(next.called, true, 'next should be called'); + }); + }); +}; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutsGet.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutsGet.middleware.test.js new file mode 100644 index 000000000..cf57fe0e0 --- /dev/null +++ b/QualityControl/test/lib/middlewares/layouts/layoutsGet.middleware.test.js @@ -0,0 +1,76 @@ +import { strictEqual } from 'node:assert'; +import { suite, test } from 'node:test'; +import sinon from 'sinon'; +import { getLayoutsMiddleware } from '../../../../lib/middleware/layouts/layoutsGet.middleware.js'; + +export const getLayoutsMiddlewareTestSuite = async () => { + suite('getLayoutsMiddleware Test Suite', () => { + const req = {}; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + }; + const next = sinon.stub(); + const invalidInputErrorJson = (message) => ({ + message: `Invalid query parameters: ${message}`, + status: 400, + title: 'Invalid Input', + }); + test('should throw if token is not provided', async () => { + req.query = { invalid: 'query' }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"token" is required')), true); + }); + //if ownner_id is not a number + test('should throw if owner_id is not a number', async () => { + req.query = { token: 'validToken', owner_id: 'notANumber' }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"owner_id" must be a number')), true); + }); + //if name is not a string + test('should throw if name is not a string', async () => { + req.query = { token: 'validToken', name: 12345 }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"name" must be a string')), true); + }); + //if filter is not an object + test('should throw if filter is not an object', async () => { + req.query = { token: 'validToken', filter: 'notAnObject' }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"filter" must be of type object')), true); + }); + test('should throw if filter.objectPath does not follow the pattern', async () => { + req.query = { token: 'validToken', filter: { objectPath: 'invalid path!' } }; + await getLayoutsMiddleware(req, res, next); + const message = + '"Object path" with value "invalid path!" fails to match the required pattern: /^[A-Za-z0-9_\\-/]+$/'; + strictEqual(res.json.calledWith(invalidInputErrorJson(message)), true); + }); + test('should throw if fields is not a string', async () => { + req.query = { token: 'validToken', fields: 12345 }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"fields" must be a string')), true); + }); + test('should throw if fields contains an invalid field', async () => { + req.query = { token: 'validToken', fields: 'id,name,invalidField' }; + await getLayoutsMiddleware(req, res, next); + strictEqual(res.json.calledWith(invalidInputErrorJson('"fields" contains invalid field: invalidField')), true); + }); + test('should pass if only token is provided', async () => { + req.query = { token: 'validToken' }; + await getLayoutsMiddleware(req, res, next); + strictEqual(next.called, true); + }); + test('should pass if all parameters are valid', async () => { + req.query = { + token: 'validToken', + owner_id: 123, + name: 'validName', + filter: { objectPath: 'valid/path' }, + fields: 'id,name,owner_id', + }; + await getLayoutsMiddleware(req, res, next); + strictEqual(next.called, true); + }); + }); +}; diff --git a/QualityControl/test/lib/middlewares/validateUser.middleware.test.js b/QualityControl/test/lib/middlewares/validateUser.middleware.test.js new file mode 100644 index 000000000..abf4713fa --- /dev/null +++ b/QualityControl/test/lib/middlewares/validateUser.middleware.test.js @@ -0,0 +1,75 @@ +import { ok, strictEqual } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; +import sinon from 'sinon'; +import { validateUserSession } from '../../../lib/middleware/validateUser.middleware.js'; + +export const validateUserMiddlewareTestSuite = async () => { + let req = null; + let res = null; + let next = null; + beforeEach(() => { + req = { session: {} }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + }; + next = sinon.stub(); + }); + suite('validateUserSession Middleware Test Suite', () => { + test('should throw if session is not an object', async () => { + req.session = 'notAnObject'; + await validateUserSession(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid user: "value" must be of type object', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if personid is not provided', async () => { + req.session = { username: 'validUsername', name: 'validName' }; + await validateUserSession(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid user: id of the user is mandatory', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if personid is not a number', async () => { + req.session = { personid: 'notANumber', username: 'validUsername', name: 'validName' }; + await validateUserSession(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid user: id of the user must be a number', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if username is not provided', async () => { + req.session = { personid: 1, name: 'validName' }; + await validateUserSession(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid user: username of the user is mandatory', + status: 400, + title: 'Invalid Input', + })); + }); + test('should throw if name is not provided', async () => { + req.session = { personid: 1, username: 'validUsername' }; + await validateUserSession(req, res, next); + strictEqual(res.json.called, true); + ok(res.json.calledWith({ + message: 'Invalid user: name of the user is mandatory', + status: 400, + title: 'Invalid Input', + })); + }); + test('should pass if all required fields are provided and valid', async () => { + req.session = { personid: 1, username: 'validUsername', name: 'validName' }; + await validateUserSession(req, res, next); + strictEqual(next.called, true); + }); + }); +}; diff --git a/QualityControl/test/lib/repositories/ChartRepository.test.js b/QualityControl/test/lib/repositories/ChartRepository.test.js deleted file mode 100644 index 588b13a06..000000000 --- a/QualityControl/test/lib/repositories/ChartRepository.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { suite, test, before, beforeEach } from 'node:test'; -import { strictEqual, throws } from 'node:assert'; -import { ChartRepository } from '../../../lib/repositories/ChartRepository.js'; - -import { initTest } from '../../setup/testRepositorySetup.js'; - -/** - * @typedef {import('../../../lib/services/JsonFileService.js').JsonFileService} JsonFileService - */ - -export const chartRepositoryTest = async () => { - suite('Chart repository tests', () => { - let jsonFileServiceMock = null; - let chartRepository = null; - - before(async () => { - const { mockedJsonFileService } = await initTest(); - jsonFileServiceMock = mockedJsonFileService; - chartRepository = new ChartRepository(jsonFileServiceMock); - }); - - beforeEach(() => { - jsonFileServiceMock.writeToFile.resetHistory(); - }); - - suite('getObjectById', async () => { - test('should return the correct object and layout name when the id exists', () => { - const result = chartRepository.getObjectById('671b8c25d5b49dbf80e81926'); - strictEqual(result.object.id, '671b8c25d5b49dbf80e81926'); - strictEqual(result.object.name, 'qc/MCH/QO/Aggregator/MCHQuality'); - strictEqual(result.layoutName, 'test'); - strictEqual(result.tabName, 'main'); - }); - - test('should throw an error when the id does not exist', () => { - throws(() => chartRepository.getObjectById('3'), new Error('Object with 3 could not be found')); - }); - - test('should throw an error when the id is missing', () => { - throws(() => chartRepository.getObjectById(), new Error('Missing mandatory parameter: id')); - }); - }); - }); -}; diff --git a/QualityControl/test/lib/repositories/LayoutRepository.test.js b/QualityControl/test/lib/repositories/LayoutRepository.test.js deleted file mode 100644 index 84309ac09..000000000 --- a/QualityControl/test/lib/repositories/LayoutRepository.test.js +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { suite, test, before, beforeEach } from 'node:test'; -import { LayoutRepository } from '../../../lib/repositories/LayoutRepository.js'; -import { deepEqual, deepStrictEqual, ok, rejects, strictEqual, throws } from 'node:assert'; -import { NotFoundError } from '@aliceo2/web-ui'; -import sinon from 'sinon'; -import { initTest } from '../../setup/testRepositorySetup.js'; - -/** - * @typedef {import('../../../lib/services/JsonFileService.js').JsonFileService} JsonFileService - */ - -export const layoutRepositoryTest = async () => { - suite('Layout repository tests', () => { - let jsonFileServiceMock = null; - let layoutRepository = null; - - before(async () => { - const { mockedJsonFileService } = await initTest(); - jsonFileServiceMock = mockedJsonFileService; - layoutRepository = new LayoutRepository(jsonFileServiceMock); - }); - - beforeEach(() => { - jsonFileServiceMock.writeToFile.resetHistory(); - }); - - beforeEach(() => { - jsonFileServiceMock.writeToFile.resetHistory(); - }); - - test('should initialize LayoutRepository successfully', () => { - ok(layoutRepository); - }); - - suite('list layouts', () => { - test('should list all layouts without filter', async () => { - const result = layoutRepository.listLayouts(); - strictEqual(result.length, 3, 'Length of list of layouts is not correct'); - deepStrictEqual(result, jsonFileServiceMock.data.layouts, 'List of layouts filtered do not match the filters'); - }); - - test('should filter layouts by owner_id', () => { - const ownerId = 0; - const result = layoutRepository.listLayouts({ filter: { owner_id: ownerId } }); - - strictEqual(result.length, 2, 'number of layouts is incorrect'); - result.forEach((layout) => { - strictEqual(layout.owner_id, ownerId, `Layout owner_id should be ${ownerId}`); - }); - - deepStrictEqual( - result[0], - jsonFileServiceMock.data.layouts[0], - 'First layout should match the expected layout', - ); - }); - - test('should return only layout with specified filter.objectPath', () => { - const objectPath = 'qc/MCH/QO/DataDecodingCheck'; - const result = layoutRepository.listLayouts({ filter: { - objectPath, - } }); - ok(result.length === 1, "listLayouts's filter.objectPath should only return one layout"); - }); - - test('should return layouts with specified partial filter.objectPath', () => { - const objectPath = '/1'; - const result = layoutRepository.listLayouts({ filter: { - objectPath, - } }); - ok(result.length === 2, "listLayouts's filter.objectPath should only return 2 layouts"); - }); - - test('should return all layouts when filter.objectPath is empty string', () => { - const objectPath = ''; - const result = layoutRepository.listLayouts({ filter: { - objectPath, - } }); - ok(result.length === 3, "listLayouts's filter.objectPath should only return 3 (all) layouts"); - }); - - test('should return all layouts when filter is an empty object', () => { - const result = layoutRepository.listLayouts({ filter: {} }); - ok(result.length === 3, "listLayouts's empty filter object should only return 3 (all) layouts"); - }); - - test('should return only specified fields when fields array is provided', () => { - const fields = ['id', 'name']; - const result = layoutRepository.listLayouts({ fields }); - - result.forEach((layout) => { - const actualKeys = Object.keys(layout); - deepStrictEqual(actualKeys.sort(), fields); - }); - - strictEqual(result.length, jsonFileServiceMock.data.layouts.length); - }); - }); - - suite('read layouts', () => { - test('readLayoutById should throw NotFoundError when layout is not found', () => { - throws(() => { - layoutRepository.readLayoutById('999'); - }, NotFoundError); - }); - test('should return a layout if it is found', () => { - const layoutId = '671b95883d23cd0d67bdc787'; - const layout = layoutRepository.readLayoutById(layoutId); - const expectedLayout = jsonFileServiceMock.data.layouts.find((l) => l.id === layoutId); - strictEqual(layout.id, layoutId); - deepStrictEqual(layout, expectedLayout); - strictEqual(layout.name, expectedLayout.name); - strictEqual(layout.owner_id, expectedLayout.owner_id); - }); - }); - - suite('create layouts', () => { - test('should throw an error if id is not provided', () => { - const newLayout = { name: 'New Layout', owner_id: 'user3' }; - return rejects( - layoutRepository.createLayout(newLayout), - (err) => err instanceof Error && err.message === 'layout id is mandatory', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should throw an error if name is not provided', () => { - const newLayout = { id: '3', owner_id: 'user3' }; - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - return rejects( - layoutRepository.createLayout(newLayout), - (err) => err instanceof Error && err.message === 'layout name is mandatory', - ); - }); - - test('should throw an error if id already exists', async () => { - const newLayout = { id: '671b8c22402408122e2f20dd', name: 'New Layout', owner_id: 'user3' }; - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - await rejects( - layoutRepository.createLayout(newLayout), - (err) => err instanceof Error - && err.message === 'layout with this id (671b8c22402408122e2f20dd) already exists', - ); - }); - - test('should create a new layout successfully', async () => { - const newLayout = { id: '3', name: 'New Layout', owner_id: 'user3' }; - await layoutRepository.createLayout(newLayout); - - strictEqual(jsonFileServiceMock.data.layouts.length, 4); - deepEqual(jsonFileServiceMock.data.layouts[3], newLayout); - sinon.assert.calledOnce(jsonFileServiceMock.writeToFile); - }); - }); - - suite('update layouts', () => { - test('should update a single layout by its id', async () => { - const newLayout = { - id: '671b8c22402408122e2f20dd', - name: 'Test Layout Updated', - owner_id: 'user1', - tabs: [{ name: 'Tab1', objects: [{ id: '1', name: 'Object1' }] }], - }; - const idOfLayoutUpdated = await layoutRepository.updateLayout('671b8c22402408122e2f20dd', newLayout); - strictEqual(idOfLayoutUpdated, '671b8c22402408122e2f20dd'); - - const updatedLayout = jsonFileServiceMock.data.layouts.find((l) => l.id === newLayout.id); - strictEqual(updatedLayout.id, newLayout.id); - strictEqual(updatedLayout.name, newLayout.name); - strictEqual(updatedLayout.owner_id, newLayout.owner_id); - deepStrictEqual(updatedLayout.tabs, newLayout.tabs); - - sinon.assert.calledOnce(jsonFileServiceMock.writeToFile); - }); - }); - - suite('delete layouts', () => { - test('should throw an error if the layoutId does not exist', async () => { - const nonExistentLayoutId = 'nonExistentId'; - return rejects( - layoutRepository.deleteLayout(nonExistentLayoutId), - (err) => err instanceof Error && err.message === `layout (${nonExistentLayoutId}) not found`, - ).then(() => { - strictEqual(jsonFileServiceMock.data.layouts.length, 4); - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should delete an existing layout', async () => { - const layoutIdToDelete = '3'; - const deletedLayoutId = await layoutRepository.deleteLayout(layoutIdToDelete); - - strictEqual(deletedLayoutId, layoutIdToDelete); - strictEqual(jsonFileServiceMock.data.layouts.length, 3); - strictEqual(deletedLayoutId, layoutIdToDelete); - sinon.assert.calledOnce(jsonFileServiceMock.writeToFile); - }); - }); - }); -}; diff --git a/QualityControl/test/lib/repositories/UserRepository.test.js b/QualityControl/test/lib/repositories/UserRepository.test.js deleted file mode 100644 index de28fafe7..000000000 --- a/QualityControl/test/lib/repositories/UserRepository.test.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE O2. This software is - * distributed under the terms of the GNU General Public License v3 (GPL - * Version 3), copied verbatim in the file "COPYING". - * - * See http://alice-o2.web.cern.ch/license for full licensing information. - * - * 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. - */ - -import { suite, test, before, beforeEach } from 'node:test'; -import assert, { ok, rejects, strictEqual } from 'node:assert'; -import sinon from 'sinon'; -import { UserRepository } from '../../../lib/repositories/UserRepository.js'; -import { initTest } from '../../setup/testRepositorySetup.js'; - -/** - * @typedef {import('../../../lib/services/JsonFileService.js').JsonFileService} JsonFileService - */ - -export const userRepositoryTest = async () => { - suite('User repository tests', () => { - let jsonFileServiceMock = null; - let userRepository = null; - - before(async () => { - const { mockedJsonFileService } = await initTest(); - jsonFileServiceMock = mockedJsonFileService; - userRepository = new UserRepository(jsonFileServiceMock); - }); - - beforeEach(() => { - jsonFileServiceMock.writeToFile.resetHistory(); - }); - - test('should initialize userRepository successfully', () => { - ok(userRepository); - }); - - test('should not create a user if the user already exists', async () => { - const existingUser = { - id: 0, - name: 'Anonymous', - username: 'anonymous', - }; - - await userRepository.createUser(existingUser); - - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - - test('should throw an error if user object is not provided', () => rejects( - userRepository.createUser(undefined), - (err) => err instanceof Error && err.message === 'User Object is mandatory', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - })); - - test('should throw an error if username is not provided', () => { - const invalidUser = { id: 4, name: 'test' }; - return rejects( - userRepository.createUser(invalidUser), - (err) => err instanceof Error && err.message === 'Field username is mandatory', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should throw an error if name is not provided', () => { - const userWithoutName = { id: 1, username: 'test' }; - return rejects( - userRepository.createUser(userWithoutName), - (err) => err instanceof Error && err.message === 'Field name is mandatory', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should throw an error if id is not provided', () => { - const userWithoutId = { name: 'Test user', username: 'user1' }; - return rejects( - userRepository.createUser(userWithoutId), - (err) => err instanceof Error && err.message === 'Field id is mandatory', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should throw an error if id is not a number', () => { - const userWithInvalidId = { id: 'abc', name: 'Test user', username: 'user1' }; - return rejects( - userRepository.createUser(userWithInvalidId), - (err) => err instanceof Error && err.message === 'Field id must be a number', - ).then(() => { - sinon.assert.notCalled(jsonFileServiceMock.writeToFile); - }); - }); - - test('should create a new user if the user does not exist', async () => { - const newUser = { id: 2, name: 'Test User 2', username: 'user2' }; - await userRepository.createUser(newUser); - - const addedUser = jsonFileServiceMock.data.users.find((user) => user.id === newUser.id); - assert(addedUser, 'New user should be added'); - strictEqual(addedUser.name, newUser.name, 'User name should match'); - strictEqual(addedUser.username, newUser.username, 'Username should match'); - sinon.assert.calledOnce(jsonFileServiceMock.writeToFile); - }); - }); -}; diff --git a/QualityControl/test/lib/services/BookkeepingService.test.js b/QualityControl/test/lib/services/BookkeepingService.test.js index 972a18724..dad896db3 100644 --- a/QualityControl/test/lib/services/BookkeepingService.test.js +++ b/QualityControl/test/lib/services/BookkeepingService.test.js @@ -17,8 +17,8 @@ import { suite, test, before, beforeEach, afterEach } from 'node:test'; import nock from 'nock'; import { stub, restore } from 'sinon'; -import { BookkeepingService } from '../../../lib/services/BookkeepingService.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; +import { BookkeepingService } from '../../../lib/services/external/BookkeepingService.js'; /** * Tests for the Bookkeeping service diff --git a/QualityControl/test/lib/services/layout/LayoutService.test.js b/QualityControl/test/lib/services/layout/LayoutService.test.js new file mode 100644 index 000000000..69d631824 --- /dev/null +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -0,0 +1,225 @@ +/** + * @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. + */ + +import { strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { LayoutService } from '../../../../lib/services/layout/LayoutService.js'; + +export const layoutServiceTestSuite = async () => { + suite('LayoutService Test Suite', () => { + let mockLayoutRepository = null; + let mockUserRepository = null; + let mockTabRepository = null; + let mockGridTabCellRepository = null; + let mockChartRepository = null; + let mockChartOptionsRepository = null; + let mockOptionRepository = null; + let mockTransaction = null; + let layoutService = null; + + beforeEach(() => { + mockTransaction = { + commit: () => Promise.resolve(), + rollback: () => Promise.resolve(), + }; + + mockLayoutRepository = { + model: { + sequelize: { + transaction: () => Promise.resolve(mockTransaction), + }, + }, + findLayoutsByFilters: () => Promise.resolve([]), + findLayoutById: () => Promise.resolve({ id: '1', name: 'Test Layout' }), + updateLayout: () => Promise.resolve(1), + deleteLayout: () => Promise.resolve(1), + createLayout: () => Promise.resolve({ id: '1', name: 'New Layout' }), + }; + + mockUserRepository = { + findUser: () => Promise.resolve({ id: 1, username: 'testuser' }), + }; + + mockTabRepository = { + findTabsByLayoutId: () => Promise.resolve([]), + deleteTab: () => Promise.resolve(1), + updateTab: () => Promise.resolve(1), + createTab: () => Promise.resolve({ id: 1 }), + }; + + mockGridTabCellRepository = { + findObjectByChartId: () => Promise.resolve({ id: '1', name: 'Test Object' }), + findByTabId: () => Promise.resolve([]), + updateGridTabCell: () => Promise.resolve(1), + createGridTabCell: () => Promise.resolve({ id: 1 }), + }; + + mockChartRepository = { + deleteChart: () => Promise.resolve(1), + updateChart: () => Promise.resolve(1), + createChart: () => Promise.resolve({ id: 1 }), + }; + + mockChartOptionsRepository = { + findChartOptionsByChartId: () => Promise.resolve([]), + deleteChartOption: () => Promise.resolve(), + createChartOption: () => Promise.resolve(), + }; + + mockOptionRepository = { + findOptionByName: () => Promise.resolve({ id: 1, name: 'test-option' }), + }; + + layoutService = new LayoutService( + mockLayoutRepository, + mockUserRepository, + mockTabRepository, + mockGridTabCellRepository, + mockChartRepository, + mockChartOptionsRepository, + mockOptionRepository, + ); + }); + + suite('Constructor', () => { + test('should successfully initialize LayoutService', () => { + strictEqual(typeof layoutService._layoutRepository, 'object'); + strictEqual(typeof layoutService._userService, 'object'); + strictEqual(typeof layoutService._tabSynchronizer, 'object'); + strictEqual(typeof layoutService._logger, 'object'); + }); + }); + + suite('getLayoutsByFilters()', () => { + test('should return layouts from repository', async () => { + const mockLayouts = [{ id: '1', name: 'Layout 1' }]; + mockLayoutRepository.findLayoutsByFilters = () => Promise.resolve(mockLayouts); + + const result = await layoutService.getLayoutsByFilters({}); + strictEqual(result, mockLayouts); + }); + + test('should handle owner_id filter by converting to username', async () => { + const filters = { owner_id: 1 }; + let capturedFilters = null; + + mockLayoutRepository.findLayoutsByFilters = (filters) => { + capturedFilters = filters; + return Promise.resolve([]); + }; + + await layoutService.getLayoutsByFilters(filters); + strictEqual(capturedFilters.owner_username, 'testuser'); + strictEqual(capturedFilters.owner_id, undefined); + }); + }); + + suite('getLayoutById()', () => { + test('should return layout when found', async () => { + const mockLayout = { id: '1', name: 'Test Layout' }; + mockLayoutRepository.findLayoutById = () => Promise.resolve(mockLayout); + + const result = await layoutService.getLayoutById('1'); + strictEqual(result, mockLayout); + }); + + test('should throw NotFoundError when layout not found', async () => { + mockLayoutRepository.findLayoutById = () => Promise.resolve(null); + + await rejects( + async () => await layoutService.getLayoutById('999'), + /Layout with id: 999 was not found/, + ); + }); + }); + + suite('getObjectById()', () => { + test('should return object when found', async () => { + const mockObject = { + toJSON: () => ({ id: '1', name: 'Test Object' }), + }; + mockGridTabCellRepository.findObjectByChartId = () => Promise.resolve(mockObject); + + const result = await layoutService.getObjectById('1'); + strictEqual(result.id, '1'); + strictEqual(result.name, 'Test Object'); + }); + + test('should throw InvalidInputError when id is not provided', async () => { + await rejects( + async () => await layoutService.getObjectById(null), + /Id must be provided/, + ); + }); + + test('should throw NotFoundError when object not found', async () => { + mockGridTabCellRepository.findObjectByChartId = () => Promise.resolve(null); + + await rejects( + async () => await layoutService.getObjectById('999'), + /Object with id 999 not found/, + ); + }); + }); + + suite('postLayout()', () => { + test('should create new layout successfully', async () => { + const layoutData = { name: 'New Layout', owner_name: 'testuser' }; + const mockCreatedLayout = { id: '1', name: 'New Layout' }; + + mockLayoutRepository.createLayout = () => Promise.resolve(mockCreatedLayout); + + const result = await layoutService.postLayout(layoutData); + strictEqual(result, mockCreatedLayout); + }); + + test('should rollback transaction on error', async () => { + const layoutData = { name: 'New Layout' }; + const error = new Error('Database error'); + let rollbackCalled = false; + + mockLayoutRepository.createLayout = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + return Promise.resolve(); + }; + + await rejects( + async () => await layoutService.postLayout(layoutData), + error, + ); + strictEqual(rollbackCalled, true); + }); + }); + + suite('removeLayout()', () => { + test('should delete layout successfully', async () => { + mockLayoutRepository.deleteLayout = () => Promise.resolve(1); + + await layoutService.removeLayout('1'); + // Should not throw + }); + + test('should throw NotFoundError when layout not found for deletion', async () => { + mockLayoutRepository.deleteLayout = () => Promise.resolve(0); + + await rejects( + async () => await layoutService.removeLayout('999'), + /Layout with id 999 not found/, + ); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/UserService.test.js b/QualityControl/test/lib/services/layout/UserService.test.js new file mode 100644 index 000000000..5977e624a --- /dev/null +++ b/QualityControl/test/lib/services/layout/UserService.test.js @@ -0,0 +1,132 @@ +/** + * @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. + */ + +import { strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; +import { UniqueConstraintError } from 'sequelize'; + +import { UserService } from '../../../../lib/services/layout/UserService.js'; + +export const userServiceTestSuite = async () => { + suite('UserService Test Suite', () => { + let mockUserRepository = null; + let userService = null; + + beforeEach(() => { + mockUserRepository = { + findUser: () => Promise.resolve(null), + createUser: () => Promise.resolve({ id: 1, username: 'testuser', name: 'Test User' }), + }; + + userService = new UserService(mockUserRepository); + }); + + suite('Constructor', () => { + test('should successfully initialize UserService', () => { + const userRepo = { test: 'userRepo' }; + const service = new UserService(userRepo); + + strictEqual(service._userRepository, userRepo); + }); + }); + + suite('createNewUser()', () => { + test('should create new user when user does not exist', async () => { + const userData = { username: 'newuser', name: 'New User', personid: 123 }; + const createdUsers = []; + + mockUserRepository.findUser = () => Promise.resolve(null); + mockUserRepository.createUser = (user) => { + createdUsers.push(user); + return Promise.resolve(user); + }; + + await userService.createNewUser(userData); + + strictEqual(createdUsers.length, 1); + strictEqual(createdUsers[0].id, 123); + strictEqual(createdUsers[0].username, 'newuser'); + strictEqual(createdUsers[0].name, 'New User'); + }); + + test('should not create user when user already exists', async () => { + const userData = { username: 'existinguser', name: 'Existing User', personid: 123 }; + let createUserCalled = false; + + mockUserRepository.findUser = () => Promise.resolve({ id: 123, username: 'existinguser' }); + mockUserRepository.createUser = () => { + createUserCalled = true; + return Promise.resolve(); + }; + + await userService.createNewUser(userData); + + strictEqual(createUserCalled, false, 'Should not create user when already exists'); + }); + + test('should throw InvalidInputError on unique constraint violation', async () => { + const userData = { username: 'testuser', name: 'Test User', personid: 123 }; + const uniqueError = new UniqueConstraintError({ + errors: [{ path: 'username' }], + }); + + mockUserRepository.findUser = () => Promise.resolve(null); + mockUserRepository.createUser = () => Promise.reject(uniqueError); + + await rejects( + async () => await userService.createNewUser(userData), + /A user with the same username already exists/, + ); + }); + }); + + suite('getUsernameById()', () => { + test('should return username when user is found', async () => { + const mockUser = { id: 123, username: 'testuser', name: 'Test User' }; + mockUserRepository.findUser = () => Promise.resolve(mockUser); + + const result = await userService.getUsernameById(123); + strictEqual(result, 'testuser'); + }); + + test('should throw NotFoundError when user is not found', async () => { + mockUserRepository.findUser = () => Promise.resolve(null); + + await rejects( + async () => await userService.getUsernameById(999), + /User with ID 999 not found/, + ); + }); + }); + + suite('getOwnerIdByUsername()', () => { + test('should return user id when user is found', async () => { + const mockUser = { id: 123, username: 'testuser', name: 'Test User' }; + mockUserRepository.findUser = () => Promise.resolve(mockUser); + + const result = await userService.getOwnerIdByUsername('testuser'); + strictEqual(result, 123); + }); + + test('should throw NotFoundError when user is not found', async () => { + mockUserRepository.findUser = () => Promise.resolve(null); + + await rejects( + async () => await userService.getOwnerIdByUsername('nonexistent'), + /User with username nonexistent not found/, + ); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js new file mode 100644 index 000000000..f554932d0 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js @@ -0,0 +1,233 @@ +/** + * @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. + */ + +import { deepStrictEqual, strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { ChartOptionsSynchronizer } from '../../../../../lib/services/layout/helpers/chartOptionsSynchronizer.js'; + +export const chartOptionsSynchronizerTestSuite = async () => { + suite('ChartOptionsSynchronizer Test Suite', () => { + let mockChartOptionRepository = null; + let mockOptionsRepository = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + // Mock repositories + mockChartOptionRepository = { + findChartOptionsByChartId: () => Promise.resolve([]), + deleteChartOption: () => Promise.resolve(), + createChartOption: () => Promise.resolve(), + }; + + mockOptionsRepository = { + findOptionByName: () => Promise.resolve({ id: 1, name: 'test-option' }), + }; + + mockTransaction = { id: 'mock-transaction', rollback: () => {} }; + synchronizer = new ChartOptionsSynchronizer(mockChartOptionRepository, mockOptionsRepository); + }); + + suite('Constructor', () => { + test('should successfully initialize ChartOptionsSynchronizer', () => { + const chartRepo = { test: 'chartRepo' }; + const optionsRepo = { test: 'optionsRepo' }; + const sync = new ChartOptionsSynchronizer(chartRepo, optionsRepo); + + strictEqual(sync._chartOptionRepository, chartRepo); + strictEqual(sync._optionsRepository, optionsRepo); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should return early when chart has no options', async () => { + const chart = { id: 1 }; + let findCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => { + findCalled = true; + return Promise.resolve([]); + }; + + await synchronizer.sync(chart, mockTransaction); + strictEqual(findCalled, false, 'Should not call repository when no options'); + }); + + test('should return early when chart has empty options array', async () => { + const chart = { id: 1, options: [] }; + let findCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => { + findCalled = true; + return Promise.resolve([]); + }; + + await synchronizer.sync(chart, mockTransaction); + strictEqual(findCalled, false, 'Should not call repository when options array is empty'); + }); + + test('should create new chart options when none exist', async () => { + const chart = { id: 1, options: ['option1', 'option2'] }; + const createdOptions = []; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = (name) => Promise.resolve({ id: name === 'option1' ? 10 : 20, name }); + mockChartOptionRepository.createChartOption = (data) => { + createdOptions.push(data); + return Promise.resolve(data); + }; + + await synchronizer.sync(chart, mockTransaction); + + strictEqual(createdOptions.length, 2); + deepStrictEqual(createdOptions[0], { chart_id: 1, option_id: 10 }); + deepStrictEqual(createdOptions[1], { chart_id: 1, option_id: 20 }); + }); + + test('should delete chart options that are no longer present', async () => { + const chart = { id: 1, options: ['option2'] }; + const deletedOptions = []; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([ + { option_id: 10 }, // This should be deleted + { option_id: 20 }, // This should remain + ]); + mockOptionsRepository.findOptionByName = () => Promise.resolve({ id: 20, name: 'option2' }); + mockChartOptionRepository.deleteChartOption = (data) => { + deletedOptions.push(data); + return Promise.resolve(1); + }; + + await synchronizer.sync(chart, mockTransaction); + + strictEqual(deletedOptions.length, 1); + deepStrictEqual(deletedOptions[0], { chartId: 1, optionId: 10 }); + }); + + test('should handle mixed create and delete operations', async () => { + const chart = { id: 1, options: ['option2', 'option3'] }; + const createdOptions = []; + const deletedOptions = []; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([ + { option_id: 10 }, // Should be deleted (option1 no longer present) + { option_id: 20 }, // Should remain (option2 still present) + ]); + + mockOptionsRepository.findOptionByName = (name) => { + if (name === 'option2') { + return Promise.resolve({ id: 20, name }); + } + if (name === 'option3') { + return Promise.resolve({ id: 30, name }); + } + return Promise.resolve({ id: 999, name }); + }; + + mockChartOptionRepository.deleteChartOption = (data) => { + deletedOptions.push(data); + return Promise.resolve(1); + }; + + mockChartOptionRepository.createChartOption = (data) => { + createdOptions.push(data); + return Promise.resolve(data); + }; + + await synchronizer.sync(chart, mockTransaction); + + // Should delete option with id 10 + strictEqual(deletedOptions.length, 1); + deepStrictEqual(deletedOptions[0], { chartId: 1, optionId: 10 }); + + // Should create option with id 30 (option3 is new) + strictEqual(createdOptions.length, 1); + deepStrictEqual(createdOptions[0], { chart_id: 1, option_id: 30 }); + }); + + test('should not create or delete when options are already synchronized', async () => { + const chart = { id: 1, options: ['option1', 'option2'] }; + let createCalled = false; + let deleteCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([ + { option_id: 10 }, + { option_id: 20 }, + ]); + + mockOptionsRepository.findOptionByName = (name) => { + if (name === 'option1') { + return Promise.resolve({ id: 10, name }); + } + if (name === 'option2') { + return Promise.resolve({ id: 20, name }); + } + return Promise.resolve({ id: 999, name }); + }; + + mockChartOptionRepository.deleteChartOption = () => { + deleteCalled = true; + }; + + mockChartOptionRepository.createChartOption = () => { + createCalled = true; + }; + + await synchronizer.sync(chart, mockTransaction); + + strictEqual(createCalled, false, 'Should not create any options'); + strictEqual(deleteCalled, false, 'Should not delete any options'); + }); + + test('should throw error when findOptionByName fails', async () => { + let rollbackCalled = false; + const chart = { id: 1, options: ['option1'] }; + const error = new Error('Database connection failed'); + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + await rejects( + async () => await synchronizer.sync(chart, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + + test('should throw error when createChartOption fails', async () => { + const chart = { id: 1, options: ['option1'] }; + const error = new Error('Failed to create chart option'); + let rollbackCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = () => Promise.resolve({ id: 10, name: 'option1' }); + mockChartOptionRepository.createChartOption = () => Promise.reject(error); + + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects( + async () => await synchronizer.sync(chart, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js new file mode 100644 index 000000000..bac16c998 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js @@ -0,0 +1,200 @@ +/** + * @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. + */ + +import { deepStrictEqual, strictEqual, rejects, throws } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { GridTabCellSynchronizer } from '../../../../../lib/services/layout/helpers/gridTabCellSynchronizer.js'; +import { mapObjectToChartAndCell } from '../../../../../lib/services/layout/helpers/mapObjectToChartAndCell.js'; + +export const gridTabCellSynchronizerTestSuite = async () => { + suite('GridTabCellSynchronizer Test Suite', () => { + let mockGridTabCellRepository = null; + let mockChartRepository = null; + let mockChartOptionsSynchronizer = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + // Mock repositories + mockGridTabCellRepository = { + findByTabId: () => Promise.resolve([]), + updateGridTabCell: () => Promise.resolve(1), + createGridTabCell: () => Promise.resolve({ id: 1 }), + }; + + mockChartRepository = { + deleteChart: () => Promise.resolve(1), + updateChart: () => Promise.resolve(1), + createChart: () => Promise.resolve({ id: 1 }), + }; + + mockChartOptionsSynchronizer = { + sync: () => Promise.resolve(), + }; + + mockTransaction = { id: 'mock-transaction', rollback: () => {} }; + synchronizer = new GridTabCellSynchronizer( + mockGridTabCellRepository, + mockChartRepository, + mockChartOptionsSynchronizer, + ); + }); + + suite('Constructor', () => { + test('should successfully initialize GridTabCellSynchronizer', () => { + const gridTabCellRepo = { test: 'gridTabCellRepo' }; + const chartRepo = { test: 'chartRepo' }; + const chartOptionsSync = { test: 'chartOptionsSync' }; + const sync = new GridTabCellSynchronizer(gridTabCellRepo, chartRepo, chartOptionsSync); + + strictEqual(sync._gridTabCellRepository, gridTabCellRepo); + strictEqual(sync._chartRepository, chartRepo); + strictEqual(sync._chartOptionsSynchronizer, chartOptionsSync); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should create new charts and cells when none exist', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 1, name: 'New Chart' }]; + const createdCharts = []; + const createdCells = []; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([]); + mockChartRepository.createChart = (chart) => { + createdCharts.push(chart); + return Promise.resolve({ id: chart.id }); + }; + mockGridTabCellRepository.createGridTabCell = (cell) => { + createdCells.push(cell); + return Promise.resolve({ id: 1 }); + }; + + await synchronizer.sync(tabId, objects, mockTransaction); + + strictEqual(createdCharts.length, 1); + strictEqual(createdCells.length, 1); + }); + + test('should update existing charts and cells', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 1, name: 'Updated Chart' }]; + const updatedCharts = []; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([{ chart_id: 1 }]); + mockChartRepository.updateChart = (chartId, chart) => { + updatedCharts.push({ chartId, chart }); + return Promise.resolve(1); + }; + + await synchronizer.sync(tabId, objects, mockTransaction); + + strictEqual(updatedCharts.length, 1); + strictEqual(updatedCharts[0].chartId, 1); + }); + + test('should delete charts that are no longer present', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 2 }]; + const deletedCharts = []; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([ + { chart_id: 1 }, // Should be deleted + { chart_id: 2 }, // Should remain + ]); + mockChartRepository.deleteChart = (chartId) => { + deletedCharts.push(chartId); + return Promise.resolve(1); + }; + mockChartRepository.updateChart = () => Promise.resolve(1); + mockGridTabCellRepository.updateGridTabCell = () => Promise.resolve(1); + + await synchronizer.sync(tabId, objects, mockTransaction); + + strictEqual(deletedCharts.length, 1); + strictEqual(deletedCharts[0], 1); + }); + + test('should call chartOptionsSynchronizer for each object', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 1, options: ['option1'] }]; + const syncCalls = []; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([]); + mockChartRepository.createChart = (chart) => Promise.resolve({ id: chart.id }); + mockGridTabCellRepository.createGridTabCell = () => Promise.resolve({ id: 1 }); + mockChartOptionsSynchronizer.sync = (chart) => { + syncCalls.push({ chartId: chart.id, options: chart.options }); + return Promise.resolve(); + }; + + await synchronizer.sync(tabId, objects, mockTransaction); + + strictEqual(syncCalls.length, 1); + strictEqual(syncCalls[0].chartId, 1); + deepStrictEqual(syncCalls[0].options, ['option1']); + }); + + test('should throw error and rollback when operation fails', async () => { + const tabId = 'test-tab'; + const objects = []; + const error = new Error('Database connection failed'); + let rollbackCalled = false; + + mockGridTabCellRepository.findByTabId = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects( + async () => await synchronizer.sync(tabId, objects, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + + suite('map to chart and cell function', () => { + const mockObject = { + id: 'chart1', + x: 0, + y: 0, + h: 2, + w: 3, + name: 'Test Chart', + ignoreDefaults: true, + }; + const mockTabId = 'tab1'; + test('should map object to chart and cell correctly', () => { + const { chart, cell } = mapObjectToChartAndCell(mockObject, mockTabId); + strictEqual(chart.id, 'chart1'); + strictEqual(chart.object_name, 'Test Chart'); + strictEqual(chart.ignore_defaults, true); + strictEqual(cell.tab_id, 'tab1'); + strictEqual(cell.chart_id, 'chart1'); + strictEqual(cell.row, 0); + strictEqual(cell.col, 0); + strictEqual(cell.row_span, 2); + strictEqual(cell.col_span, 3); + }); + test('should throw error for invalid input', () => { + throws(() => mapObjectToChartAndCell(null, mockTabId), /Invalid input/); + throws(() => mapObjectToChartAndCell(mockObject, null), /Invalid input/); + throws(() => mapObjectToChartAndCell('invalid', mockTabId), /Invalid input/); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js new file mode 100644 index 000000000..2eb532631 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js @@ -0,0 +1,152 @@ +/** + * @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. + */ + +import { strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { TabSynchronizer } from '../../../../../lib/services/layout/helpers/tabSynchronizer.js'; + +export const tabSynchronizerTestSuite = async () => { + suite('TabSynchronizer Test Suite', () => { + let mockTabRepository = null; + let mockGridTabCellSynchronizer = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + mockTabRepository = { + findTabsByLayoutId: () => Promise.resolve([]), + deleteTab: () => Promise.resolve(1), + updateTab: () => Promise.resolve(1), + createTab: () => Promise.resolve({ id: 1 }), + findTabById: () => Promise.resolve({ id: 1 }), + }; + + mockGridTabCellSynchronizer = { + sync: () => Promise.resolve(), + }; + + mockTransaction = { id: 'mock-transaction', rollback: () => {} }; + synchronizer = new TabSynchronizer(mockTabRepository, mockGridTabCellSynchronizer); + }); + + suite('Constructor', () => { + test('should successfully initialize TabSynchronizer', () => { + const tabRepo = { test: 'tabRepo' }; + const gridSync = { test: 'gridSync' }; + const sync = new TabSynchronizer(tabRepo, gridSync); + + strictEqual(sync._tabRepository, tabRepo); + strictEqual(sync._gridTabCellSynchronizer, gridSync); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should create new tabs when none exist', async () => { + const layoutId = 'layout-1'; + const tabs = [{ name: 'New Tab', objects: [] }]; + const createdTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); + mockTabRepository.createTab = (tab) => { + createdTabs.push(tab); + return Promise.resolve({ id: 1 }); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(createdTabs.length, 1); + strictEqual(createdTabs[0].layout_id, layoutId); + }); + + test('should update existing tabs', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 1, name: 'Updated Tab', objects: [] }]; + const updatedTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([{ id: 1 }]); + mockTabRepository.updateTab = (id, tab) => { + updatedTabs.push({ id, tab }); + return Promise.resolve(1); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(updatedTabs.length, 1); + strictEqual(updatedTabs[0].id, 1); + strictEqual(updatedTabs[0].tab.layout_id, layoutId); + }); + + test('should delete tabs that are no longer present', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 2, name: 'Keep Tab' }]; + const deletedTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([ + { id: 1 }, // Should be deleted + { id: 2 }, // Should remain + ]); + mockTabRepository.deleteTab = (id) => { + deletedTabs.push(id); + return Promise.resolve(1); + }; + mockTabRepository.updateTab = () => Promise.resolve(1); + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(deletedTabs.length, 1); + strictEqual(deletedTabs[0], 1); + }); + + test('should sync grid tab cells when tab has objects', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 1, name: 'Tab with objects', objects: [{ id: 'obj1' }] }]; + const syncCalls = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([{ id: 1 }]); + mockTabRepository.updateTab = () => Promise.resolve(1); + mockGridTabCellSynchronizer.sync = (tabId, objects, transaction) => { + syncCalls.push({ tabId, objects }); + return Promise.resolve(); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(syncCalls.length, 1); + strictEqual(syncCalls[0].tabId, 1); + strictEqual(syncCalls[0].objects.length, 1); + }); + + test('should throw error and rollback when operation fails', async () => { + const layoutId = 'layout-1'; + const tabs = [{ name: 'New Tab' }]; + const error = new Error('Database error'); + let rollbackCalled = false; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); + mockTabRepository.createTab = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects( + async () => await synchronizer.sync(layoutId, tabs, mockTransaction), + error + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + }); +}; \ No newline at end of file diff --git a/QualityControl/test/lib/services/layout/helpers/normalizeLayout.test.js b/QualityControl/test/lib/services/layout/helpers/normalizeLayout.test.js new file mode 100644 index 000000000..16e76f8a8 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/normalizeLayout.test.js @@ -0,0 +1,72 @@ +/** + * @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. + */ + +import { deepStrictEqual } from 'node:assert'; +import { normalizeLayout } from '../../../../../lib/services/layout/helpers/normalizeLayout.js'; +import { test } from 'node:test'; + +export const normalizeLayoutTestSuite = async () => { + const mockUserService = { + getUsernameById: async (id) => { + const users = { + 1: 'alice', + 2: 'bob', + }; + return users[id] || null; + }, + }; + + const mockPatch = { + isOfficial: true, + }; + + const mockFullUpdate = { + name: 'Updated Layout', + description: 'This is the updated layout', + displayTimestamp: false, + autoTabChange: 60, + isOfficial: false, + owner_id: 2, + }; + + const mockOriginalLayout = { + id: 10, + name: 'Original Layout', + description: 'This is the original layout', + displayTimestamp: true, + autoTabChange: 30, + isOfficial: false, + owner_id: 1, + }; + + test('should patch a layout correctly', async () => { + const result = await normalizeLayout(mockPatch, mockOriginalLayout, false, mockUserService); + deepStrictEqual(result, { + is_official: true, + }); + }); + + test ('should fully replace a layout correctly', async () => { + const result = await normalizeLayout(mockFullUpdate, mockOriginalLayout, true, mockUserService); + deepStrictEqual(result, { + id: 10, + name: 'Updated Layout', + description: 'This is the updated layout', + display_timestamp: false, + auto_tab_change_interval: 60, + is_official: false, + owner_username: 'bob', + }); + }); +}; diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 70fee4890..ed5c75ec7 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -112,7 +112,7 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should have a link to show a layout from users layout', async () => { - const linkpath = cardLayoutLinkPath(cardPath(myLayoutIndex, 2)); + const linkpath = cardLayoutLinkPath(cardPath(myLayoutIndex, 4)); const href = await page.evaluate((path) => document.querySelector(path).href, linkpath); strictEqual(href, 'http://localhost:8080/?page=layoutShow&layoutId=671b8c22402408122e2f20dd'); @@ -180,12 +180,16 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) await testParent.test('should have a folder with one card after object path filtering', async () => { const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); - strictEqual(preFilterCardCount, 2); + strictEqual(preFilterCardCount, 4); + await page.locator('#openFilterToggle').click(); await delay(100); + await page.locator(filterObjectPath).fill('qc/MCH/QO/'); + await page.locator('#openFilterToggle').click(); await delay(100); + const postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); strictEqual(postFilterCardCount, 1); }); @@ -206,32 +210,34 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test('should have a folder with 1 card after object path filtering + regular search', async () => { - // reset page, thus reset filter/search. await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); await delay(100); await page.locator('div.m2:nth-child(3) > div:nth-child(1)').click(); await delay(100); const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); - strictEqual(preFilterCardCount, 5); + strictEqual(preFilterCardCount, 8, 'Number of cards before filtering should be 8'); + await page.locator('#openFilterToggle').click(); await delay(100); await page.locator(filterObjectPath).fill('object'); await page.locator('#openFilterToggle').click(); await delay(100); + let postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); - strictEqual(postFilterCardCount, 3); - await page.locator(filterPath).fill('pdpBeamType'); + strictEqual(postFilterCardCount, 2, 'Number of cards after object filtering should be 2'); + + await page.locator(filterPath).fill('test'); await delay(100); + postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); - strictEqual(postFilterCardCount, 1); + strictEqual(postFilterCardCount, 2, `Number of cards should be 2 but was ${postFilterCardCount}`); }); await testParent.test('should have a folder with one card after filtering', async () => { - // reset page, thus reset filter/search. await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); await delay(100); const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); - strictEqual(preFilterCardCount, 2); + strictEqual(preFilterCardCount, 4); await page.locator(filterPath).fill('a'); await delay(100); diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 0825d365d..2c591bfe1 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -107,49 +107,40 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => ); await testParent.test( - 'should have jsroot svg plots in the section', + 'should have jsroot svg plot in the section', { timeout }, async () => { - const plotsCount = await page.evaluate(() => document.querySelectorAll('section svg.jsroot').length); - ok(plotsCount > 1); + const isSvg = await page.evaluate(() => document.querySelector('.jsroot') instanceof SVGElement); + ok(isSvg, true, 'Plot is not an SVG element'); }, ); - await testParent - .test('should have an info button with full path and last modified when clicked (plot success)', async () => { - const commonSelectorPath = 'section > div > div > div > div:nth-child(2) > div > div'; + await testParent.test( + 'should have an info button with full path and last modified when clicked (plot success)', + async () => { + const commonSelectorPath = 'section > div > div > div > div > div > div'; const plot1Path = `${commonSelectorPath} > div:nth-child(1)`; + + // Click on the first plot await page.locator(plot1Path).click(); + // Evaluate in the browser const result = await page.evaluate((commonSelectorPath) => { - const { title } = document.querySelector(`${commonSelectorPath} > div:nth-child(2) > div > div > button`); - const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > div > div > div > div > div`; - const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > div > div`).innerText; - const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > b`).innerText; - const lastModifiedTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(6) > b`).innerText; - return { title, pathTitle, objectPath, lastModifiedTitle }; - }, commonSelectorPath); - strictEqual(result.title, 'View details about histogram'); - strictEqual(result.pathTitle, 'path'); - strictEqual(result.objectPath, 'qc/test/object/1'); - strictEqual(result.lastModifiedTitle, 'lastModified'); - }); + const { title } = document.querySelector('.mh1 > .btn'); - await testParent.test( - 'should have an info button with full path and last modified when clicked on a second plot(plot success)', - { timeout }, - async () => { - const commonSelectorPath = '#subcanvas > div:nth-child(2) > div > div'; - const plot2Path = `${commonSelectorPath} > div:nth-child(1)`; - await page.locator(plot2Path).click(); - const result = await page.evaluate((commonSelectorPath) => { - const { title } = document.querySelector(`${commonSelectorPath} > div:nth-child(2) > div > div > button`); const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > div > div > div > div > div`; - const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > div > div`).innerText; - const pathTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > b`).innerText; - const lastModifiedTitle = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(6) > b`).innerText; + + const objectPathEl = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > div > div`); + const pathTitleEl = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > b`); + const lastModifiedEl = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(6) > b`); + + const objectPath = objectPathEl?.innerText || ''; + const pathTitle = pathTitleEl?.innerText || ''; + const lastModifiedTitle = lastModifiedEl?.innerText || ''; + return { title, pathTitle, objectPath, lastModifiedTitle }; }, commonSelectorPath); + strictEqual(result.title, 'View details about histogram'); strictEqual(result.pathTitle, 'path'); strictEqual(result.objectPath, 'qc/test/object/1'); diff --git a/QualityControl/test/setup/seeders/ccdbObjects.js b/QualityControl/test/setup/seeders/ccdbObjects.js index 04066287a..ba82fa5e5 100644 --- a/QualityControl/test/setup/seeders/ccdbObjects.js +++ b/QualityControl/test/setup/seeders/ccdbObjects.js @@ -68,7 +68,7 @@ export const MOCK_OBJECT_BY_ID_RESULT = { qcVersion: '1.64.0', objectType: 'o2::quality_control::core::QualityObject', location: '/download/016fa8ac-f3b6-11ec-b9a9-c0a80209250c', - layoutDisplayOptions: [], + layoutDisplayOptions: ['logz'], layoutName: 'a-test', tabName: 'main', ignoreDefaults: false, diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 964518733..eefaee220 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -28,6 +28,7 @@ import { * the tests are imported and run here with NodeJS Test Runner which replaces (mocha, nyc, sinon, nock) */ +// Frontend tests import { initialPageSetupTests } from './public/initialPageSetup.test.js'; import { qcDrawingOptionsTests } from './public/components/qcDrawingOptions.test.js'; import { layoutListPageTests } from './public/pages/layout-list.test.js'; @@ -36,57 +37,70 @@ import { objectViewFromObjectTreeTests } from './public/pages/object-view-from-o import { objectViewFromLayoutShowTests } from './public/pages/object-view-from-layout-show.test.js'; import { layoutShowTests } from './public/pages/layout-show.test.js'; import { aboutPageTests } from './public/pages/about-page.test.js'; +import { filterTests } from './public/features/filterTest.test.js'; +import { runModeTests } from './public/features/runMode.test.js'; -/** - * Backend tests imports - */ +// API tests +import { apiGetLayoutsTests } from './api/layouts/api-get-layout.test.js'; +import { apiPutLayoutTests } from './api/layouts/api-put-layout.test.js'; +import { apiPatchLayoutTests } from './api/layouts/api-patch-layout.test.js'; +import { apiGetObjectsTests } from './api/objects/api-get-object.test.js'; +import { apiGetRunStatusTests } from './api/filters/api-get-run-status.test.js'; + +// Common library tests +import { commonLibraryQcObjectUtilsTestSuite } from './common/library/qcObject/utils.test.js'; +import { commonLibraryUtilsDateTimeTestSuite } from './common/library/utils/dateTimeFormat.test.js'; + +// Backend tests: utils import { errorHandlerTestSuite } from './lib/utils/errorHandler.test.js'; import { httpRequestsTestSuite } from './lib/utils/httpRequests.test.js'; -/** - * Controllers - */ +// Backend tests: controllers import { layoutControllerTestSuite } from './lib/controllers/LayoutController.test.js'; +import { layoutAdapterTestSuite } from './lib/controllers/adapters/layout-adapter.test.js'; import { statusControllerTestSuite } from './lib/controllers/StatusController.test.js'; import { filtersControllerTestSuite } from './lib/controllers/FiltersController.test.js'; import { objectControllerTestSuite } from './lib/controllers/ObjectController.test.js'; +import { userControllerTestSuite } from './lib/controllers/UserController.test.js'; -/** - * Services - */ +// Backend tests: services import { ccdbServiceTestSuite } from './lib/services/CcdbService.test.js'; import { statusServiceTestSuite } from './lib/services/StatusService.test.js'; import { bookkeepingServiceTestSuite } from './lib/services/BookkeepingService.test.js'; - -import { commonLibraryQcObjectUtilsTestSuite } from './common/library/qcObject/utils.test.js'; -import { commonLibraryUtilsDateTimeTestSuite } from './common/library/utils/dateTimeFormat.test.js'; +import { filterServiceTestSuite } from './lib/services/FilterService.test.js'; +import { qcObjectServiceTestSuite } from './lib/services/QcObjectService.test.js'; +import { runModeServiceTestSuite } from './lib/services/RunModeService.test.js'; +import { aliecsSynchronizerTestSuite } from './lib/services/external/AliEcsSynchronizer.test.js'; +import { chartOptionsSynchronizerTestSuite } from './lib/services/layout/helpers/ChartOptionsSynchronizer.test.js'; +import { gridTabCellSynchronizerTestSuite } from './lib/services/layout/helpers/GridTabCellSynchronizer.test.js'; +import { tabSynchronizerTestSuite } from './lib/services/layout/helpers/TabSynchronizer.test.js'; +import { layoutServiceTestSuite } from './lib/services/layout/LayoutService.test.js'; +import { userServiceTestSuite } from './lib/services/layout/UserService.test.js'; +import { normalizeLayoutTestSuite } from './lib/services/layout/helpers/normalizeLayout.test.js'; + +// Backend tests: repositories +import { layoutRepositoryTestSuite } from './lib/database/repositories/LayoutRepository.test.js'; +import { userRepositoryTestSuite } from './lib/database/repositories/UserRepository.test.js'; +import { chartRepositoryTestSuite } from './lib/database/repositories/ChartRepository.test.js'; +import { chartOptionsRepositoryTestSuite } from './lib/database/repositories/ChartOptionsRepository.test.js'; +import { tabRepositoryTestSuite } from './lib/database/repositories/TabRepository.test.js'; +import { optionRepositoryTestSuite } from './lib/database/repositories/OptionRepository.test.js'; +import { gridTabCellRepositoryTestSuite } from './lib/database/repositories/GridTabCellRepository.test.js'; + +// Backend tests: middlewares import { layoutIdMiddlewareTest } from './lib/middlewares/layouts/layoutId.middleware.test.js'; import { layoutOwnerMiddlewareTest } from './lib/middlewares/layouts/layoutOwner.middleware.test.js'; -import { layoutServiceMiddlewareTest } from './lib/middlewares/layouts/layoutService.middleware.test.js'; import { statusComponentMiddlewareTest } from './lib/middlewares/status/statusComponent.middleware.test.js'; import { runModeMiddlewareTest } from './lib/middlewares/filters/runMode.middleware.test.js'; import { runStatusFilterMiddlewareTest } from './lib/middlewares/filters/runStatusFilter.middleware.test.js'; -import { apiPutLayoutTests } from './api/layouts/api-put-layout.test.js'; -import { apiPatchLayoutTests } from './api/layouts/api-patch-layout.test.js'; -import { layoutRepositoryTest } from './lib/repositories/LayoutRepository.test.js'; -import { userRepositoryTest } from './lib/repositories/UserRepository.test.js'; -import { jsonFileServiceTestSuite } from './lib/services/JsonFileService.test.js'; -import { userControllerTestSuite } from './lib/controllers/UserController.test.js'; -import { chartRepositoryTest } from './lib/repositories/ChartRepository.test.js'; -import { filterServiceTestSuite } from './lib/services/FilterService.test.js'; -import { apiGetLayoutsTests } from './api/layouts/api-get-layout.test.js'; -import { apiGetObjectsTests } from './api/objects/api-get-object.test.js'; import { objectsGetValidationMiddlewareTest } from './lib/middlewares/objects/objectsGetValidation.middleware.test.js'; -import { objectGetContentsValidationMiddlewareTest } - from './lib/middlewares/objects/objectGetByContentsValidation.middleware.test.js'; -import { objectGetByIdValidationMiddlewareTest } - from './lib/middlewares/objects/objectGetByIdValidation.middleware.test.js'; -import { filterTests } from './public/features/filterTest.test.js'; -import { qcObjectServiceTestSuite } from './lib/services/QcObjectService.test.js'; -import { runModeServiceTestSuite } from './lib/services/RunModeService.test.js'; -import { apiGetRunStatusTests } from './api/filters/api-get-run-status.test.js'; -import { runModeTests } from './public/features/runMode.test.js'; -import { aliecsSynchronizerTestSuite } from './lib/services/external/AliEcsSynchronizer.test.js'; +import { objectGetContentsValidationMiddlewareTest } from + './lib/middlewares/objects/objectGetByContentsValidation.middleware.test.js'; +import { objectGetByIdValidationMiddlewareTest } from + './lib/middlewares/objects/objectGetByIdValidation.middleware.test.js'; +import { getLayoutsMiddlewareTestSuite } from './lib/middlewares/layouts/layoutsGet.middleware.test.js'; +import { layoutValidateMiddlewareTestSuite } from './lib/middlewares/layouts/layoutValidate.middleware.test.js'; +import { validateUserMiddlewareTestSuite } from './lib/middlewares/validateUser.middleware.test.js'; const FRONT_END_PER_TEST_TIMEOUT = 5000; // each front-end test is allowed this timeout // remaining tests are based on the number of individual tests in each suite @@ -209,26 +223,50 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn }); suite('Repositories - Test Suite', async () => { - suite('Layout Repository - Test Suite', async () => await layoutRepositoryTest()); - suite('User Repository - Test Suite', async () => await userRepositoryTest()); - suite('Chart Repository - Test Suite', async () => await chartRepositoryTest()); + suite('Layout Repository - Test Suite', async () => await layoutRepositoryTestSuite()); + suite('User Repository - Test Suite', async () => await userRepositoryTestSuite()); + suite('Chart Repository - Test Suite', async () => await chartRepositoryTestSuite()); + suite('Chart Options Repository - Test Suite', async () => await chartOptionsRepositoryTestSuite()); + suite('Grid Tab Cell Repository - Test Suite', async () => await gridTabCellRepositoryTestSuite()); + suite('Tab Repository - Test Suite', async () => await tabRepositoryTestSuite()); + suite('Option Repository - Test Suite', async () => await optionRepositoryTestSuite()); }); suite('Services - Test Suite', async () => { suite('CcdbService - Test Suite', async () => await ccdbServiceTestSuite()); suite('StatusService - Test Suite', async () => await statusServiceTestSuite()); - suite('JsonServiceTest test suite', async () => await jsonFileServiceTestSuite()); - suite('FilterService', async () => await filterServiceTestSuite()); + suite('FilterService - Test Suite', async () => await filterServiceTestSuite()); suite('RunModeService - Test Suite', async () => await runModeServiceTestSuite()); suite('QcObjectService - Test Suite', async () => await qcObjectServiceTestSuite()); suite('BookkeepingServiceTest test suite', async () => await bookkeepingServiceTestSuite()); suite('AliEcsSynchronizer - Test Suite', async () => await aliecsSynchronizerTestSuite()); + suite('Service Helpers - Test Suites', async () => { + suite('Layout mapper - Test Suite', async () => await normalizeLayoutTestSuite()); + suite('Chart Options Synchronizer - Test Suite', async () => await chartOptionsSynchronizerTestSuite()); + suite('Grid tab cell synchronizer - Test Suite', async () => await gridTabCellSynchronizerTestSuite()); + suite('Tab Synchronizer - Test Suite', async () => await tabSynchronizerTestSuite()); + }); + suite('Layout Service - Test Suite', async () => await layoutServiceTestSuite()); + suite('User Service - Test Suite', async () => await userServiceTestSuite()); + }); + + suite('Controllers - Test Suite', async () => { + suite('LayoutController test suite', async () => await layoutControllerTestSuite()); + suite('StatusController test suite', async () => await statusControllerTestSuite()); + suite('ObjectController test suite', async () => await objectControllerTestSuite()); + suite('UserController - Test Suite', async () => await userControllerTestSuite()); + suite('FiltersController test suite', async () => await filtersControllerTestSuite()); + suite('Adapters - Test Suite', async () => { + suite('LayoutAdapter test suite', async () => await layoutAdapterTestSuite()); + }); }); suite('Middleware - Test Suite', async () => { - suite('LayoutServiceMiddleware test suite', async () => layoutServiceMiddlewareTest()); suite('LayoutIdMiddleware test suite', async () => layoutIdMiddlewareTest()); + suite('LayoutsGetMiddleware test suite', async () => getLayoutsMiddlewareTestSuite()); suite('LayoutOwnerMiddleware test suite', async () => layoutOwnerMiddlewareTest()); + suite('Layout validation middleware test suite', async () => layoutValidateMiddlewareTestSuite()); + suite('User session middleware test suite', async () => validateUserMiddlewareTestSuite()); suite('StatusComponentMiddleware test suite', async () => statusComponentMiddlewareTest()); suite('RunModeMiddleware test suite', async () => runModeMiddlewareTest()); suite('RunStatusFilterMiddleware test suite', async () => runStatusFilterMiddlewareTest()); @@ -237,13 +275,5 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn objectGetContentsValidationMiddlewareTest()); suite('ObjectGetByIdValidationMiddleware test suite', async () => objectGetByIdValidationMiddlewareTest()); }); - - suite('Controllers - Test Suite', async () => { - suite('LayoutController test suite', async () => await layoutControllerTestSuite()); - suite('StatusController test suite', async () => await statusControllerTestSuite()); - suite('ObjectController test suite', async () => await objectControllerTestSuite()); - suite('UserController - Test Suite', async () => await userControllerTestSuite()); - suite('FiltersController test suite', async () => await filtersControllerTestSuite()); - }); }); });