diff --git a/QualityControl/config-default.js b/QualityControl/config-default.js index 8d635505c..8af212af7 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.yml b/QualityControl/docker-compose.yml index ad099379a..7835f3672 100644 --- a/QualityControl/docker-compose.yml +++ b/QualityControl/docker-compose.yml @@ -22,7 +22,7 @@ services: target: /docker-entrypoint-initdb.d # Max total time for the container to start 2 mins (20s + 5*20s) healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD:-cern}"] interval: 20s timeout: 20s retries: 5 diff --git a/QualityControl/docs/Configuration.md b/QualityControl/docs/Configuration.md index 9d00c16b1..5a6915121 100644 --- a/QualityControl/docs/Configuration.md +++ b/QualityControl/docs/Configuration.md @@ -86,16 +86,20 @@ qc: { ### Database Configuration The application requires the following database configuration parameters: -| **Field** | **Description** | -|-----------------|---------------------------------------------------------------------------------------------| -| `host` | Hostname or IP address of the database server. | -| `port` | Port number used to connect to the database server. | -| `username` | Username for authenticating with the database. | -| `password` | Password for the specified database user. | -| `database` | Name of the database to connect to. | -| `charset` | Character encoding used for the connection. | -| `collate` | Collation setting used for string comparison and sorting. | -| `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. | + +| **Field** | **Type** | **Description** | **Default Value** | +|-----------------|-------------|---------------------------------------------------------------------------------------------|---------------------------| +| `host` | `string` | Hostname or IP address of the database server. | `'database'` | +| `port` | `number` | Port number used to connect to the database server. | `3306` | +| `username` | `string` | Username for authenticating with the database. | `'cern'` | +| `password` | `string` | Password for the specified database user. | `'cern'` | +| `database` | `string` | Name of the database to connect to. | `'qcg'` | +| `charset` | `string` | Character encoding used for the connection. | `'utf8mb4'` | +| `collate` | `string` | Collation setting used for string comparison and sorting. | `'utf8mb4_unicode_ci'` | +| `timezone` | `string` | Time zone used for all date/time values in the database connection. | `'+00:00'` | +| `logging` | `boolean` | Enables or disables SQL query logging (useful for debugging). | `false` | +| `retryThrottle` | `number` | Time in milliseconds to wait before retrying a failed database connection. | `5000` | +| `forceSeed` | `boolean` | (for dev mode) Force seeding the database with mock data. **Warning:** Not recommended for production use. | `false` | +| `drop` | `boolean` | (for dev mode) Force deleting the data from the database when server starts. **Warning:** This will erase all data—never use in production. | `false` | + +To know more about the database configuration, please go to: [Database Setup](./Database.md) \ No newline at end of file diff --git a/QualityControl/docs/Database.md b/QualityControl/docs/Database.md new file mode 100644 index 000000000..384e8c486 --- /dev/null +++ b/QualityControl/docs/Database.md @@ -0,0 +1,79 @@ +# Database Setup and Configuration + +This document explains how to set up and configure the database for development and testing purposes. + +## Development Database Setup + +There are two ways to set up a database for development: + +### Option 1: Docker (Recommended) + +The easiest way to get started is using Docker with the default configuration: + +```bash +npm run docker-dev +``` + +This command will: +- Start the database container using Docker Compose +- Use the default configuration settings +- Set up the database ready for development + +### Option 2: Local MariaDB Installation + +For a local MariaDB setup, you'll need to install MariaDB on your system first. + +**Installation Guide:** [MariaDB Getting Started](https://mariadb.com/get-started-with-mariadb/) + +After installing MariaDB locally, configure your application to connect to your local database instance by updating the appropriate configuration files. + +## Testing Database Setup + +For running tests, Docker must be installed and running on your system. + +To set up the test database: + +```bash +npm run docker-test +``` + +This command will: +- Start a test database container +- Automatically seed the database with mock data prepared specifically for testing +- Ensure a clean testing environment + +## Database Seeding and Configuration + +### Development Seeding + +For development purposes, you can seed the database with mock data by setting `forceSeed` to `true` in your configuration. + +**Important Notes:** +- When `forceSeed` is enabled, seeding will execute every time the server reloads +- The `drop` property will clean all data from the database if set to `true` every time the server reloads +- Use these options carefully to avoid unintended data loss + +### Migrations + +Database migrations will be executed automatically if they haven't been run before. + +**Clean Setup Recommendation:** +For a completely clean database setup, it's recommended to run: + +```bash +docker compose down database -v +``` + +And delete all existing data. + +**WARNING:** Only perform this clean setup the first time or when you specifically need to reset everything, as it will permanently delete all database data. + +## Current Status + +**Important:** The database is currently not being used by the application or tests, as the services and controllers are not yet configured to utilize the database layer. This functionality is planned for future implementation. + +## Database Management Commands + +- `npm run docker-dev` - Start development database +- `npm run docker-test` - Start test database with mock data +- `docker compose down database -v` - Clean database setup (removes all data) \ No newline at end of file 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 e56185c92..cf299d979 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -23,7 +23,6 @@ import { Kafka, logLevel } from 'kafkajs'; 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'; @@ -36,9 +35,6 @@ import { FilterController } from './controllers/FilterController.js'; import { UserController } from './controllers/UserController.js'; 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 { objectGetByIdValidationMiddlewareFactory } @@ -49,6 +45,9 @@ import { objectGetContentsValidationMiddlewareFactory } import { RunModeService } from './services/RunModeService.js'; import { KafkaConfigDto } from './dtos/KafkaConfigurationDto.js'; import { QcdbDownloadService } from './services/QcdbDownload.service.js'; +import { LayoutService } from './services/layout/LayoutService.js'; +import { setupRepositories } from './database/repositories/index.js'; +import { UserService } from './services/layout/UserService.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`; /** @@ -63,10 +62,13 @@ export const setupQcModel = async (eventEmitter) => { 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 || {})); + 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 sequelizeDatabase = new SequelizeDatabase(databaseConfig); + initDatabase(sequelizeDatabase, { forceSeed: config?.database?.forceSeed, drop: config?.database?.drop }); if (config?.kafka?.enabled) { try { @@ -85,12 +87,29 @@ export const setupQcModel = async (eventEmitter) => { } } - const layoutRepository = new LayoutRepository(jsonFileService); - const userRepository = new UserRepository(jsonFileService); - const chartRepository = new ChartRepository(jsonFileService); + const { + layoutRepository, + gridTabCellRepository, + userRepository, + tabRepository, + chartRepository, + chartOptionRepository, + optionRepository, + } = setupRepositories(sequelizeDatabase); + + const userService = new UserService(userRepository); + const layoutService = new LayoutService( + layoutRepository, + userService, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, + ); - const userController = new UserController(userRepository); - const layoutController = new LayoutController(layoutRepository); + const userController = new UserController(userService); + const layoutController = new LayoutController(layoutService); const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); const statusController = new StatusController(statusService); @@ -100,7 +119,7 @@ export const setupQcModel = async (eventEmitter) => { 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(); @@ -125,9 +144,8 @@ export const setupQcModel = async (eventEmitter) => { statusController, objectController, intervalsService, + layoutService, filterController, - layoutRepository, - jsonFileService, objectGetByIdValidation, objectsGetValidation, objectGetContentsValidation, diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 47e3c9a9c..5bbd78303 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -16,8 +16,6 @@ import { setupQcModel } from './QCModel.js'; import { minimumRoleMiddleware } from './middleware/minimumRole.middleware.js'; import { UserRole } from './../common/library/userRole.enum.js'; 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'; @@ -39,7 +37,6 @@ export const setup = async (http, ws, eventEmitter) => { * statusController: import('./controllers/StatusController.js').StatusController, * statusService: import('./services/statusService').StatusService, * userController: import('./controllers/UserController.js').UserController, - * jsonFileService: import('./services/JsonFileService.js').JsonFileService * }} */ const { @@ -48,8 +45,7 @@ export const setup = async (http, ws, eventEmitter) => { statusController, statusService, userController, - layoutRepository, - jsonFileService, + layoutService, filterController, objectGetByIdValidation, objectsGetValidation, @@ -75,23 +71,17 @@ export const setup = async (http, ws, eventEmitter) => { http.post('/layout', layoutController.postLayoutHandler.bind(layoutController)); http.put( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), - layoutOwnerMiddleware(layoutRepository), + layoutOwnerMiddleware(layoutService), layoutController.putLayoutHandler.bind(layoutController), ); http.patch( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), minimumRoleMiddleware(UserRole.GLOBAL), layoutController.patchLayoutHandler.bind(layoutController), ); http.delete( '/layout/:id', - layoutServiceMiddleware(jsonFileService), - layoutIdMiddleware(layoutRepository), - layoutOwnerMiddleware(layoutRepository), + layoutOwnerMiddleware(layoutService), layoutController.deleteLayoutHandler.bind(layoutController), ); 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..cd7b8836c 100644 --- a/QualityControl/lib/controllers/LayoutController.js +++ b/QualityControl/lib/controllers/LayoutController.js @@ -21,13 +21,13 @@ import { LayoutPatchDto } from './../dtos/LayoutPatchDto.js'; import { InvalidInputError, LogManager, - NotFoundError, updateAndSendExpressResponseFromNativeError, } from '@aliceo2/web-ui'; +import { mapLayoutToAPI } from './helpers/mapLayoutToAPI.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`); @@ -38,16 +38,14 @@ 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 service for layout data */ - constructor(layoutRepository) { - assert(layoutRepository, 'Missing layout repository'); + constructor(layoutService) { + assert(layoutService, 'Missing layout service'); - /** - * @type {LayoutRepository} - */ - this._layoutRepository = layoutRepository; + /** @type {LayoutService} */ + this._layoutService = layoutService; } /** @@ -71,13 +69,16 @@ export class LayoutController { new InvalidInputError(`Invalid query parameters: ${error.details[0].message}`) : new Error('Unable to process request'); - logger.errorMessage(`Error validating query parameters: ${error}`); + logger.errorMessage(`Error getting layouts: ${responseError.message}`); return updateAndSendExpressResponseFromNativeError(res, responseError); } try { - const layouts = await this._layoutRepository.listLayouts({ fields, filter: { ...filter, owner_id } }); - return res.status(200).json(layouts); + const filters = owner_id !== undefined ? { owner_id, ...filter } : { ...filter }; + const layouts = await this._layoutService.getLayoutsByFilters(filters); + const adaptedLayouts = layouts.map((layout) => + mapLayoutToAPI(layout, fields)); + return res.status(200).json(adaptedLayouts); } catch (error) { logger.errorMessage(`Error retrieving layouts: ${error}`); return updateAndSendExpressResponseFromNativeError(res, new Error('Unable to retrieve layouts')); @@ -92,15 +93,12 @@ export class LayoutController { */ 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 layout = await this._layoutService.getLayoutById(id); + const adaptedLayout = mapLayoutToAPI(layout); + res.status(200).json(adaptedLayout); + } catch (error) { + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -126,8 +124,9 @@ 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 = mapLayoutToAPI(layout); + res.status(200).json(adaptedLayout); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); } @@ -143,28 +142,28 @@ export class LayoutController { */ async putLayoutHandler(req, res) { const { id } = req.params; - let layoutProposed = {}; + let layoutProposed = req.body; + const parsedId = parseInt(id, 10); try { - layoutProposed = await LayoutDto.validateAsync(req.body); + if (Object.keys(layoutProposed).length === 0) { + throw new InvalidInputError('No layout data provided in the request body'); + } + if (parsedId !== layoutProposed.id) { + throw new InvalidInputError('Layout ID in the path does not match ID in the body'); + } + layoutProposed = await LayoutDto.validateAsync({ ...layoutProposed }); } catch (error) { updateAndSendExpressResponseFromNativeError( res, - new InvalidInputError(`Failed to update layout: ${error?.details?.[0]?.message || ''}`), + error.isJoi ? + new InvalidInputError(`Failed to validate layout: ${error?.details[0]?.message || ''}`) : + error, ); 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(parsedId, layoutProposed); + res.status(200).json({ id: updatedLayoutId }); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); } @@ -179,10 +178,10 @@ export class LayoutController { async deleteLayoutHandler(req, res) { const { id } = req.params; try { - const result = await this._layoutRepository.deleteLayout(id); - res.status(200).json(result); - } catch { - updateAndSendExpressResponseFromNativeError(res, new Error(`Unable to delete layout with id: ${id}`)); + await this._layoutService.removeLayout(id); + res.status(200).json({ id }); + } catch (error) { + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -204,18 +203,10 @@ export class LayoutController { return; } 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); - res.status(201).json(result); - } catch { - updateAndSendExpressResponseFromNativeError(res, new Error('Unable to create new layout')); + const newLayout = await this._layoutService.postLayout(layoutProposed); + res.status(201).json({ id: newLayout.id }); + } catch (error) { + updateAndSendExpressResponseFromNativeError(res, error); } } @@ -227,27 +218,23 @@ export class LayoutController { */ async patchLayoutHandler(req, res) { const { id } = req.params; - let layout = {}; + const parsedId = parseInt(id, 10); try { - layout = await LayoutPatchDto.validateAsync(req.body); + if (Object.keys(req.body).length === 0) { + throw new InvalidInputError('No layout data provided in the request body'); + } + // Validate the patch object + const layout = await LayoutPatchDto.validateAsync(req.body); + + // Apply the patch + const updatedLayoutId = await this._layoutService.patchLayout(parsedId, layout); + res.status(200).json({ id: updatedLayoutId }); } 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}`)); + let responseError = error; + if (error.isJoi) { + responseError = new InvalidInputError(`Failed to validate layout patch: ${error?.details[0]?.message || ''}`); + } + updateAndSendExpressResponseFromNativeError(res, responseError); return; } } diff --git a/QualityControl/lib/controllers/UserController.js b/QualityControl/lib/controllers/UserController.js index 9c67da4ca..1d9644150 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; } /** @@ -49,11 +49,11 @@ export class UserController { * @returns {undefined} */ async addUserHandler(req, res) { - const { personid: id, name, username } = req.session; + const { personid, name, username } = req.session; try { - this._validateUser(username, name, id); - await this._userRepository.createUser({ id, name, username }); + this._validateUser(username, name, personid); + await this._userService.saveUser({ personid, name, username }); res.status(200).json({ ok: true }); } catch (err) { if (err.stack) { diff --git a/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js new file mode 100644 index 000000000..3811816c5 --- /dev/null +++ b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js @@ -0,0 +1,66 @@ +/** + * @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. + */ + +/** + * 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. + */ +export function mapLayoutToAPI(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 || 2, + 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 || false, + 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 23cf35050..b126bc0e3 100644 --- a/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs +++ b/QualityControl/lib/database/migrations/20250424083717-create-tables.mjs @@ -62,6 +62,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/repositories/LayoutRepository.js b/QualityControl/lib/database/repositories/LayoutRepository.js index 33c525ef3..42f9d2ccb 100644 --- a/QualityControl/lib/database/repositories/LayoutRepository.js +++ b/QualityControl/lib/database/repositories/LayoutRepository.js @@ -79,18 +79,20 @@ export class LayoutRepository extends BaseRepository { * @param {string} [filters.objectPath] optional object path to filter charts * @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 === 0) { + async findLayoutsByFilters(filters = {}) { + const where = {}; + + if (filters.objectPath) { + const layoutIds = await this._getLayoutIdsByObjectPath(filters.objectPath); + if (!layoutIds?.length) { return []; } - whereClause.id = { [Op.in]: layoutIds }; + where.id = { [Op.in]: layoutIds }; + delete filters.objectPath; } + Object.assign(where, filters); return this.model.findAll({ - where: whereClause, + where, include: this.defaultInclude, }); } @@ -105,12 +107,15 @@ export class LayoutRepository extends BaseRepository { include: [ { association: 'tabs', + required: true, include: [ { association: 'gridTabCells', + required: true, include: [ { association: 'chart', + required: true, where: { object_name: { [Op.like]: `%${objectPath}%` } }, }, ], 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..fe7347b4c --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs @@ -0,0 +1,46 @@ +/** + * @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'; + +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed users + * @param {QueryInterface} queryInterface - The query interface + */ +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('users', [ + { + id: 0, + name: 'Anonymous', + username: 'anonymous', + }, + { + id: 99, + name: 'Some other owner', + username: 'some_other_owner', + }, + ], {}); +}; + +/** + * Remove seeded users + * @param {QueryInterface} queryInterface - The query interface + */ +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..68b2541cc --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs @@ -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. + */ + +'use strict'; + +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed layouts + * @param {QueryInterface} queryInterface - The query interface + */ +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('layouts', [ + { + id: 1, + old_id: '671b8c22402408122e2f20dd', + name: 'test', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + { + id: 2, + old_id: '671b95883d23cd0d67bdc787', + name: 'a-test', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }, + { + id: 3, + old_id: '3d23671b9588787cd0d67bdc', + name: 'rundefinition_pdpBeamType', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'some_other_owner', + }, + ], {}); +}; + +/** + * Remove seeded layouts + * @param {QueryInterface} queryInterface - The query interface + */ +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..2479c466f --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.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'; + +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed tabs + * @param {QueryInterface} queryInterface - The query interface + */ + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('tabs', [ + { + id: 1, + name: 'main', + layout_id: 1, + column_count: 2, + }, + { + id: 2, + name: 'test-tab', + layout_id: 1, + column_count: 3, + }, + { + id: 3, + name: 'main', + layout_id: 2, + column_count: 2, + }, + { + id: 4, + name: 'a', + layout_id: 2, + column_count: 2, + }, + { + id: 5, + name: 'main', + layout_id: 3, + column_count: 2, + }, + { + id: 6, + name: 'a', + layout_id: 3, + column_count: 2, + }, + + ], {}); +}; + +/** + * Remove seeded tabs + * @param {QueryInterface} queryInterface - The query interface + */ +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..40502bdf6 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs @@ -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. + */ + +'use strict'; + +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed charts + * @param {QueryInterface} queryInterface - The query interface + */ + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('charts', [ + { + id: 1, + object_name: 'qc/TPC/QO/CheckOfTrack_Trending', + ignore_defaults: false, + }, + { + id: 2, + object_name: 'qc/MCH/QO/DataDecodingCheck', + ignore_defaults: false, + }, + { + id: 3, + object_name: 'qc/MCH/QO/MFTRefCheck', + ignore_defaults: false, + }, + { + id: 4, + object_name: 'qc/MCH/MO/Pedestals/ST5/DE1006/BadChannels_XY_B_1006', + ignore_defaults: false, + }, + { + id: 5, + object_name: 'qc/MCH/MO/Pedestals/BadChannelsPerDE', + ignore_defaults: false, + }, + { + id: 6, + object_name: 'qc/test/object/1', + ignore_defaults: false, + }, + { + id: 7, + object_name: 'qc/test/object/1', + ignore_defaults: false, + }, + { + id: 8, + object_name: 'qc/test/object/1', + ignore_defaults: false, + }, + ], {}); +}; + +/** + * Remove seeded charts + * @param {QueryInterface} queryInterface - The query interface + */ +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..850bbe20c --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs @@ -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. + */ + +'use strict'; + +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed grid tab cells + * @param {QueryInterface} queryInterface - The query interface + */ +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('grid_tab_cells', [ + { + chart_id: 1, + row: 0, + col: 0, + tab_id: 1, + row_span: 1, + col_span: 1, + }, + { + chart_id: 2, + row: 0, + col: 1, + tab_id: 1, + row_span: 1, + col_span: 1, + }, + { + chart_id: 3, + row: 0, + col: 2, + tab_id: 1, + row_span: 1, + col_span: 1, + }, + { + chart_id: 4, + row: 1, + col: 0, + tab_id: 1, + row_span: 1, + col_span: 1, + }, + { + chart_id: 5, + row: 0, + col: 0, + tab_id: 2, + row_span: 1, + col_span: 1, + }, + { + chart_id: 6, + row: 0, + col: 0, + tab_id: 3, + row_span: 1, + col_span: 1, + }, + { + chart_id: 7, + row: 0, + col: 0, + tab_id: 5, + row_span: 1, + col_span: 1, + }, + { + chart_id: 8, + row: 0, + col: 0, + tab_id: 5, + row_span: 1, + col_span: 1, + }, + ], {}); +}; + +/** + * Remove seeded grid tab cells + * @param {QueryInterface} queryInterface - The query interface + */ +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..c3a96be72 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs @@ -0,0 +1,66 @@ +/** + * @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 + */ +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('chart_options', [ + { + chart_id: 1, + option_id: 1, + }, + { + chart_id: 1, + option_id: 2, + }, + { + chart_id: 2, + option_id: 1, + }, + { + chart_id: 3, + option_id: 3, + }, + { + chart_id: 4, + option_id: 4, + }, + { + chart_id: 5, + option_id: 5, + }, + { + chart_id: 5, + option_id: 6, + }, + { + chart_id: 6, + option_id: 7, + }, + ], {}); +}; + +/** + * Remove seeded chart options + * @param {QueryInterface} queryInterface - The query interface + */ +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('chart_options', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/dtos/LayoutDto.js b/QualityControl/lib/dtos/LayoutDto.js index a285ddb94..71471d737 100644 --- a/QualityControl/lib/dtos/LayoutDto.js +++ b/QualityControl/lib/dtos/LayoutDto.js @@ -44,7 +44,7 @@ function parseAndValidateFields(value, helpers) { } const ObjectDto = Joi.object({ - id: Joi.string().required(), + id: Joi.number(), name: Joi.string().required(), x: Joi.number().min(0).default(0), y: Joi.number().min(0).default(0), @@ -56,19 +56,19 @@ const ObjectDto = Joi.object({ }); const TabsDto = Joi.object({ - id: Joi.string().required(), + id: Joi.number(), name: Joi.string().min(1).max(50).required(), columns: Joi.number().min(1).max(5).default(2), objects: Joi.array().max(30).items(ObjectDto).default([]), }); -const UserDto = Joi.object({ +export const UserDto = Joi.object({ id: Joi.number().min(0).required(), name: Joi.string().required(), }); export const LayoutDto = Joi.object({ - id: Joi.string().required(), + id: Joi.number(), name: Joi.string().min(3).max(40).required(), tabs: Joi.array().min(1).max(45).items(TabsDto).required(), owner_id: Joi.number().min(0).required(), diff --git a/QualityControl/lib/middleware/layouts/layoutId.middleware.js b/QualityControl/lib/middleware/layouts/layoutId.middleware.js deleted file mode 100644 index 4c15b321e..000000000 --- a/QualityControl/lib/middleware/layouts/layoutId.middleware.js +++ /dev/null @@ -1,46 +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 { InvalidInputError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; - -/** - * @typedef {import('../../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) - */ - 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; - } - }; diff --git a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js index 40380b748..a4a2a7190 100644 --- a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js +++ b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js @@ -12,18 +12,24 @@ * or submit itself to any jurisdiction. */ -import { NotFoundError, UnauthorizedAccessError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { + InvalidInputError, + NotFoundError, + UnauthorizedAccessError, + updateAndSendExpressResponseFromNativeError, +} from '@aliceo2/web-ui'; +import { UserDto } from '../../dtos/LayoutDto.js'; /** - * @typedef {import('../../repositories/LayoutRepository.js').LayoutRepository} LayoutRepository + * @typedef {import('../../services/layout/LayoutService').LayoutService} LayoutService */ /** * Middleware that checks if the requestor is the owner of the layout - * @param {LayoutRepository} layoutRepository - Repository for getting/setting layout data + * @param {LayoutService} layoutService - Service for getting/setting layout data * @returns {function(req, res, next): Function} - middleware function */ -export const layoutOwnerMiddleware = (layoutRepository) => +export const layoutOwnerMiddleware = (layoutService) => /** * Returned middleware method @@ -34,18 +40,32 @@ 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 === '') { + + if (!req.session) { + throw new NotFoundError('Session not found'); + } + + const { personid, name } = req.session; + try { + await UserDto.validateAsync({ id: personid, name }); + } catch (error) { + if (error.isJoi) { + throw new InvalidInputError('User could not be validated'); + } + } + + const layout = await layoutService.getLayoutById(id); + const owner = layout?.owner; + if (owner?.id == null || owner?.name == null || owner.id === '' || owner.name === '') { 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) { + } + + if (owner.name !== name || owner.id !== personid) { throw new UnauthorizedAccessError('Only the owner of the layout can delete it'); } + next(); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); - return; } }; 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/services/QcObject.service.js b/QualityControl/lib/services/QcObject.service.js index 56eed8fde..2d0bc6e1a 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('../services/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 - Layout service to retrieve layout related information * @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} @@ -98,7 +98,7 @@ export class QcObjectService { * The service can return objects either: * * from cache if it is requested by the client and the system is configured to use a cache; * * make a new request and get data directly from data service - * @example Equivalent of URL request: `/latest/qc/TPC/object.*` + * * @example Equivalent of URL request: `/latest/qc/TPC/object.*` * @param {object} options - An object that contains query parameters among other arguments * @param {string|Regex} options.prefix - Prefix for which CCDB should search for objects. * @param {Array} options.fields - List of fields that should be requested for each object @@ -184,16 +184,17 @@ export class QcObjectService { * @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/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js new file mode 100644 index 000000000..26fc61712 --- /dev/null +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -0,0 +1,292 @@ +/** + * @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, NotFoundError } from '@aliceo2/web-ui'; +import { normalizeLayout } from './helpers/layoutMapper.js'; +import { TabSynchronizer } from '../../services/layout/helpers/tabSynchronizer.js'; +import { GridTabCellSynchronizer } from './helpers/gridTabCellSynchronizer.js'; +import { ChartOptionsSynchronizer } from './helpers/chartOptionsSynchronizer.js'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-svc`; + +/** + * @typedef {import('../../database/repositories/LayoutRepository').LayoutRepository} LayoutRepository + * @typedef {import('../../database/repositories/GridTabCellRepository').GridTabCellRepository} GridTabCellRepository + * @typedef {import('../../services/layout/UserService.js').UserService} UserService + * @typedef {import('../../services/layout/helpers/tabSynchronizer.js').TabSynchronizer} TabSynchronizer + */ + +/** + * Class that handles the business logic for the layouts + */ +export class LayoutService { + /** + * Creates an instance of the LayoutService class + * @param {LayoutRepository} layoutRepository Layout repository instance + * @param tabRepository + * @param {GridTabCellRepository} gridTabCellRepository Grid tab cell repository instance + * @param {UserService} userService User service instance + * @param {TabSynchronizer} tabSynchronizer Tab synchronizer instance + * @param chartRepository + * @param chartOptionRepository + * @param optionRepository + */ + constructor( + layoutRepository, + userService, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, + ) { + this._logger = LogManager.getLogger(LOG_FACILITY); + this._layoutRepository = layoutRepository; + this._gridTabCellRepository = gridTabCellRepository; + this._userService = userService; + + // Synchronizers + this._chartOptionsSynchronizer = new ChartOptionsSynchronizer( + chartOptionRepository, + optionRepository, + ); + this._gridTabCellSynchronizer = new GridTabCellSynchronizer( + gridTabCellRepository, + chartRepository, + this._chartOptionsSynchronizer, + ); + this._tabSynchronizer = new TabSynchronizer( + 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 = {}) { + try { + if (filters.owner_id !== undefined) { + filters = await this._addOwnerUsername(filters); + } + const layouts = await this._layoutRepository.findLayoutsByFilters(filters); + return layouts; + } catch (error) { + this._logger.errorMessage(`Error getting layouts by filters: ${error?.message || error}`); + throw error; + } + } + + /** + * Adds the owner's username to the filters based on owner_id + * @param {object} filters - The original filters object + * @returns {Promise} The updated filters object with owner_username + */ + async _addOwnerUsername(filters) { + try { + const owner_username = await this._userService.getUsernameById(filters.owner_id); + filters = { ...filters, owner_username }; + delete filters.owner_id; + return filters; + } catch (error) { + this._logger.errorMessage(`Error adding owner username to filters: ${error?.message || error}`); + throw error; + } + } + + /** + * 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) { + try { + if (!id) { + throw new Error('Layout ID must be provided'); + } + const layoutFoundById = await this._layoutRepository.findById(id); + let layoutFoundByOldId = null; + if (!layoutFoundById) { + layoutFoundByOldId = await this._layoutRepository.findOne({ old_id: id }); + } + + if (!layoutFoundById && !layoutFoundByOldId) { + throw new NotFoundError(`Layout with id: ${id} was not found`); + } + return layoutFoundById || layoutFoundByOldId; + } catch (error) { + this._logger.errorMessage(`Error getting layout by ID: ${error?.message || error}`); + throw error; + } + } + + /** + * 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) { + try { + const layout = await this._layoutRepository.findOne({ name }); + if (!layout) { + throw new NotFoundError(`Layout with name: ${name} was not found`); + } + return layout; + } catch (error) { + this._logger.errorMessage(`Error getting layout by name: ${error?.message || error}`); + throw error; + } + } + + /** + * Gets a single object by its ID + * @param {*} objectId - 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(objectId) { + try { + const object = await this._gridTabCellRepository.findObjectByChartId(objectId); + if (!object) { + throw new NotFoundError(`Object with id: ${objectId} was not found`); + } + return object; + } 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) { + //TODO: Owner verification in the middleware. Plus addd ownerUsername to updateData + const transaction = await this._layoutRepository.model.sequelize.transaction(); + try { + const layout = await this.getLayoutById(id); + const normalizedLayout = await normalizeLayout(updateData, layout, true); + const updatedCount = await this._layoutRepository.updateLayout(id, normalizedLayout); + if (updatedCount === 0) { + throw new NotFoundError(`Layout with id ${id} not found`); + } + if (updateData.tabs) { + await this._tabSynchronizer.sync(id, updateData.tabs, transaction); + } + await transaction.commit(); + return id; + } catch (error) { + await transaction.rollback(); + 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} + * @throws {Error} If an error occurs updating the layout + */ + async patchLayout(id, updateData) { + const transaction = await this._layoutRepository.model.sequelize.transaction(); + try { + const normalizedLayout = await normalizeLayout(updateData); + const count = await this._updateLayout(id, normalizedLayout, transaction); + if (count === 0) { + throw new NotFoundError(`Layout with id ${id} not found`); + } + if (updateData.tabs) { + await this._tabSynchronizer.sync(id, updateData.tabs, transaction); + } + await transaction.commit(); + return id; + } catch (error) { + await transaction.rollback(); + this._logger.errorMessage(`Error in patchLayout: ${error.message || error}`); + throw error; + } + } + + /** + * 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 result = await this._layoutRepository.updateLayout(layoutId, updateData, { transaction }); + const updatedCount = Array.isArray(result) ? result[0] : result; + 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) { + try { + const deletedCount = await this._layoutRepository.delete(id); + if (deletedCount === 0) { + throw new NotFoundError(`Layout with id ${id} not found for deletion`); + } + } catch (error) { + this._logger.errorMessage(`Error in removeLayout: ${error.message || error}`); + throw error; + } + } + + /** + * 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 ownerUsername = await this._userService.getUsernameById(layoutData.owner_id); + layoutData.ownerUsername = ownerUsername; + const normalizedLayout = await normalizeLayout(layoutData, {}, true); + const newLayout = await this._layoutRepository.createLayout(normalizedLayout, { transaction }); + if (!newLayout) { + throw new Error('Failed to create new layout'); + } + await this._tabSynchronizer.sync(newLayout.id, layoutData.tabs, transaction); + await transaction.commit(); + return newLayout; + } catch (error) { + await transaction.rollback(); + this._logger.errorMessage(`Error in postLayout: ${error.message || error}`); + throw error; + } + } +} diff --git a/QualityControl/lib/services/layout/UserService.js b/QualityControl/lib/services/layout/UserService.js new file mode 100644 index 000000000..806cea091 --- /dev/null +++ b/QualityControl/lib/services/layout/UserService.js @@ -0,0 +1,108 @@ +/** + * @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, NotFoundError } from '@aliceo2/web-ui'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/user-svc`; + +/** + * @typedef {import('../../database/repositories/UserRepository.js').UserRepository} UserRepository + * @typedef {import('../../database/models/User.js').UserAttributes} UserAttributes + */ + +/** + * Class that handles the business logic for the users + */ +export class UserService { + /** + * Creates an instance of the UserService class + * @param {UserRepository} 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} userData - Data for the new user + * @param {string} userData.username - Username of the new user + * @param {string} userData.name - Name of the new user + * @param {number} userData.personid - Person ID of the new user + * @throws {InvalidInputError} If a user with the same unique fields already exists + * @returns {Promise} + */ + async saveUser(userData) { + const { username, name, personid } = userData; + try { + const existingUser = await this._userRepository.findOne({ + username, + name, + }); + + if (!existingUser) { + const userToCreate = { + id: personid, + username, + name, + }; + const createdUser = await this._userRepository.createUser(userToCreate); + if (!createdUser) { + throw new Error('Error creating user'); + } + } + } catch (error) { + 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.findById(id); + if (!user || !user.username) { + 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.findOne({ username }); + if (!user || !user.id) { + 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..ceb3ff330 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js @@ -0,0 +1,75 @@ +/** + * @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 { NotFoundError } from '@aliceo2/web-ui'; + +/** + * @typedef {import('../../../database/repositories/ChartOptionsRepository.js') + * .ChartOptionsRepository} ChartOptionsRepository + */ + +export class ChartOptionsSynchronizer { + /** + * Creates an instance of ChartOptionsSynchronizer. + * @param {ChartOptionsRepository} chartOptionRepository Chart options repository + * @param optionsRepository + */ + constructor(chartOptionRepository, optionsRepository) { + this._chartOptionRepository = chartOptionRepository; + this._optionsRepository = optionsRepository; + } + + /** + * 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; + + 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); + + const toDelete = existingOptionIds.filter((id) => !incomingOptionIds.includes(id)); + for (const optionId of toDelete) { + const deletedCount = await this._chartOptionRepository.delete({ chartId: chart.id, optionId }, { transaction }); + if (deletedCount === 0) { + throw new NotFoundError(`Not found chart option with chart=${chart.id} and option=${optionId} for deletion`); + } + } + + for (const option of incomingOptions) { + if (!existingOptionIds.includes(option.id)) { + const createdOption = await this._chartOptionRepository.create( + { chart_id: chart.id, option_id: option.id }, + { transaction }, + ); + if (!createdOption) { + throw new Error('Option creation returned null'); + } + } + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js new file mode 100644 index 000000000..9dc2204cb --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js @@ -0,0 +1,70 @@ +/** + * @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 { NotFoundError } from '@aliceo2/web-ui'; +import { mapObjectToChartAndCell } from './mapObjectToChartAndCell.js'; + +/** + * 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; + } + + /** + * 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) { + const existingCells = await this._gridTabCellRepository.findByTabId(tabId, { transaction }); + const existingChartIds = existingCells.map((cell) => cell.chart_id); + + const incomingChartIds = objects.filter((obj) => obj.id).map((obj) => obj.id); + const toDelete = incomingChartIds.length + ? existingChartIds.filter((id) => !incomingChartIds.includes(id)) + : existingChartIds; + + for (const chartId of toDelete) { + const deletedCount = await this._chartRepository.delete(chartId, { transaction }); + if (deletedCount === 0) { + throw new NotFoundError(`Not found chart with id=${chartId} for deletion`); + } + } + for (const object of objects) { + const { chart, cell } = mapObjectToChartAndCell(object, tabId); + let chartId = chart?.id; + if (existingChartIds.includes(chart.id)) { + const updatedRows = await this._chartRepository.update(chart.id, chart, { transaction }); + const updatedCells = + await this._gridTabCellRepository.update({ chartId: chart.id, tabId }, cell, { transaction }); + if (updatedRows === 0 || updatedCells === 0) { + throw new NotFoundError(`Chart or cell not found for update (chartId=${chart.id}, tabId=${tabId})`); + } + } else { + const createdChart = await this._chartRepository.create(chart, { transaction }); + chartId = createdChart.id; + const createdCell = await this._gridTabCellRepository.create({ ...cell, chart_id: chartId }, { transaction }); + if (!createdChart || !createdCell) { + throw new NotFoundError('Chart or cell not found for creation'); + } + } + await this._chartOptionsSynchronizer.sync({ ...chart, options: object?.options, id: chartId }, transaction); + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/layoutMapper.js b/QualityControl/lib/services/layout/helpers/layoutMapper.js new file mode 100644 index 000000000..2fb16e53b --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/layoutMapper.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. + */ + +/** + * Helper to normalize layout data + * @param {object} patch partial layout data + * @param {object} layout original layout + * @param {boolean} isFull if true, patch is a full layout + * @param {UserService} userService user service to get username from id + * @returns {Promise} normalized layout data + */ +export const normalizeLayout = async (patch, layout = {}, isFull = false) => { + const source = isFull ? { ...layout, ...patch } : patch; + + const fieldMap = { + name: 'name', + description: 'description', + displayTimestamp: 'display_timestamp', + autoTabChange: 'auto_tab_change_interval', + isOfficial: 'is_official', + ownerUsername: 'owner_username', + }; + + const data = Object.entries(fieldMap).reduce((acc, [frontendKey, backendKey]) => { + if (frontendKey in source) { + acc[backendKey] = source[frontendKey]; + } + return acc; + }, {}); + + return data; +}; 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/tabSynchronizer.js b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js new file mode 100644 index 000000000..f719c6448 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js @@ -0,0 +1,76 @@ +/** + * @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 { NotFoundError } 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. + */ + constructor(tabRepository, gridTabCellSynchronizer) { + this._tabRepository = tabRepository; + this._gridTabCellSynchronizer = gridTabCellSynchronizer; + } + + /** + * Synchronizes the tabs of a layout with the provided list of tabs. + * @param {string} layoutId - The ID of the layout whose tabs are to be synchronized. + * @param {Array} tabs - The list of tabs to synchronize. + * @param {object} transaction - The database transaction object. + */ + async sync(layoutId, tabs, transaction) { + const existingTabs = await this._tabRepository.findTabsByLayoutId(layoutId, { transaction }); + const existingTabsByName = Object.fromEntries(existingTabs.map((t) => [t.name, t])); + + for (const tab of tabs) { + tab.layout_id = layoutId; + + if (!tab.id && existingTabsByName[tab.name]) { + tab.id = existingTabsByName[tab.name].id; + } + } + + const incomingNames = tabs.map((t) => t.name); + const tabsToDelete = existingTabs.filter((t) => !incomingNames.includes(t.name)); + + for (const tab of tabsToDelete) { + const deletedCount = await this._tabRepository.delete(tab.id, { transaction }); + if (deletedCount === 0) { + throw new NotFoundError(`Tab with id=${tab.id} not found for deletion`); + } + } + + for (const tab of tabs) { + if (tab.id && existingTabsByName[tab.name]) { + 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 new tab'); + } + tab.id = tabRecord.id; + } + if (tab.objects && tab.objects.length) { + await this._gridTabCellSynchronizer.sync(tab.id, tab.objects, transaction); + } + } + } +} diff --git a/QualityControl/public/common/Types.js b/QualityControl/public/common/Types.js index 69b6f7107..fb27808f2 100644 --- a/QualityControl/public/common/Types.js +++ b/QualityControl/public/common/Types.js @@ -34,11 +34,13 @@ export function assertLayouts(array) { * @returns {boolean} true is correct */ export function assertLayout(obj) { - assertString(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertNumber(obj.owner_id); assertString(obj.owner_name); - assertArray(obj.tabs); + assertTabs(obj.tabs); return obj; } @@ -59,7 +61,9 @@ export function assertTabs(array) { * @returns {boolean} true is correct */ export function assertTab(obj) { - assertString(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertArray(obj.objects); return obj; @@ -71,7 +75,9 @@ export function assertTab(obj) { * @returns {boolean} true is correct */ export function assertTabObject(obj) { - assertString(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertArray(obj.options); assertNumber(obj.x); diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index 2e7fb292e..ce7a95f45 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -14,15 +14,6 @@ import { isUserRoleSufficient } from '../../../../library/userRole.enum.js'; -/** - * Generates a new ObjectId - * @returns {string} 16 random chars, base 16 - */ -export function objectId() { - const timestamp = (new Date().getTime() / 1000 | 0).toString(16); - return timestamp + 'xxxxxxxxxxxxxxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16)).toLowerCase(); -} - /** * Make a deep clone of object provided * @param {object} obj - to be cloned diff --git a/QualityControl/public/layout/Layout.js b/QualityControl/public/layout/Layout.js index 58740064f..3d4088d4a 100644 --- a/QualityControl/public/layout/Layout.js +++ b/QualityControl/public/layout/Layout.js @@ -16,7 +16,7 @@ import { RemoteData } from '/js/src/index.js'; import GridList from './Grid.js'; import LayoutUtils from './LayoutUtils.js'; -import { objectId, clone, setBrowserTabTitle } from '../common/utils.js'; +import { clone, setBrowserTabTitle } from '../common/utils.js'; import { assertTabObject, assertLayout } from '../common/Types.js'; import { buildQueryParametersString } from '../common/buildQueryParametersString.js'; import { BaseViewModel } from '../common/abstracts/BaseViewModel.js'; @@ -104,6 +104,9 @@ export default class Layout extends BaseViewModel { } else { const result = await this.model.services.layout.getLayoutById(layoutId); if (result.isSuccess()) { + //change params to have correct tab name in url + layoutId = result.payload.id; + this.model.router.params.layoutId = layoutId; this.item = assertLayout(result.payload); this.item.autoTabChange = this.item.autoTabChange || 0; let tabIndex = this.item.tabs @@ -196,7 +199,6 @@ export default class Layout extends BaseViewModel { this.model.notification.show('A new layout was not created due to invalid name', 'warning', 2000); } else { const layout = assertLayout({ - id: objectId(), name: layoutName, owner_id: this.model.session.personid, owner_name: this.model.session.name, @@ -205,7 +207,6 @@ export default class Layout extends BaseViewModel { autoTabChange: 0, tabs: [ { - id: objectId(), name: 'main', objects: [], }, @@ -217,9 +218,10 @@ export default class Layout extends BaseViewModel { this.model.notification.show(result.payload || 'Unable to create layout', 'danger', 2000); return; } + const { id } = result.payload; // Read the new layout created and edit it - this.model.router.go(`?page=layoutShow&layoutId=${layout.id}&edit=true`, false, false); + this.model.router.go(`?page=layoutShow&layoutId=${id}&edit=true`, false, false); // Update user list in background this.model.services.layout.getLayoutsByUserId(this.model.session.personid); @@ -256,6 +258,7 @@ export default class Layout extends BaseViewModel { } const result = await this.model.services.layout.saveLayout(this.item); if (result.isSuccess()) { + await this.loadItem(this.item.id, this.tab.name); await this.model.services.layout.getLayoutsByUserId(this.model.session.personid); this.model.notification.show(`Layout "${this.item.name}" has been saved successfully.`, 'success'); } else { @@ -391,7 +394,6 @@ export default class Layout extends BaseViewModel { } this.item.tabs.push({ - id: objectId(), name: name, objects: [], }); @@ -454,7 +456,6 @@ export default class Layout extends BaseViewModel { */ addItem(objectName) { const newTabObject = assertTabObject({ - id: objectId(), x: 0, y: 100, // Place it at the end first h: 1, @@ -600,7 +601,6 @@ export default class Layout extends BaseViewModel { itemToDuplicate.tabs.forEach((tab) => { const duplicatedTab = { - id: objectId(), name: tab.name, objects: clone(tab.objects), columns: tab.columns, @@ -610,7 +610,6 @@ export default class Layout extends BaseViewModel { // Create new duplicated layout const layout = assertLayout({ - id: objectId(), name: layoutName, owner_id: this.model.session.personid, owner_name: this.model.session.name, @@ -726,7 +725,6 @@ export default class Layout extends BaseViewModel { this.item = { ...updatedLayout, - id: this.item.id, }; this.save(); diff --git a/QualityControl/public/layout/LayoutClass.js b/QualityControl/public/layout/LayoutClass.js index 018260177..a0d54d05b 100644 --- a/QualityControl/public/layout/LayoutClass.js +++ b/QualityControl/public/layout/LayoutClass.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { objectId, clone } from '../common/utils.js'; +import { clone } from '../common/utils.js'; /** * Class with a layout type @@ -33,29 +33,6 @@ export class LayoutClass { this.autoTabChange = 0; } - /** - * Given a layout skeleton, parse its structure and add ids to the layout, tabs and objects - * Return a format expected to be accepted by the API - create layout route - * @param {JSON} skeleton - layout as given by the user - * @returns {JSON} newly validated layout - */ - static fromSkeleton(skeleton) { - const layout = clone(skeleton); - layout.id = objectId(); - if (layout.tabs) { - layout.tabs.map((tab) => { - tab.id = objectId(); - if (tab.objects) { - tab.objects.map((object) => { - object.id = objectId(); - }); - } - return tab; - }); - } - return layout; - } - /** * Given a layout, send back a stringified version of it stripped of IDs * @param {JSON} layout - layout dto representation diff --git a/QualityControl/public/layout/LayoutUtils.js b/QualityControl/public/layout/LayoutUtils.js index 597c60767..71884d73e 100644 --- a/QualityControl/public/layout/LayoutUtils.js +++ b/QualityControl/public/layout/LayoutUtils.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { objectId, clone } from '../common/utils.js'; +import { clone } from '../common/utils.js'; /** * Class with utility functions for Layouts @@ -33,19 +33,6 @@ export default class LayoutUtils { static fromSkeleton(skeleton) { const layout = clone(skeleton); delete layout.isOfficial; - - layout.id = objectId(); - if (layout.tabs) { - layout.tabs.map((tab) => { - tab.id = objectId(); - if (tab.objects) { - tab.objects.map((object) => { - object.id = objectId(); - }); - } - return tab; - }); - } return layout; } diff --git a/QualityControl/test/api/layouts/api-get-layout.test.js b/QualityControl/test/api/layouts/api-get-layout.test.js index 068b338bb..49bfce1ca 100644 --- a/QualityControl/test/api/layouts/api-get-layout.test.js +++ b/QualityControl/test/api/layouts/api-get-layout.test.js @@ -16,7 +16,14 @@ 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 { + MOCK_GET_LAYOUT_1, + MOCK_GET_LAYOUT_A_TEST, + MOCK_GET_LAYOUT_RUN_DEF, + MOCK_GET_LAYOUTS_ALL, + MOCK_GET_LAYOUTS_BY_OWNER_ID, + MOCK_GET_ONLY_NAME_AND_OWNER_ID, +} from '../../demoData/layout/layout.mock.js'; export const apiGetLayoutsTests = () => { suite('GET /layouts', () => { @@ -31,6 +38,7 @@ export const apiGetLayoutsTests = () => { if (res.body.length < 2) { throw new Error(`Expected at least 3 layouts ${res.body.length}`); } + deepStrictEqual(res.body, MOCK_GET_LAYOUTS_ALL); }); }); @@ -43,8 +51,7 @@ export const apiGetLayoutsTests = () => { 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'); + deepStrictEqual(res.body, MOCK_GET_LAYOUTS_BY_OWNER_ID(ownerId)); }); }); @@ -57,13 +64,7 @@ export const apiGetLayoutsTests = () => { if (!Array.isArray(res.body)) { throw new Error('Expected array of layouts'); } - res.body.forEach((layout) => { - const hasName = Object.prototype.hasOwnProperty.call(layout, 'name'); - const hasOwnerId = Object.prototype.hasOwnProperty.call(layout, 'owner_id'); - if (Object.keys(layout).length !== 2 || !hasName || !hasOwnerId) { - throw new Error(`Expected only name and owner_id fields but instead got: ${Object.keys(layout)}`); - } - }); + deepStrictEqual(res.body, MOCK_GET_ONLY_NAME_AND_OWNER_ID); }); }); @@ -78,25 +79,31 @@ export const apiGetLayoutsTests = () => { }); suite('GET /layout/:id', () => { - test('should return a single layout by id', async () => { + test('should return a single layout by the old id', async () => { const layoutId = '671b8c22402408122e2f20dd'; 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')); + .expect((res) => { + deepStrictEqual(res.body, MOCK_GET_LAYOUT_1, 'Unexpected Layout structure was returned'); + }); }); - test('should return 400 when id parameter is an empty string', async () => { - await request(`${URL_ADDRESS}/api/layout/ `) + test('should return a single layout by id', async () => { + const layoutId = 1; + await request(`${URL_ADDRESS}/api/layout/${layoutId}`) .get(`?token=${OWNER_TEST_TOKEN}`) - .expect(400, { message: 'Missing parameter "id" of layout', status: 400, title: 'Invalid Input' }); + .expect(200) + .expect((res) => { + deepStrictEqual(res.body, MOCK_GET_LAYOUT_1, '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: nonexistent123 was not found', status: 404, title: 'Not Found' }); }); }); @@ -106,7 +113,7 @@ 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, MOCK_GET_LAYOUT_A_TEST, 'Unexpected Layout structure was returned')); }); test('should return layout by runDefinition', async () => { @@ -114,7 +121,7 @@ export const apiGetLayoutsTests = () => { 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, MOCK_GET_LAYOUT_A_TEST, 'Unexpected Layout structure was returned')); }); test('should return layout by runDefinition and pdpBeamType combination', async () => { const runDefinition = 'rundefinition'; @@ -123,11 +130,7 @@ export const apiGetLayoutsTests = () => { .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', - ); + deepStrictEqual(res.body, MOCK_GET_LAYOUT_RUN_DEF, 'Unexpected Layout structure was returned'); }); }); @@ -141,7 +144,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..ebd1c270f 100644 --- a/QualityControl/test/api/layouts/api-patch-layout.test.js +++ b/QualityControl/test/api/layouts/api-patch-layout.test.js @@ -20,25 +20,16 @@ export const apiPatchLayoutTests = () => { suite('PATCH /layout/:id', () => { test('should return a 404 error if the id of the layout is not provided', async () => { await request(`${URL_ADDRESS}/api/layout/`) - .patch(`?token=${OWNER_TEST_TOKEN}`) + .patch(`?token=${GLOBAL_TEST_TOKEN}`) .expect(404, { error: '404 - Page not found', message: 'The requested URL was not found on this server.', }); }); - test('should return a 404 error if the id of the layout does not exist', async () => { - await request(`${URL_ADDRESS}/api/layout/test`) - .patch(`?token=${OWNER_TEST_TOKEN}`) - .expect(404, { - message: 'layout (test) not found', - status: 404, - title: 'Not Found', - }); - }); - - test('should return a 403 error if role is not enough to update the layout', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + //if not enough permissions + test('should return a 403 error if the requestor is not allowed to edit', async () => { + await request(`${URL_ADDRESS}/api/layout/1`) .patch(`?token=${OWNER_TEST_TOKEN}`) .expect(403, { message: 'Not enough permissions for this operation', @@ -47,27 +38,56 @@ export const apiPatchLayoutTests = () => { }); }); - test('should return a 400 error for invalid body', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + //if body is not provided + test('should return a 400 error if the body is not provided', async () => { + await request(`${URL_ADDRESS}/api/layout/1`) + .patch(`?token=${GLOBAL_TEST_TOKEN}`) + .expect(400, { + message: 'No layout data provided in the request body', + status: 400, + title: 'Invalid Input', + }); + }); + + test('should return a 400 error if invalid body', async () => { + await request(`${URL_ADDRESS}/api/layout/1`) .patch(`?token=${GLOBAL_TEST_TOKEN}`) .send({ test: 'test', }) .expect(400, { - message: 'Failed to validate layout: "test" is not allowed', + message: 'Failed to validate layout patch: "test" is not allowed', status: 400, title: 'Invalid Input', }); }); - test('should successfully update the layout', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + //if the id does not exist + test('should return a 404 error if the id of the layout does not exist', async () => { + await request(`${URL_ADDRESS}/api/layout/9999`) + .patch(`?token=${GLOBAL_TEST_TOKEN}`) + .send({ + isOfficial: true, + }) + .expect(404, { + message: 'Layout with id 9999 not found', + status: 404, + title: 'Not Found', + }); + }); + + //200 response + test('should return a 200 response with the id of the updated layout', async () => { + await request(`${URL_ADDRESS}/api/layout/2`) .patch(`?token=${GLOBAL_TEST_TOKEN}`) .send({ - isOfficial: false, + isOfficial: true, }) - .expect(201, { - id: '671b8c22402408122e2f20dd', + .expect(200) + .then((response) => { + if (!response.body.id || response.body.id !== 2) { + throw new Error('Response does not contain the correct layout id'); + } }); }); }); diff --git a/QualityControl/test/api/layouts/api-put-layout.test.js b/QualityControl/test/api/layouts/api-put-layout.test.js index 96dc333b5..9ad57ccb0 100644 --- a/QualityControl/test/api/layouts/api-put-layout.test.js +++ b/QualityControl/test/api/layouts/api-put-layout.test.js @@ -15,22 +15,24 @@ 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 { MOCK_UPDATED_LAYOUT } from '../../demoData/layout/layout.mock.js'; export const apiPutLayoutTests = () => { suite('PUT /layout/:id', () => { + const layoutToUpdate = MOCK_UPDATED_LAYOUT; + delete layoutToUpdate.isOfficial; test('should return a 404 error if the id of the layout does not exist', async () => { await request(`${URL_ADDRESS}/api/layout/test`) .put(`?token=${OWNER_TEST_TOKEN}`) .expect(404, { - message: 'layout (test) not found', + message: 'Layout with id: test 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`) + await request(`${URL_ADDRESS}/api/layout/1`) .put(`?token=${USER_TEST_TOKEN}`) .expect(403, { message: 'Only the owner of the layout can delete it', @@ -40,32 +42,45 @@ export const apiPutLayoutTests = () => { }); test('should return a 400 error if the body is not provided', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + await request(`${URL_ADDRESS}/api/layout/2`) .put(`?token=${OWNER_TEST_TOKEN}`) .expect(400, { - message: 'Failed to update layout: "id" is required', + message: 'No layout data provided in the request body', + status: 400, + title: 'Invalid Input', + }); + }); + + //if the id in the path and in the body do not match + test('should return a 400 error if the id in the path and in the body do not match', async () => { + const layoutWithDifferentId = { ...layoutToUpdate, id: 'different-id' }; + await request(`${URL_ADDRESS}/api/layout/1`) + .put(`?token=${OWNER_TEST_TOKEN}`) + .send(layoutWithDifferentId) + .expect(400, { + message: 'Layout ID in the path does not match ID in the body', 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`) + await request(`${URL_ADDRESS}/api/layout/1`) .put(`?token=${OWNER_TEST_TOKEN}`) - .send(LAYOUT_MOCK_3) + .send({ ...layoutToUpdate, name: 'rundefinition_pdpBeamType' }) .expect(400, { - message: 'Proposed layout name: a-test already exists', + message: 'A layout with the same name already exists.', status: 400, title: 'Invalid Input', }); }); test('should update the layout successfully', async () => { - await request(`${URL_ADDRESS}/api/layout/671b8c22402408122e2f20dd`) + await request(`${URL_ADDRESS}/api/layout/1`) .put(`?token=${OWNER_TEST_TOKEN}`) - .send(LAYOUT_MOCK_2) - .expect(201, { - id: '671b8c22402408122e2f20dd', + .send(layoutToUpdate) + .expect(200, { + id: layoutToUpdate.id, }); }); }); diff --git a/QualityControl/test/api/objects/api-get-object.test.js b/QualityControl/test/api/objects/api-get-object.test.js index 92ea7a2fa..a6b5e5567 100644 --- a/QualityControl/test/api/objects/api-get-object.test.js +++ b/QualityControl/test/api/objects/api-get-object.test.js @@ -77,15 +77,17 @@ export const apiGetObjectsTests = () => { }); suite('GET /object/:id', () => { - const objectId = '6724a6bd1b2bad3d713cc4ee'; + const objectId = 6; test('should return QCObject details with all versions', async () => { const url = `${URL_ADDRESS}/api/object/${objectId}?token=${OWNER_TEST_TOKEN}`; + console.log('URL1:', url); await testResult(url, 200, MOCK_OBJECT_BY_ID_RESULT, OBJECT_VERSIONS); }); test('should return QCObject versions if a filter is added', async () => { const url = `${URL_ADDRESS}/api/object/${objectId}?token=${OWNER_TEST_TOKEN}&filters[RunNumber]=0`; + console.log('URL2:', url); await testResult(url, 200, MOCK_OBJECT_BY_ID_RESULT, OBJECT_VERSIONS_FILTERED_BY_RUN_NUMBER); }); diff --git a/QualityControl/test/config.js b/QualityControl/test/config.js index 201bf9f54..5b79d49ea 100644 --- a/QualityControl/test/config.js +++ b/QualityControl/test/config.js @@ -56,4 +56,18 @@ 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..89f46e919 100644 --- a/QualityControl/test/demoData/layout/layout.mock.js +++ b/QualityControl/test/demoData/layout/layout.mock.js @@ -269,3 +269,292 @@ export const LAYOUT_MOCK_6 = { ], collaborators: [], }; + +// Mocks for LayoutController tests +export const LAYOUT_CONTROLLER_MOCK_1 = { + id: 10001, + name: 'Test Layout 1', + owner: { id: 123, name: 'Owner 1' }, + tabs: [{ id: 1, name: 'Tab 1', gridTabCells: [] }], + is_official: true, +}; + +export const LAYOUT_CONTROLLER_MOCK_2 = { + id: 10002, + name: 'Test Layout 2', + owner: { id: 123, name: 'Owner 1' }, + tabs: [{ id: 1, name: 'Tab 1', gridTabCells: [] }], + is_official: true, +}; + +//Mocks for mapLayoutToAPI tests +export const RAW_LAYOUT_MOCK = { + id: 10003, + name: 'Raw Layout', + owner: { id: 456, name: 'Owner 2' }, + description: 'A raw layout for testing', + display_timestamp: true, + auto_tab_change_interval: 30, + tabs: [ + { + id: 1, + name: 'Tab 1', + column_count: 3, + gridTabCells: [ + { + row: 0, + col: 0, + row_span: 1, + col_span: 1, + chart: { + id: 2001, + object_name: 'Chart 1', + chartOptions: [{ option: { name: 'Option A' } }, { option: { name: 'Option B' } }], + ignore_defaults: false, + }, + }, + { + row: 0, + col: 1, + row_span: 2, + col_span: 2, + chart: { + id: 2002, + object_name: 'Chart 2', + chartOptions: [{ option: { name: 'Option C' } }], + ignore_defaults: true, + }, + }, + ], + }, + ], + is_official: false, +}; + +export const API_ADAPTED_LAYOUT_MOCK = { + id: 10003, + name: 'Raw Layout', + owner_id: 456, + owner_name: 'Owner 2', + description: 'A raw layout for testing', + displayTimestamp: true, + autoTabChange: 30, + tabs: [ + { + id: 1, + name: 'Tab 1', + columns: 3, + objects: [ + { + id: 2001, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'Chart 1', + options: ['Option A', 'Option B'], + autoSize: false, + ignoreDefaults: false, + }, + { + id: 2002, + x: 1, + y: 0, + h: 2, + w: 2, + name: 'Chart 2', + options: ['Option C'], + autoSize: false, + ignoreDefaults: true, + }, + ], + }, + ], + isOfficial: false, + collaborators: [], +}; +export const MOCK_GET_LAYOUTS_ALL = [ + { + id: 1, + name: 'test', + owner_id: 0, + owner_name: 'Anonymous', + description: '', + displayTimestamp: false, + autoTabChange: 0, + tabs: [ + { + id: 1, + name: 'main', + columns: 2, + objects: [ + { + id: 1, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'qc/TPC/QO/CheckOfTrack_Trending', + options: [ + 'lego', + 'colz', + ], + autoSize: false, + ignoreDefaults: false, + }, + { + id: 2, + x: 1, + y: 0, + h: 1, + w: 1, + name: 'qc/MCH/QO/DataDecodingCheck', + options: ['lego'], + autoSize: false, + ignoreDefaults: false, + }, + { + id: 3, + x: 2, + y: 0, + h: 1, + w: 1, + name: 'qc/MCH/QO/MFTRefCheck', + options: ['lcolz'], + autoSize: false, + ignoreDefaults: false, + }, + { + id: 4, + x: 0, + y: 1, + h: 1, + w: 1, + name: 'qc/MCH/MO/Pedestals/ST5/DE1006/BadChannels_XY_B_1006', + options: ['text'], + autoSize: false, + ignoreDefaults: false, + }, + ], + }, + { + id: 2, + name: 'test-tab', + columns: 3, + objects: [ + { + id: 5, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'qc/MCH/MO/Pedestals/BadChannelsPerDE', + options: [ + 'logx', + 'logy', + ], + autoSize: false, + ignoreDefaults: false, + }, + ], + }, + ], + isOfficial: false, + collaborators: [], + }, + { + id: 2, + name: 'a-test', + owner_id: 0, + owner_name: 'Anonymous', + description: '', + displayTimestamp: false, + autoTabChange: 0, + tabs: [ + { + id: 3, + name: 'main', + columns: 2, + objects: [ + { + id: 6, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'qc/test/object/1', + options: ['logz'], + autoSize: false, + ignoreDefaults: false, + }, + ], + }, + { + id: 4, + name: 'a', + columns: 2, + objects: [], + }, + ], + isOfficial: false, + collaborators: [], + }, + { + id: 3, + name: 'rundefinition_pdpBeamType', + owner_id: 99, + owner_name: 'Some other owner', + description: '', + displayTimestamp: false, + autoTabChange: 0, + tabs: [ + { + id: 5, + name: 'main', + columns: 2, + objects: [ + { + id: 7, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'qc/test/object/1', + options: [], + autoSize: false, + ignoreDefaults: false, + }, + { + id: 8, + x: 0, + y: 0, + h: 1, + w: 1, + name: 'qc/test/object/1', + options: [], + autoSize: false, + ignoreDefaults: false, + }, + ], + }, + { + id: 6, + name: 'a', + columns: 2, + objects: [], + }, + ], + isOfficial: false, + collaborators: [], + }, +]; +export const MOCK_GET_LAYOUTS_BY_OWNER_ID = (ownerId) => + MOCK_GET_LAYOUTS_ALL.filter((layout) => layout.owner_id === ownerId); + +export const MOCK_GET_ONLY_NAME_AND_OWNER_ID = MOCK_GET_LAYOUTS_ALL.map((layout) => ({ + name: layout.name, + owner_id: layout.owner_id, +})); + +export const [MOCK_GET_LAYOUT_1, MOCK_GET_LAYOUT_A_TEST, MOCK_GET_LAYOUT_RUN_DEF] = MOCK_GET_LAYOUTS_ALL; +export const MOCK_UPDATED_LAYOUT = { ...MOCK_GET_LAYOUTS_ALL[0], name: 'Updated Layout Name' }; diff --git a/QualityControl/test/lib/controllers/LayoutController.test.js b/QualityControl/test/lib/controllers/LayoutController.test.js index f6411ce91..9fa8ccaf3 100644 --- a/QualityControl/test/lib/controllers/LayoutController.test.js +++ b/QualityControl/test/lib/controllers/LayoutController.test.js @@ -12,737 +12,294 @@ * or submit itself to any jurisdiction. */ -import { ok, throws, doesNotThrow, AssertionError } from 'node:assert'; +import { ok } 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_CONTROLLER_MOCK_1, LAYOUT_CONTROLLER_MOCK_2 } 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({})); - }); + let req = {}; + let res = {}; + let layoutServiceMock = {}; + let layoutController = null; + beforeEach(() => { + req = { + query: { + token: 'validtoken', + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + layoutServiceMock = { + getLayoutsByFilters: sinon.stub(), + getLayoutById: sinon.stub(), + getLayoutByName: sinon.stub(), + putLayout: sinon.stub(), + removeLayout: sinon.stub(), + postLayout: sinon.stub(), + patchLayout: sinon.stub(), + }; + layoutController = new LayoutController(layoutServiceMock); }); + suite('Layout controller - constructor', () => { + test('should throw an error if layout service is not provided', () => { }); + }); + suite('getLayoutsHandler', () => { + test('should throw invalid input error if Joi validation fails', async () => { + req.query.fields = 'invalid_field'; + await layoutController.getLayoutsHandler(req, res); - 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.status.calledWith(400)); ok(res.json.calledWith({ - message: 'Invalid query parameters: "Object path" must be a string', + message: 'Invalid query parameters: "fields" contains invalid field: invalid_field', 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 call getLayoutsByFilters from layoutService with correct parameters', async () => { + req.query.fields = 'id,name'; + req.query.owner_id = 123; - 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 mockLayouts = [ + LAYOUT_CONTROLLER_MOCK_1, + LAYOUT_CONTROLLER_MOCK_2, ]; - 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); + layoutServiceMock.getLayoutsByFilters.resolves(mockLayouts); - ok(res.json.calledOnce, 'Response was not sent'); - ok(res.json.calledWith(response), 'A list of layouts should have been sent back'); + await layoutController.getLayoutsHandler(req, res); + ok(res.status.calledWith(200)); + ok(res.json.calledWith([ + { id: 10001, name: 'Test Layout 1' }, + { id: 10002, name: 'Test Layout 2' }, + ])); }); - 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' }, - ]; + test('should throw if layoutService.getLayoutsByFilters throws', async () => { + delete req.query.owner_id; - const jsonStub = sinon.createStubInstance(LayoutRepository, { - listLayouts: sinon.stub().resolves(response), - }); - const req = { - query: { - filter: {}, - token: 'fasdfsdfa', - }, - }; - const layoutConnector = new LayoutController(jsonStub); + layoutServiceMock.getLayoutsByFilters.rejects(new Error()); - await layoutConnector.getLayoutsHandler(req, res); + await layoutController.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'); + ok(res.status.calledWith(500)); + ok(res.json.calledWith({ + message: 'Unable to retrieve layouts', + status: 500, + title: 'Unknown Error', + })); }); + }); - 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); + suite('getLayoutHandler', () => { + test('should call getLayoutById from layoutService with correct parameters', async () => { + req.params = { id: 10001 }; - await layoutConnector.getLayoutsHandler(req, res); + const mockLayout = LAYOUT_CONTROLLER_MOCK_1; - 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_\\-/]+$/'; + layoutServiceMock.getLayoutById.resolves(mockLayout); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.status.calledWith(400), 'Response status was not 400'); + await layoutController.getLayoutHandler(req, res); + ok(res.status.calledWith(200)); ok(res.json.calledWith({ - message: message, - status: 400, - title: 'Invalid Input', - }), 'Error message is not as expected'); + id: 10001, + name: 'Test Layout 1', + owner_id: 123, + owner_name: 'Owner 1', + description: undefined, + displayTimestamp: undefined, + autoTabChange: undefined, + tabs: [{ id: 1, name: 'Tab 1', columns: 2, objects: [] }], + isOfficial: true, + collaborators: [], + })); }); + test('should throw if layoutService.getLayoutById throws', async () => { + req.params = { id: 99999 }; - 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); + layoutServiceMock.getLayoutById.rejects(new Error('Server error')); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledOnce, 'Response was not sent'); + await layoutController.getLayoutHandler(req, res); + ok(res.status.calledWith(500)); ok(res.json.calledWith({ - message: 'Invalid query parameters: "fields" contains invalid field: invalid_field', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); + message: 'Server error', + status: 500, + title: 'Unknown Error', + })); }); }); - 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'); + suite('getLayoutByNameHandler', () => { + test('should set the proper name to the layout', async () => { + req.query = { name: 'Test Layout' }; + await layoutController.getLayoutByNameHandler(req, res); + ok(layoutServiceMock.getLayoutByName.calledWith('Test Layout')); + sinon.resetHistory(); + req.query = { runDefinition: 'RunDef', pdpBeamType: 'BeamType' }; + await layoutController.getLayoutByNameHandler(req, res); + ok(layoutServiceMock.getLayoutByName.calledWith('RunDef_BeamType')); + sinon.resetHistory(); + req.query = { runDefinition: 'RunDef' }; + await layoutController.getLayoutByNameHandler(req, res); + ok(layoutServiceMock.getLayoutByName.calledWith('RunDef')); + }); + test('should return 200 and layout data when layoutService.getLayoutByName resolves', async () => { + const mockLayout = LAYOUT_CONTROLLER_MOCK_1; + layoutServiceMock.getLayoutByName.resolves(mockLayout); + req.query = { name: 'Test Layout' }; + await layoutController.getLayoutByNameHandler(req, res); + ok(res.status.calledWith(200)); 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'); + id: 10001, + name: 'Test Layout 1', + owner_id: 123, + owner_name: 'Owner 1', + description: undefined, + displayTimestamp: undefined, + autoTabChange: undefined, + tabs: [{ id: 1, name: 'Tab 1', columns: 2, objects: [] }], + isOfficial: true, + collaborators: [], + })); }); - - 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'); + test('should return error when layoutService.getLayoutByName rejects', async () => { + layoutServiceMock.getLayoutByName.rejects(new Error('Server error')); + req.query = { name: 'Test Layout' }; + await layoutController.getLayoutByNameHandler(req, res); + ok(res.status.calledWith(500)); ok(res.json.calledWith({ - message: 'Unable to read layout', + message: 'Server error', 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', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), + suite('putLayoutHandler', () => { + test('should throw invalid input error if Joi validation fails', async () => { + req.params = { id: 10001 }; + req.body = { + id: 10001, + name: 'Updated Layout', + tabs: 'invalid_tabs_format', }; - }); - - 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); + await layoutController.putLayoutHandler(req, res); - ok(res.status.calledWith(400), 'Response status was not 400'); + ok(res.status.calledWith(400)); ok(res.json.calledWith({ - message: 'Missing query parameters', + message: 'Failed to validate layout: "tabs" must be an array', 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(), - }; + })); }); - - 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, + test('should return updated layout ID when layoutService.putLayout resolves', async () => { + req.params = { id: 10001 }; + req.body = { + id: 10001, + name: 'Updated Layout', + tabs: [{ id: 1, name: 'Tab 1' }], + owner_id: 123, + owner_name: 'Owner 1', }; - const jsonStub = sinon.createStubInstance(LayoutRepository, { - updateLayout: sinon.stub().resolves(expectedMockWithDefaults.id), - listLayouts: sinon.stub().resolves([]), - readLayoutById: sinon.stub().resolves(LAYOUT_MOCK_1), - }); - 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), - }); - const layoutConnector = new LayoutController(jsonStub); + layoutServiceMock.putLayout.resolves(10001); + await layoutController.putLayoutHandler(req, res); - 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')), - }); - 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', - ); + ok(res.status.calledWith(200)); + ok(res.json.calledWith({ id: 10001 })); }); }); - - suite('`deleteLayoutHandler()` tests', () => { - let res = {}; - beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), + suite('postLayoutHandler', () => { + test('should throw invalid input error if Joi validation fails', async () => { + req.body = { + name: 'New Layout', + tabs: 'invalid_tabs_format', }; - }); - 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' }), - }); - 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'); - }); + await layoutController.postLayoutHandler(req, res); - 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')), - }); - 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.status.calledWith(400)); 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', + message: 'Failed to validate layout: "tabs" must be an array', status: 400, title: 'Invalid Input', - }), 'Error message was incorrect'); + })); }); + test('should return new layout ID when layoutService.postLayout resolves', async () => { + req.body = { + name: 'New Layout', + tabs: [{ id: 1, name: 'Tab 1' }], + owner_id: 123, + owner_name: 'Owner 1', + }; - 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); - ok(res.status.calledWith(400), 'Response status was not 400'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "name" is required', - 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'); - }); + layoutServiceMock.postLayout.resolves({ id: 10003 }); + await layoutController.postLayoutHandler(req, res); - 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'); + ok(res.status.calledWith(201)); + ok(res.json.calledWith({ id: 10003 })); }); + }); + suite('deleteLayoutHandler', () => { + test('should return result when layoutService.removeLayout resolves', async () => { + req.params = { id: 10001 }; - 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'); - }); + layoutServiceMock.removeLayout.resolves(); - 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'); - ok(res.json.calledWith({ - message: 'Failed to validate layout: "owner_name" is required', - status: 400, - title: 'Invalid Input', - }), 'Error message was incorrect'); - }, - ); + await layoutController.deleteLayoutHandler(req, res); - 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'); + ok(res.status.calledWith(200)); + ok(res.json.calledWith({ id: 10001 })); }); + test('should throw if layoutService.removeLayout throws', async () => { + req.params = { id: 99999 }; - 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([]), - }); - 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'); - }); + layoutServiceMock.removeLayout.rejects(new Error('Server error')); + await layoutController.deleteLayoutHandler(req, res); - 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([]), - }); - 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.status.calledWith(500)); ok(res.json.calledWith({ - message: 'Unable to create new layout', + message: 'Server error', 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(), + suite('patchLayoutHandler', () => { + // change isOfficial to true + test('should throw invalid input error if Joi validation fails', async () => { + req.params = { id: 10001 }; + req.body = { + isOfficial: 'not_a_boolean', }; - }); - - 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), - }); - 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({}); + await layoutController.patchLayoutHandler(req, res); - 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.status.calledWith(400)); ok(res.json.calledWith({ - message: 'Failed to validate layout: "missing" is not allowed', + message: 'Failed to validate layout patch: "isOfficial" must be a boolean', status: 400, title: 'Invalid Input', })); }); + test('should return updated layout ID when layoutService.patchLayout resolves', async () => { + req.params = { id: 10001 }; + req.body = { + isOfficial: true, + }; - 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')), - }); - const layoutConnector = new LayoutController(jsonStub); - - const req = { params: { id: 'mylayout' }, session: { personid: 1 }, body: { isOfficial: true } }; - await layoutConnector.patchLayoutHandler(req, res); + layoutServiceMock.patchLayout.resolves(10001); + await layoutController.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', - ); + ok(res.status.calledWith(200)); + ok(res.json.calledWith({ id: 10001 })); }); }); }; diff --git a/QualityControl/test/lib/controllers/UserController.test.js b/QualityControl/test/lib/controllers/UserController.test.js index f223726f0..485eafee4 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 = { + saveUser: sinon.stub().resolves(), }; - userController = new UserController(userRepositoryMock); + userController = new UserController(userServiceMock); reqMock = { session: { personid: 123, @@ -49,9 +49,9 @@ export const userControllerTestSuite = async () => { test('should add a user successfully', async () => { await userController.addUserHandler(reqMock, resMock); - ok(userRepositoryMock.createUser.calledOnce); - ok(userRepositoryMock.createUser.calledWith({ - id: 123, + ok(userServiceMock.saveUser.calledOnce); + ok(userServiceMock.saveUser.calledWith({ + personid: 123, name: 'Test User', username: 'testuser', })); @@ -61,7 +61,7 @@ export const userControllerTestSuite = async () => { test('should handle errors during user creation', async () => { const error = new Error('User creation failed'); - userRepositoryMock.createUser.rejects(error); + userServiceMock.saveUser.rejects(error); await userController.addUserHandler(reqMock, resMock); @@ -77,7 +77,7 @@ export const userControllerTestSuite = async () => { await userController.addUserHandler(reqMock, resMock); - ok(userRepositoryMock.createUser.notCalled); + ok(userServiceMock.saveUser.notCalled); ok(resMock.status.calledWith(502)); ok(resMock.json.calledWith({ ok: false, @@ -90,7 +90,7 @@ export const userControllerTestSuite = async () => { await userController.addUserHandler(reqMock, resMock); - ok(userRepositoryMock.createUser.notCalled); + ok(userServiceMock.saveUser.notCalled); ok(resMock.status.calledWith(502)); ok(resMock.json.calledWith({ ok: false, @@ -103,7 +103,7 @@ export const userControllerTestSuite = async () => { await userController.addUserHandler(reqMock, resMock); - ok(userRepositoryMock.createUser.notCalled); + ok(userServiceMock.saveUser.notCalled); ok(resMock.status.calledWith(502)); ok(resMock.json.calledWith({ ok: false, @@ -116,7 +116,7 @@ export const userControllerTestSuite = async () => { await userController.addUserHandler(reqMock, resMock); - ok(userRepositoryMock.createUser.notCalled); + ok(userServiceMock.saveUser.notCalled); ok(resMock.status.calledWith(502)); ok(resMock.json.calledWith({ ok: false, diff --git a/QualityControl/test/lib/controllers/helpers/mapLayoutToAPI.test.js b/QualityControl/test/lib/controllers/helpers/mapLayoutToAPI.test.js new file mode 100644 index 000000000..9f17fca06 --- /dev/null +++ b/QualityControl/test/lib/controllers/helpers/mapLayoutToAPI.test.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 { ok } from 'node:assert'; +import { suite, test } from 'node:test'; +import { API_ADAPTED_LAYOUT_MOCK, RAW_LAYOUT_MOCK } from '../../../demoData/layout/layout.mock.js'; +import { mapLayoutToAPI } from '../../../../lib/controllers/helpers/mapLayoutToAPI.js'; + +export const mapLayoutToAPITestSuite = async () => { + suite('mapLayoutToAPI', () => { + test('should map backend layout to API format correctly', () => { + const backendLayout = RAW_LAYOUT_MOCK; + const adaptedLayout = API_ADAPTED_LAYOUT_MOCK; + + const result = mapLayoutToAPI(backendLayout); + ok(JSON.stringify(result) === JSON.stringify(adaptedLayout)); + }); + test('should filter fields when fields parameter is provided', () => { + const backendLayout = RAW_LAYOUT_MOCK; + const fields = ['id', 'name', 'owner_id']; + + const result = mapLayoutToAPI(backendLayout, fields); + const expected = { + id: backendLayout.id, + name: backendLayout.name, + owner_id: backendLayout.owner.id, + }; + + ok(JSON.stringify(result) === JSON.stringify(expected)); + }); + }); +}; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js deleted file mode 100644 index 02d8a69b1..000000000 --- a/QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js +++ /dev/null @@ -1,85 +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 { layoutIdMiddleware } from '../../../../lib/middleware/layouts/layoutId.middleware.js'; -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', () => { - const req = { - params: { - id: null, - }, - }; - const res = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - 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'); - ok(res.json.calledWith({ - message: 'The "id" parameter is missing from the request', - status: 400, - title: 'Invalid Input', - })); - }); - - 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')), - }); - const req = { - params: { - id: 'nonExistingId', - }, - }; - 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)); - ok(res.json.calledWith({ - message: 'Layout 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'); - }); - }); -}; diff --git a/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js b/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js index c5f22002a..1a995b67d 100644 --- a/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js +++ b/QualityControl/test/lib/middlewares/layouts/layoutOwner.middleware.test.js @@ -12,111 +12,80 @@ * 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 = () => { +export const layoutOwnerMiddlewareTest = async () => { + /** + * Test suite for layoutOwnerMiddleware using real UserDto validation + */ 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 = { - status: sinon.stub().returnsThis(), - json: sinon.stub().returns(), - }; - const next = sinon.stub(); // Do not call fake to catch unexpected execution + let layoutService = null; + let res = null; + let next = null; - const dataServiceStub = sinon.createStubInstance(LayoutRepository, { - readLayoutById: sinon.stub().resolves({ owner_name: 'ownerName', owner_id: 'ownerId' }), - }); + beforeEach(() => { + layoutService = { getLayoutById: sinon.stub() }; + res = { status: sinon.stub().returnsThis(), json: sinon.stub() }; + next = sinon.stub(); + }); - await layoutOwnerMiddleware(dataServiceStub)(req, res, next); + test('should return NotFoundError if session is missing', async () => { + const req = { params: { id: 'layoutId' } }; + await layoutOwnerMiddleware(layoutService)(req, res, next); + sinon.assert.calledWith(res.status, 404); + sinon.assert.calledWith(res.json, sinon.match({ + message: 'Session not found', + status: 404, + title: 'Not Found', + })); + }); - sinon.assert.calledWith(res.status, 403); + test('should return InvalidInputError if session user fails validation', async () => { + const req = { params: { id: 'layoutId' }, session: { personid: -1, name: '' } }; + await layoutOwnerMiddleware(layoutService)(req, res, next); + sinon.assert.calledWith(res.status, 400); sinon.assert.calledWith(res.json, sinon.match({ - message: 'Only the owner of the layout can delete it', - status: 403, - title: 'Unauthorized Access', + message: 'User could not be validated', + status: 400, + title: 'Invalid Input', })); }); - 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(), - }); - await layoutOwnerMiddleware(dataServiceStub)(req, res, next); - ok(res.status.calledWith(404)); - ok(res.json.calledWith({ + test('should return NotFoundError if layout owner info missing', async () => { + const req = { params: { id: 'layoutId' }, session: { personid: 1, name: 'Alice' } }; + layoutService.getLayoutById.resolves({ owner: { id: '', name: '' } }); + + await layoutOwnerMiddleware(layoutService)(req, res, next); + sinon.assert.calledWith(res.status, 404); + sinon.assert.calledWith(res.json, sinon.match({ 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)); - ok(res.json.calledWith({ - message: 'Unable to retrieve session information', - status: 404, - title: 'Not Found', + + test('should return UnauthorizedAccessError if user is not the owner', async () => { + const req = { params: { id: 'layoutId' }, session: { personid: 2, name: 'Bob' } }; + layoutService.getLayoutById.resolves({ owner: { id: 1, name: 'Alice' } }); + + await layoutOwnerMiddleware(layoutService)(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', + status: 403, + title: 'Unauthorized Access', })); }); - 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' }), - }); - await layoutOwnerMiddleware(dataServiceStub)(req, {}, next); - ok(next.called, 'The next() callback should be called'); + test('should call next() if user is the owner', async () => { + const req = { params: { id: 'layoutId' }, session: { personid: 1, name: 'Alice' } }; + layoutService.getLayoutById.resolves({ owner: { id: 1, name: 'Alice' } }); + + await layoutOwnerMiddleware(layoutService)(req, res, next); + ok(next.called, 'next() should be called for valid owner'); }); }); }; 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/services/external/AliEcsSynchronizer.test.js b/QualityControl/test/lib/services/external/AliEcsSynchronizer.test.js index 51ae5b515..a84463015 100644 --- a/QualityControl/test/lib/services/external/AliEcsSynchronizer.test.js +++ b/QualityControl/test/lib/services/external/AliEcsSynchronizer.test.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { ok, deepStrictEqual } from 'node:assert'; +import { ok, deepStrictEqual, strictEqual } from 'node:assert'; import { test, beforeEach, afterEach } from 'node:test'; import { stub, restore } from 'sinon'; import { AliEcsSynchronizer } from '../../../../lib/services/external/AliEcsSynchronizer.js'; @@ -50,12 +50,18 @@ export const aliecsSynchronizerTestSuite = async () => { test('should emit a run track event when a valid run message is received', () => { const runNumber = 123; const transition = Transition.START_ACTIVITY; - const timestamp = { toNumber: () => Date.now() } ; + const timestamp = { toNumber: () => Date.now() }; + aliecsSynchronizer._onRunMessage({ runEvent: { runNumber, transition }, timestamp }); + ok(eventEmitterMock.emit.called); deepStrictEqual(eventEmitterMock.emit.firstCall.args[0], EmitterKeys.RUN_TRACK); - deepStrictEqual(eventEmitterMock.emit.firstCall.args[1], { - runNumber, transition, timestamp: timestamp.toNumber() - }); + + const [, emitted] = eventEmitterMock.emit.firstCall.args; + + strictEqual(emitted.runNumber, runNumber); + strictEqual(emitted.transition, transition); + // Allow ±5ms margin + ok(Math.abs(emitted.timestamp - timestamp.toNumber()) <= 5); }); }; 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..2de193212 --- /dev/null +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -0,0 +1,300 @@ +/** + * @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 { rejects, strictEqual } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { LayoutService } from '../../../../lib/services/layout/LayoutService.js'; +import { NotFoundError } from '@aliceo2/web-ui'; +import { stub } from 'sinon'; + +export const layoutServiceTestSuite = async () => { + suite('LayoutService Test Suite', () => { + let layoutService = null; + let layoutRepositoryMock = null; + let gridTabCellRepositoryMock = null; + let userServiceMock = null; + let chartRepositoryMock = null; + let chartOptionsRepositoryMock = null; + let optionRepositoryMock = null; + let tabRepositoryMock = null; + let transactionMock = { }; + + beforeEach(() => { + transactionMock = { commit: stub().resolves(), rollback: stub().resolves() }; + layoutRepositoryMock = { + findById: stub(), + findOne: stub(), + model: { sequelize: { transaction: () => transactionMock } }, + updateLayout: stub(), + createLayout: stub(), + delete: stub(), + }; + userServiceMock = { getUsernameById: stub() }; + gridTabCellRepositoryMock = { + findObjectByChartId: stub(), + }; + chartRepositoryMock = {}; + chartOptionsRepositoryMock = {}; + optionRepositoryMock = {}; + tabRepositoryMock = {}; + + layoutService = new LayoutService( + layoutRepositoryMock, + userServiceMock, + tabRepositoryMock, + gridTabCellRepositoryMock, + chartRepositoryMock, + chartOptionsRepositoryMock, + optionRepositoryMock, + ); + layoutService._tabSynchronizer = { + sync: stub(), + }; + }); + + suite('getLayoutById', () => { + test('should return layout when found by id', async () => { + const layoutData = { id: 1, name: 'Test Layout' }; + layoutRepositoryMock.findById.resolves(layoutData); + layoutRepositoryMock.findOne.resolves(null); + + const result = await layoutService.getLayoutById(1); + strictEqual(result, layoutData); + }); + + test('should return layout when found by old_id', async () => { + const layoutData = { id: 2, name: 'Old Layout' }; + layoutRepositoryMock.findById.resolves(null); + layoutRepositoryMock.findOne.resolves(layoutData); + const result = await layoutService.getLayoutById('old-123'); + strictEqual(result, layoutData); + }); + + test ('should throw NotFoundError when layout not found', async () => { + layoutRepositoryMock.findById.resolves(null); + layoutRepositoryMock.findOne.resolves(null); + await rejects(async () => { + await layoutService.getLayoutById(999); + }, new NotFoundError('Layout with id: 999 was not found')); + }); + }); + suite('getLayoutsByFilters', () => { + test('should return layouts matching filters', async () => { + const filters = { is_official: true }; + const layoutsData = [ + { id: 1, name: 'Official Layout 1', is_official: true }, + { id: 2, name: 'Official Layout 2', is_official: true }, + ]; + layoutRepositoryMock.findLayoutsByFilters = stub().resolves(layoutsData); + + const result = await layoutService.getLayoutsByFilters(filters); + strictEqual(result, layoutsData); + }); + + test('should add owner_username to filters when owner_id is provided', async () => { + const filters = { owner_id: 42 }; + const updatedFilters = { owner_username: 'johndoe' }; + const layoutsData = [{ id: 3, name: 'User Layout', owner_username: 'johndoe' }]; + userServiceMock.getUsernameById.resolves('johndoe'); + layoutRepositoryMock.findLayoutsByFilters = stub().resolves(layoutsData); + layoutService._userService = userServiceMock; + + const result = await layoutService.getLayoutsByFilters(filters); + strictEqual(result, layoutsData); + strictEqual(layoutRepositoryMock.findLayoutsByFilters.calledWith(updatedFilters), true); + }); + + test('should throw error if userService fails to get username', async () => { + const filters = { owner_id: 99 }; + userServiceMock.getUsernameById.rejects(new Error('User not found')); + layoutService._userService = userServiceMock; + + await rejects(async () => { + await layoutService.getLayoutsByFilters(filters); + }, new Error('User not found')); + }); + }); + suite('getObjectById', () => { + test('should return object when found by id', async () => { + const objectData = { id: 1, name: 'Test Object' }; + gridTabCellRepositoryMock.findObjectByChartId.resolves(objectData); + + const result = await layoutService.getObjectById(1); + strictEqual(result, objectData); + }); + + test('should throw NotFoundError when object not found', async () => { + gridTabCellRepositoryMock.findObjectByChartId.resolves(null); + await rejects(async () => { + await layoutService.getObjectById(999); + }, new NotFoundError('Object with id: 999 was not found')); + }); + }); + suite('putLayout', () => { + test('putLayout should update layout when it exists', async () => { + const updatedData = { + id: 123456, + autoTabChange: 0, + collaborators: [], + description: 'Updated description', + displayTimestamp: true, + name: 'Updated Layout', + ownerUsername: 'alice_username', + tabs: [{ id: 1, name: 'Tab Updated' }], + }; + const normalizedLayout = { + name: 'Updated Layout', + description: 'Updated description', + display_timestamp: true, + auto_tab_change_interval: 0, + owner_username: 'alice_username', + }; + layoutRepositoryMock.findById.resolves({ + id: 123456, + name: 'Old Layout', + tabs: [{ id: 1, name: 'Tab 1' }], + }); + layoutRepositoryMock.updateLayout.resolves(1); + layoutService._tabSynchronizer.sync.resolves(); + const result = await layoutService.putLayout(123456, updatedData); + strictEqual(result, 123456); + strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); + strictEqual(layoutService._tabSynchronizer.sync.calledWith(123456, updatedData.tabs), true); + strictEqual(transactionMock.commit.called, true); + strictEqual(transactionMock.rollback.called, false); + }); + test('putLayout should throw NotFoundError when layout does not exist', async () => { + layoutRepositoryMock.findById.resolves(null); + await rejects(async () => { + await layoutService.putLayout(999, { name: 'Nonexistent Layout' }); + }, new NotFoundError('Layout with id: 999 was not found')); + strictEqual(transactionMock.rollback.called, true); + strictEqual(transactionMock.commit.called, false); + }); + + test('putLayout should rollback transaction on error', async () => { + layoutRepositoryMock.findById.resolves({ id: 123, name: 'Existing Layout' }); + layoutRepositoryMock.updateLayout.rejects(new Error('DB error')); + await rejects(async () => { + await layoutService.putLayout(123, { name: 'Updated Layout' }); + }, new Error('DB error')); + strictEqual(transactionMock.rollback.called, true); + strictEqual(transactionMock.commit.called, false); + }); + }); + + suite('patchLayout', () => { + test('should patch layout when it exists', async () => { + const updateData = { + isOfficial: true, + }; + const normalizedLayout = { + is_official: true, + }; + layoutRepositoryMock.findById.resolves({ + id: 123456, + name: 'Old Layout', + is_official: false, + tabs: [{ id: 1, name: 'Tab 1' }], + }); + layoutRepositoryMock.updateLayout.resolves([1]); + layoutService._tabSynchronizer.sync.resolves(); + + await layoutService.patchLayout(123456, updateData); + strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); + strictEqual(layoutService._tabSynchronizer.sync.called, false); + strictEqual(transactionMock.commit.called, true); + strictEqual(transactionMock.rollback.called, false); + }); + test('should throw NotFoundError when layout to patch does not exist', async () => { + layoutRepositoryMock.findById.resolves({ id: 123, name: 'Existing Layout' }); + layoutRepositoryMock.updateLayout.resolves(0); + await rejects(async () => { + await layoutService.patchLayout(999, { name: 'Nonexistent Layout' }); + }, new NotFoundError('Layout with id 999 not found')); + strictEqual(transactionMock.rollback.called, true); + strictEqual(transactionMock.commit.called, false); + }); + + test('should rollback transaction on error during patch', async () => { + layoutRepositoryMock.findById.resolves({ id: 123, name: 'Existing Layout' }); + layoutRepositoryMock.updateLayout.rejects(new Error('DB error')); + await rejects(async () => { + await layoutService.patchLayout(123, { name: 'Updated Layout' }); + }, new Error('DB error')); + strictEqual(transactionMock.rollback.called, true); + strictEqual(transactionMock.commit.called, false); + }); + }); + suite('removeLayout', () => { + test('should remove layout when it exists', async () => { + layoutRepositoryMock.delete.resolves(1); + await layoutService.removeLayout(123); + strictEqual(layoutRepositoryMock.delete.calledWith(123), true); + }); + + test('should throw NotFoundError when layout to remove does not exist', async () => { + layoutRepositoryMock.delete.resolves(0); + await rejects(async () => { + await layoutService.removeLayout(999); + }, new NotFoundError('Layout with id 999 not found for deletion')); + }); + }); + suite('postLayout', () => { + test('should create new layout', async () => { + const layoutData = { + name: 'blabla', + owner_id: 0, + owner_name: 'Anonymous', + description: '', + displayTimestamp: false, + autoTabChange: 0, + tabs: [{ name: 'main', objects: [], columns: 2 }], + collaborators: [], + }; + const createdLayout = { + created_at: new Date('2025-10-24T13:09:28.012Z'), + updated_at: new Date('2025-10-24T13:09:28.012Z'), + is_official: false, + id: 4, + name: 'blabla', + description: '', + display_timestamp: false, + auto_tab_change_interval: 0, + owner_username: 'anonymous', + }; + userServiceMock.getUsernameById.resolves('anonymous'); + layoutRepositoryMock.createLayout.resolves(createdLayout); + layoutService._tabSynchronizer.sync.resolves(); + + const result = await layoutService.postLayout(layoutData); + strictEqual(result, createdLayout); + strictEqual(layoutRepositoryMock.createLayout.called, true); + strictEqual(layoutService._tabSynchronizer.sync.calledWith(createdLayout.id, layoutData.tabs), true); + strictEqual(transactionMock.commit.called, true); + strictEqual(transactionMock.rollback.called, false); + }); + + test('should rollback transaction on error during layout creation', async () => { + layoutRepositoryMock.createLayout.rejects(new Error('DB error')); + await rejects(async () => { + await layoutService.postLayout({ name: 'New Layout' }); + }, new Error('DB error')); + strictEqual(transactionMock.rollback.called, true); + strictEqual(transactionMock.commit.called, false); + }); + }); + }); +}; 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..4e1436539 --- /dev/null +++ b/QualityControl/test/lib/services/layout/UserService.test.js @@ -0,0 +1,151 @@ +/** + * @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 { UserService } from '../../../../lib/services/layout/UserService.js'; +import { NotFoundError } from '@aliceo2/web-ui'; + +export const userServiceTestSuite = async () => { + suite('UserService Test Suite', () => { + let mockUserRepository = null; + let userService = null; + + beforeEach(() => { + mockUserRepository = { + findOne: () => Promise.resolve(null), + createUser: () => Promise.resolve({ id: 1, username: 'testuser', name: 'Test User' }), + findById: () => 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('saveUser()', () => { + test('should create new user when user does not exist', async () => { + const userData = { username: 'newuser', name: 'New User', personid: 123 }; + const createdUsers = []; + + mockUserRepository.findOne = () => Promise.resolve(null); + mockUserRepository.createUser = (user) => { + createdUsers.push(user); + return Promise.resolve(user); + }; + + await userService.saveUser(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.findOne = () => Promise.resolve({ id: 123, username: 'existinguser' }); + mockUserRepository.createUser = () => { + createUserCalled = true; + return Promise.resolve(); + }; + + await userService.saveUser(userData); + + strictEqual(createUserCalled, false, 'Should not create user when already exists'); + }); + + test('should throw error if createUser returns null', async () => { + const userData = { username: 'newuser', name: 'New User', personid: 123 }; + + mockUserRepository.findOne = () => Promise.resolve(null); + mockUserRepository.createUser = () => Promise.resolve(null); + + await rejects( + async () => await userService.saveUser(userData), + /Error creating user/, + ); + }); + + test('should throw error if repository throws', async () => { + const userData = { username: 'newuser', name: 'New User', personid: 123 }; + + mockUserRepository.findOne = () => Promise.reject(new Error('DB error')); + + await rejects( + async () => await userService.saveUser(userData), + /DB error/, + ); + }); + }); + + suite('getUsernameById()', () => { + test('should return username when user is found', async () => { + const mockUser = { id: 123, username: 'testuser', name: 'Test User' }; + mockUserRepository.findById = () => Promise.resolve(mockUser); + + const result = await userService.getUsernameById(123); + strictEqual(result, 'testuser'); + }); + + test('should throw NotFoundError when user is not found', async () => { + mockUserRepository.findById = () => Promise.resolve(null); + + await rejects( + async () => await userService.getUsernameById(999), + new NotFoundError('User with ID 999 not found'), + ); + }); + + test('should throw NotFoundError when user has no username', async () => { + const mockUser = { id: 123, name: 'Test User' }; + mockUserRepository.findById = () => Promise.resolve(mockUser); + + await rejects( + async () => await userService.getUsernameById(123), + new NotFoundError('User with ID 123 not found'), + ); + }); + }); + + suite('getOwnerIdByUsername()', () => { + test('should return user id when user is found', async () => { + const mockUser = { id: 123, username: 'testuser', name: 'Test User' }; + mockUserRepository.findOne = () => Promise.resolve(mockUser); + + const result = await userService.getOwnerIdByUsername('testuser'); + strictEqual(result, 123); + }); + + test('should throw NotFoundError when user is not found', async () => { + mockUserRepository.findOne = () => 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..a03b89a0d --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js @@ -0,0 +1,222 @@ +/** + * @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([]), + delete: () => Promise.resolve(), + create: () => 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', () => { + strictEqual(synchronizer._chartOptionRepository, mockChartOptionRepository); + strictEqual(synchronizer._optionsRepository, mockOptionsRepository); + }); + }); + + 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.create = (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.delete = (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.delete = (data) => { + deletedOptions.push(data); + return Promise.resolve(1); + }; + + mockChartOptionRepository.create = (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.delete = () => { + deleteCalled = true; + }; + + mockChartOptionRepository.create = () => { + 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 () => { + const chart = { id: 1, options: ['option1'] }; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = () => Promise.reject(new Error('DB error')); + + await rejects( + async () => { + await synchronizer.sync(chart, mockTransaction); + }, + { + message: 'DB error', + }, + ); + }); + + test('should throw error when delete operation fails', async () => { + const chart = { id: 1, options: ['Option1'] }; // provide at least one option + + // Mock repository methods + mockChartOptionRepository.findChartOptionsByChartId = () => + Promise.resolve([{ option_id: 10 }]); + mockChartOptionRepository.delete = () => Promise.resolve(0); // Simulate failure + mockOptionsRepository.findOptionByName = () => + Promise.resolve({ id: 20, name: 'Option1' }); // Return a dummy option + + await rejects(synchronizer.sync(chart, mockTransaction), { + message: 'Not found chart option with chart=1 and option=10 for deletion', + }); + }); + }); + }); +}; 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..6d539c0d2 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js @@ -0,0 +1,190 @@ +/** + * @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([]), + update: () => Promise.resolve(1), + create: () => Promise.resolve({ id: 1 }), + }; + + mockChartRepository = { + delete: () => Promise.resolve(1), + update: () => Promise.resolve(1), + create: () => 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', () => { + strictEqual(synchronizer._gridTabCellRepository, mockGridTabCellRepository); + strictEqual(synchronizer._chartRepository, mockChartRepository); + strictEqual(synchronizer._chartOptionsSynchronizer, mockChartOptionsSynchronizer); + }); + }); + + 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.create = (chart) => { + createdCharts.push(chart); + return Promise.resolve({ id: chart.id }); + }; + mockGridTabCellRepository.create = (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.update = (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.delete = (chartId) => { + deletedCharts.push(chartId); + return Promise.resolve(1); + }; + mockChartRepository.update = () => Promise.resolve(1); + mockGridTabCellRepository.update = () => 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.create = (chart) => Promise.resolve({ id: chart.id }); + mockGridTabCellRepository.create = () => 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 when updating non-existing chart', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 1, name: 'Non-existing Chart' }]; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([{ chart_id: 1 }]); + mockChartRepository.update = () => Promise.resolve(0); + mockGridTabCellRepository.update = () => Promise.resolve(0); + + await rejects( + synchronizer.sync(tabId, objects, mockTransaction), + /Chart or cell not found for update/, + ); + }); + }); + + 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..0d5c56a65 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js @@ -0,0 +1,171 @@ +/** + * @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'; +import { NotFoundError } from '@aliceo2/web-ui'; + +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([]), + delete: () => Promise.resolve(1), + updateTab: () => Promise.resolve(1), + createTab: () => 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', () => { + strictEqual(synchronizer._tabRepository, mockTabRepository); + strictEqual(synchronizer._gridTabCellSynchronizer, mockGridTabCellSynchronizer); + }); + }); + + 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, name: 'Updated Tab' }]); + + mockTabRepository.updateTab = (id, tab) => { + updatedTabs.push({ id, tab }); + return Promise.resolve(1); + }; + + mockTabRepository.delete = () => Promise.resolve(1); + mockTabRepository.createTab = () => Promise.resolve(null); + + 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, name: 'Old Tab' }, + { id: 2, name: 'Keep Tab' }, // ✅ Should remain + ]); + + mockTabRepository.delete = (id) => { + deletedTabs.push(id); + return Promise.resolve(1); + }; + + mockTabRepository.updateTab = () => Promise.resolve(1); + mockTabRepository.createTab = () => Promise.resolve(null); // Optional safety + + 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 NotFoundError when delete returns 0', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 2, name: 'Keep Tab' }]; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([ + { id: 1, name: 'Old Tab' }, + { id: 2, name: 'Keep Tab' }, + ]); + + mockTabRepository.delete = (id) => { + if (id === 1) { + return Promise.resolve(0); + } + return Promise.resolve(1); + }; + + await rejects( + synchronizer.sync(layoutId, tabs, mockTransaction), + new NotFoundError('Tab with id=1 not found for deletion'), + ); + }); + + test('should throw Error when createTab fails', async () => { + const layoutId = 'layout-1'; + const tabs = [{ name: 'New Tab', objects: [] }]; // no id = triggers create + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); // no existing tabs + mockTabRepository.createTab = () => Promise.resolve(null); // fail creation + + await rejects( + synchronizer.sync(layoutId, tabs, mockTransaction), + new Error('Failed to create new tab'), + ); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js new file mode 100644 index 000000000..fd2d40ad7 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js @@ -0,0 +1,81 @@ +/** + * @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/layoutMapper.js'; +import { suite, test } from 'node:test'; + +export const layoutMapperTestSuite = async () => { + suite('layoutMapper tests suite', () => { + + const baseLayout = { + 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 patch = { isOfficial: true }; + const result = await normalizeLayout(patch, baseLayout, false); + deepStrictEqual(result, { + is_official: true, + }); + }); + + test('should fully replace a layout correctly', async () => { + const fullUpdate = { + name: 'Updated Layout', + description: 'This is the updated layout', + displayTimestamp: false, + autoTabChange: 60, + isOfficial: false, + owner_id: 2, + }; + + const result = await normalizeLayout(fullUpdate, baseLayout, true); + + 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', + }); + }); + + test('should handle missing userService', async () => { + const patch = { owner_id: 1 }; + const result = await normalizeLayout(patch, baseLayout, false); + deepStrictEqual(result, {}); + }); + + test('should handle missing fields', async () => { + const patch = {}; + const result = await normalizeLayout(patch, baseLayout, false); + deepStrictEqual(result, {}); + }); + + test('should return null username if user not found', async () => { + const patch = { owner_id: 999 }; + const result = await normalizeLayout(patch, baseLayout, false); + deepStrictEqual(result, { owner_username: null }); + }); + }); +}; diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 8308a6013..335e1eddb 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -123,13 +123,13 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) const linkpath = cardLayoutLinkPath(cardPath(myLayoutIndex, 2)); const href = await page.evaluate((path) => document.querySelector(path).href, linkpath); - strictEqual(href, 'http://localhost:8080/?page=layoutShow&layoutId=671b8c22402408122e2f20dd'); + strictEqual(href, 'http://localhost:8080/?page=layoutShow&layoutId=1'); await page.click(linkpath); await page.waitForNetworkIdle(); const location = await page.evaluate(() => window.location); - strictEqual(location.search, '?page=layoutShow&layoutId=671b8c22402408122e2f20dd&tab=main'); + strictEqual(location.search, '?page=layoutShow&layoutId=1&tab=main'); }); await testParent.test('should add official logo the \'make Official\' button is pressed', async () => { diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 4307ac4d3..9ae0e2bb1 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -23,7 +23,7 @@ import { editedMockedLayout } from '../../setup/seeders/layout-show/json-file-mo * @param {object} testParent - Node.js test object which ensures sub-tests are being awaited */ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => { - const LAYOUT_ID = '671b95883d23cd0d67bdc787'; + const LAYOUT_ID = 2; await testParent.test( 'should load the layoutShow page', { timeout }, @@ -122,51 +122,38 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => { timeout }, async () => { const plotsCount = await page.evaluate(() => document.querySelectorAll('section svg.jsroot').length); - ok(plotsCount > 1); + ok(plotsCount > 0); }, ); 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'; - const plot1Path = `${commonSelectorPath} > div:nth-child(1)`; - await page.locator(plot1Path).click(); - - const result = await page.evaluate((commonSelectorPath) => { - const { title } = document.querySelector(`${commonSelectorPath} > div:nth-child(2) > div > button`); - const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > 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 plotSelector = '.jsrootdiv'; + const infoButtonSelector = 'button[title="View details about histogram"]'; + await page.waitForSelector(plotSelector); + await page.hover(plotSelector); + await page.waitForSelector(infoButtonSelector, { state: 'visible' }); + await page.click(infoButtonSelector); + await page.waitForSelector('.dropdown-menu.right-menu', { state: 'visible' }); + const result = await page.evaluate(() => { + const rows = document.querySelectorAll('.dropdown-menu .flex-row.g2'); + const data = {}; + rows.forEach((row) => { + const key = row.querySelector('b')?.innerText?.trim(); + const value = row.querySelector('.w-75 div, .w-75')?.innerText?.trim(); + if (key) { + data[key] = value; + } + }); + return data; + }); - 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 > button`); - const infoCommonSelectorPath = `${commonSelectorPath} > div:nth-child(2) > div > div > div > div`; - const objectPath = document.querySelector(`${infoCommonSelectorPath} > div:nth-child(2) > 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'); - }, - ); + strictEqual(result.path, 'qc/test/object/1'); + ok(result.lastModified.includes('2022')); + strictEqual(result.qcDetectorName, 'TPC'); + strictEqual(result.qcVersion, '1.64.0'); + ok(result.objectType.includes('QualityObject')); + }); await testParent.test('should have second tab to be empty (according to demo data)', { timeout }, async () => { await page.locator('#tab-1').click(); @@ -407,7 +394,6 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => const updateButtonPath = '#updateLayoutButton'; await page.locator(updateButtonPath).click(); - await delay(50); const newTabName = await page.evaluate(() => { diff --git a/QualityControl/test/public/pages/object-view-from-layout-show.test.js b/QualityControl/test/public/pages/object-view-from-layout-show.test.js index 7476dcb84..d4d9fcc8b 100644 --- a/QualityControl/test/public/pages/object-view-from-layout-show.test.js +++ b/QualityControl/test/public/pages/object-view-from-layout-show.test.js @@ -48,8 +48,8 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t 'should load a plot and update button text to "Back to layout" if layoutId parameter is provided', { timeout }, async () => { - const objectId = '6724a6bd1b2bad3d713cc4ee'; - const layoutId = '671b95883d23cd0d67bdc787'; + const objectId = 6; + const layoutId = 2; await page .goto(`${url}?page=objectView&objectId=${objectId}&layoutId=${layoutId}`, { waitUntil: 'networkidle0' }); @@ -62,7 +62,7 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t }); strictEqual( result.location, - '?page=objectView&objectId=6724a6bd1b2bad3d713cc4ee&layoutId=671b95883d23cd0d67bdc787', + '?page=objectView&objectId=6&layoutId=2', ); strictEqual(result.backButtonTitle, 'Back to layout'); }, @@ -83,7 +83,7 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t 'should take back the user to page=layoutShow when clicking "Back to layout"', { timeout }, async () => { - const layoutId = '671b95883d23cd0d67bdc787'; + const layoutId = 2; const backToLayoutButtonPath = 'div > div > div > div > a'; const href = await page.evaluate((backToLayoutButtonPath) => document.querySelector(backToLayoutButtonPath).href, backToLayoutButtonPath); @@ -100,8 +100,8 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t 'should load page=objectView and display a plot when objectId and layoutId are passed', { timeout }, async () => { - const objectId = '6724a6bd1b2bad3d713cc4ee'; - const layoutId = '671b95883d23cd0d67bdc787'; + const objectId = 6; + const layoutId = 2; await page.goto( `${url}?page=objectView&objectId=${objectId}&layoutId=${layoutId}`, { waitUntil: 'networkidle0' }, 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 16405e1aa..a67f5729e 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -58,6 +58,16 @@ import { ccdbServiceTestSuite } from './lib/services/CcdbService.test.js'; import { qcdbDownloadServiceTestSuite } from './lib/services/QcdbDownloadService.test.js'; import { statusServiceTestSuite } from './lib/services/StatusService.test.js'; import { bookkeepingServiceTestSuite } from './lib/services/BookkeepingService.test.js'; +import { aliecsSynchronizerTestSuite } from './lib/services/external/AliEcsSynchronizer.test.js'; +import { filterServiceTestSuite } from './lib/services/FilterService.test.js'; +import { jsonFileServiceTestSuite } from './lib/services/JsonFileService.test.js'; +import { qcObjectServiceTestSuite } from './lib/services/QcObjectService.test.js'; +import { runModeServiceTestSuite } from './lib/services/RunModeService.test.js'; +import { tabSynchronizerTestSuite } from './lib/services/layout/helpers/TabSynchronizer.test.js'; +import { gridTabCellSynchronizerTestSuite } from './lib/services/layout/helpers/GridTabCellSynchronizer.test.js'; +import { chartOptionsSynchronizerTestSuite } from './lib/services/layout/helpers/ChartOptionsSynchronizer.test.js'; +import { layoutServiceTestSuite } from './lib/services/layout/LayoutService.test.js'; +import { userServiceTestSuite } from './lib/services/layout/UserService.test.js'; /** * Repositories @@ -71,33 +81,31 @@ import { gridTabCellRepositoryTestSuite } from './lib/database/repositories/Grid import { tabRepositoryTestSuite } from './lib/database/repositories/TabRepository.test.js'; import { optionRepositoryTestSuite } from './lib/database/repositories/OptionRepository.test.js'; -import { commonLibraryQcObjectUtilsTestSuite } from './common/library/qcObject/utils.test.js'; -import { commonLibraryUtilsDateTimeTestSuite } from './common/library/utils/dateTimeFormat.test.js'; -import { layoutIdMiddlewareTest } from './lib/middlewares/layouts/layoutId.middleware.test.js'; +/** + * Middlewares + */ 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 { 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 { commonLibraryQcObjectUtilsTestSuite } from './common/library/qcObject/utils.test.js'; +import { commonLibraryUtilsDateTimeTestSuite } from './common/library/utils/dateTimeFormat.test.js'; import { apiPutLayoutTests } from './api/layouts/api-put-layout.test.js'; import { apiPatchLayoutTests } from './api/layouts/api-patch-layout.test.js'; -import { jsonFileServiceTestSuite } from './lib/services/JsonFileService.test.js'; import { userControllerTestSuite } from './lib/controllers/UserController.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 { mapLayoutToAPITestSuite } from './lib/controllers/helpers/mapLayoutToAPI.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 @@ -240,11 +248,16 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn suite('QcObjectService - Test Suite', async () => await qcObjectServiceTestSuite()); suite('BookkeepingServiceTest test suite', async () => await bookkeepingServiceTestSuite()); suite('AliEcsSynchronizer - Test Suite', async () => await aliecsSynchronizerTestSuite()); + suite('Layout Service - Test Suite', async () => { + await tabSynchronizerTestSuite(); + await gridTabCellSynchronizerTestSuite(); + await chartOptionsSynchronizerTestSuite(); + await userServiceTestSuite(); + await layoutServiceTestSuite(); + }); }); suite('Middleware - Test Suite', async () => { - suite('LayoutServiceMiddleware test suite', async () => layoutServiceMiddlewareTest()); - suite('LayoutIdMiddleware test suite', async () => layoutIdMiddlewareTest()); suite('LayoutOwnerMiddleware test suite', async () => layoutOwnerMiddlewareTest()); suite('StatusComponentMiddleware test suite', async () => statusComponentMiddlewareTest()); suite('RunModeMiddleware test suite', async () => runModeMiddlewareTest()); @@ -256,6 +269,9 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn }); suite('Controllers - Test Suite', async () => { + suite('Helpers - Test Suite', async () => { + await mapLayoutToAPITestSuite(); + }); suite('LayoutController test suite', async () => await layoutControllerTestSuite()); suite('StatusController test suite', async () => await statusControllerTestSuite()); suite('ObjectController test suite', async () => await objectControllerTestSuite());