From baa4c3d015d13499bd3e65a9f541873368de9feb Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:40:14 +0200 Subject: [PATCH 01/35] create seeders --- QualityControl/jsconfig.json | 4 +- .../seeders/20250930071301-seed-users.mjs | 30 +++++++ .../seeders/20250930071308-seed-layouts.mjs | 44 ++++++++++ .../seeders/20250930071313-seed-tabs.mjs | 50 ++++++++++++ .../seeders/20250930071317-seed-charts.mjs | 55 +++++++++++++ .../20250930071322-seed-gridtabcells.mjs | 81 +++++++++++++++++++ .../20250930071334-seed-chart-options.mjs | 60 ++++++++++++++ 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 QualityControl/lib/database/seeders/20250930071301-seed-users.mjs create mode 100644 QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs create mode 100644 QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs create mode 100644 QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs create mode 100644 QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs create mode 100644 QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs 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/database/seeders/20250930071301-seed-users.mjs b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs new file mode 100644 index 000000000..ddbc1ac9e --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file 'COPYING'. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('users', [ + { + id: 0, + name: 'Anonymous', + username: 'anonymous' }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('users', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs new file mode 100644 index 000000000..9fe89eaf6 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs @@ -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. + */ + +'use strict'; + +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', + }, + ], {}); +}; + +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..1c94650bd --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs @@ -0,0 +1,50 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('tabs', [ + { + id: 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, + }, + + ], {}); +}; + +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..301484a85 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs @@ -0,0 +1,55 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('charts', [ + { + id: 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, + }, + ], {}); +}; + +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..30f317c0c --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs @@ -0,0 +1,81 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +'use strict'; + +export const up = async (queryInterface) => { + await queryInterface.bulkInsert('grid_tab_cells', [ + { + chart_id: 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: 6, + row: 1, + col: 0, + tab_id: 3, + row_span: 1, + col_span: 1, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('grid_tab_cells', null, { transaction }); + }); +}; diff --git a/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs new file mode 100644 index 000000000..216a6a070 --- /dev/null +++ b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs @@ -0,0 +1,60 @@ +/** + * @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'; + +/** + * Seed chart options + * @param {*} 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, + }, + ], {}); +}; + +export const down = async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.bulkDelete('chart_options', null, { transaction }); + }); +}; From 75d1e3b0dd10c0d2c5711701fc355886e2d27ccd Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:57:31 +0200 Subject: [PATCH 02/35] database configuration --- QualityControl/config-default.js | 2 + QualityControl/lib/QCModel.js | 15 +++++- QualityControl/lib/config/database.js | 2 + .../lib/database/SequelizeDatabase.js | 49 +++++++++++++++++-- QualityControl/lib/database/index.js | 23 +++++++-- 5 files changed, 81 insertions(+), 10 deletions(-) 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/lib/QCModel.js b/QualityControl/lib/QCModel.js index ed38adea6..3131fee3b 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -64,8 +64,19 @@ export const setupQcModel = async (eventEmitter) => { 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 || {})); + + try { + const databaseConfig = config.database || {}; + if (!databaseConfig || Object.keys(databaseConfig).length === 0) { + logger.errorMessage('Database configuration is not provided. The application cannot be initialized'); + throw new Error('Database configuration is missing'); + } + + const sequelizeDatabase = new SequelizeDatabase(databaseConfig); + initDatabase(sequelizeDatabase, { forceSeed: config?.database?.forceSeed, drop: config?.database?.drop }); + } catch (error) { + logger.errorMessage(`Database initialization failed: ${error.message}`); + throw error; } if (config?.kafka?.enabled) { 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/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}`); } From 01205fe41108fc1e99db89c3494d987ce492fa0b Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:50:13 +0200 Subject: [PATCH 03/35] mapper to patch a layout --- .../services/layout/helpers/layoutMapper.js | 61 +++++++++++++ .../layout/helpers/layoutMapper.test.js | 87 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 QualityControl/lib/services/layout/helpers/layoutMapper.js create mode 100644 QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js diff --git a/QualityControl/lib/services/layout/helpers/layoutMapper.js b/QualityControl/lib/services/layout/helpers/layoutMapper.js new file mode 100644 index 000000000..b5c3a61dd --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/layoutMapper.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { LogManager } from '@aliceo2/web-ui'; + +/** + * @typedef {import('../../../services/layout/UserService.js').UserService} UserService + */ + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-mapper`; + +/** + * Helper to normalize layout data + * @param {*} patch partial layout data + * @param {*} layout original layout + * @param {*} isFull if true, patch is a full layout + * @param {*} userService user service to get username from id + * @returns + */ +export const normalizeLayout = async (patch, layout = {}, isFull = false, userService) => { + const logger = LogManager.getLogger(LOG_FACILITY); + const source = isFull ? { ...layout, ...patch } : patch; + + const fieldMap = { + id: 'id', + name: 'name', + description: 'description', + displayTimestamp: 'display_timestamp', + autoTabChange: 'auto_tab_change_interval', + isOfficial: 'is_official', + }; + + const data = Object.entries(fieldMap).reduce((acc, [frontendKey, backendKey]) => { + if (frontendKey in source) { + acc[backendKey] = source[frontendKey]; + } + return acc; + }, {}); + + if ('owner_id' in source && userService?.getUsernameById) { + try { + const username = await userService.getUsernameById(source.owner_id); + data.owner_username = username; + } catch (error) { + logger.errorMessage('Failed to get username by id', error); + } + } + + return data; +}; 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..1e21895c6 --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { 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 mockUserService = { + getUsernameById: async (id) => { + const users = { 1: 'alice', 2: 'bob' }; + return users[id] || null; + }, + }; + + 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, mockUserService); + 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, mockUserService); + + deepStrictEqual(result, { + id: 10, + name: 'Updated Layout', + description: 'This is the updated layout', + display_timestamp: false, + auto_tab_change_interval: 60, + is_official: false, + owner_username: 'bob', + }); + }); + + test('should handle missing userService', async () => { + const patch = { owner_id: 1 }; + const result = await normalizeLayout(patch, baseLayout, false, null); + deepStrictEqual(result, {}); + }); + + test('should handle missing fields', async () => { + const patch = {}; + const result = await normalizeLayout(patch, baseLayout, false, mockUserService); + deepStrictEqual(result, {}); + }); + + test('should return null username if user not found', async () => { + const patch = { owner_id: 999 }; + const result = await normalizeLayout(patch, baseLayout, false, mockUserService); + deepStrictEqual(result, { owner_username: null }); + }); + }); +}; From fc6dbe1e36624c96b753751236098a80224d79f0 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:43:09 +0200 Subject: [PATCH 04/35] layout service helpers to keep the database synchronized --- .../helpers/chartOptionsSynchronizer.js | 93 +++++++ .../layout/helpers/gridTabCellSynchronizer.js | 89 +++++++ .../layout/helpers/mapObjectToChartAndCell.js | 44 ++++ .../layout/helpers/tabSynchronizer.js | 82 ++++++ .../helpers/ChartOptionsSynchronizer.test.js | 233 ++++++++++++++++++ .../helpers/GridTabCellSynchronizer.test.js | 200 +++++++++++++++ .../layout/helpers/TabSynchronizer.test.js | 148 +++++++++++ QualityControl/test/test-index.js | 18 +- 8 files changed, 902 insertions(+), 5 deletions(-) create mode 100644 QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js create mode 100644 QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js create mode 100644 QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js create mode 100644 QualityControl/lib/services/layout/helpers/tabSynchronizer.js create mode 100644 QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js create mode 100644 QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js create mode 100644 QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js diff --git a/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js new file mode 100644 index 000000000..c4059e207 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { LogManager } from '@aliceo2/web-ui'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/chart-options-synchronizer`; + +/** + * @typedef {import('../../../database/repositories/ChartOptionsRepository.js') + * .ChartOptionsRepository} ChartOptionsRepository + */ + +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; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Synchronize chart options with the database. + * @param {object} chart Chart object + * @param {Array} chart.options Array of options + * @param {object} transaction Sequelize transaction + */ + async sync(chart, transaction) { + if (!(chart.options && chart.options.length)) { + return; + } + + let existingOptions = null; + let existingOptionIds = null; + let incomingOptions = null; + let incomingOptionIds = null; + + try { + existingOptions = await this._chartOptionRepository.findChartOptionsByChartId(chart.id, { transaction }); + existingOptionIds = existingOptions.map((co) => co.option_id); + incomingOptions = await Promise.all(chart.options.map((o) => + this._optionsRepository.findOptionByName(o, { transaction }))); + incomingOptionIds = incomingOptions.map((o) => o.id); + } catch (error) { + this._logger.errorMessage(`Failed to fetch chart options: ${error.message}`); + await transaction.rollback(); + throw error; + } + + const toDelete = existingOptionIds.filter((id) => !incomingOptionIds.includes(id)); + for (const optionId of toDelete) { + try { + await this._chartOptionRepository.delete({ chartId: chart.id, optionId }, { transaction }); + } catch (error) { + this._logger.errorMessage(`Failed to delete chart option: ${error.message}`); + transaction.rollback(); + throw error; + } + } + + for (const option of incomingOptions) { + if (!existingOptionIds.includes(option.id)) { + try { + const createdOption = await this._chartOptionRepository.create( + { chart_id: chart.id, option_id: option.id }, + { transaction }, + ); + if (!createdOption) { + throw new Error('Option creation returned null'); + } + } catch (error) { + this._logger.errorMessage(`Failed to create chart option: ${error.message}`); + transaction.rollback(); + throw error; + } + } + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js new file mode 100644 index 000000000..f4b542ab6 --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js @@ -0,0 +1,89 @@ +/** + * @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 { mapObjectToChartAndCell } from './mapObjectToChartAndCell.js'; + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/grid-tab-cell-synchronizer`; + +/** + * Class to synchronize grid tab cells with the database. + */ +export class GridTabCellSynchronizer { + constructor(gridTabCellRepository, chartRepository, chartOptionsSynchronizer) { + this._gridTabCellRepository = gridTabCellRepository; + this._chartRepository = chartRepository; + this._chartOptionsSynchronizer = chartOptionsSynchronizer; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Synchronize grid tab cells with the database. + * @param {string} tabId Tab ID + * @param {Array} objects Array of objects to map to charts and cells + * @param {object} transaction Sequelize transaction + */ + async sync(tabId, objects, transaction) { + this._logger.infoMessage(`[GridTabCellSynchronizer] syncing cells for tabId=${tabId}`); + + let existingCells = null; + try { + existingCells = await this._gridTabCellRepository.findByTabId(tabId, { transaction }); + } catch (error) { + this._logger.errorMessage(`Failed to fetch existing cells for tabId=${tabId}: ${error.message}`); + transaction.rollback(); + throw error; + } + const existingChartIds = existingCells.map((cell) => cell.chart_id); + const incomingChartIds = objects.map((obj) => obj.id); + + const toDelete = existingChartIds.filter((id) => !incomingChartIds.includes(id)); + for (const chartId of toDelete) { + try { + const deletedCount = await this._chartRepository.delete(chartId, { transaction }); + if (deletedCount === 0) { + throw new NotFoundError(`Chart with id=${chartId} not found for deletion`); + } + } catch (error) { + this._logger.errorMessage(`Failed to delete chartId=${chartId}: ${error.message}`); + transaction.rollback(); + throw error; + } + } + for (const object of objects) { + try { + const { chart, cell } = mapObjectToChartAndCell(object, tabId); + if (existingChartIds.includes(chart.id)) { + const updatedRows = await this._chartRepository.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 }); + const createdCell = await this._gridTabCellRepository.create(cell, { transaction }); + if (!createdChart || !createdCell) { + throw new NotFoundError('Chart or cell not found for creation'); + } + } + await this._chartOptionsSynchronizer.sync({ ...chart, options: object?.options }, transaction); + } catch (error) { + this._logger.errorMessage(`Failed to sync chart/cell for object id=${object.id}: ${error.message}`); + transaction.rollback(); + throw error; + } + } + } +} diff --git a/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js b/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js new file mode 100644 index 000000000..1995ecb1b --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/mapObjectToChartAndCell.js @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { InvalidInputError } from '@aliceo2/web-ui'; + +/** + * Maps an input object to a chart and a cell + * @param {object} object - The input object + * @param {string} tabId - The ID of the tab + * @returns {object} An object containing the mapped chart and cell + */ +export function mapObjectToChartAndCell(object, tabId) { + if (!object || typeof object !== 'object' || !tabId) { + throw new InvalidInputError('Invalid input: object and tab id are required'); + } + const { id: chartId, x, y, h, w, name, ignoreDefaults } = object; + + return { + chart: { + id: chartId, + object_name: name, + ignore_defaults: ignoreDefaults, + }, + cell: { + tab_id: tabId, + chart_id: chartId, + row: x, + col: y, + row_span: h, + col_span: w, + }, + }; +} diff --git a/QualityControl/lib/services/layout/helpers/tabSynchronizer.js b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js new file mode 100644 index 000000000..555b5b35f --- /dev/null +++ b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/tab-synchronizer`; +import { LogManager, 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. + * @param {import('@aliceo2/web-ui').Logger} logger - Logger instance for logging operations. + */ + constructor(tabRepository, gridTabCellSynchronizer) { + this._tabRepository = tabRepository; + this._gridTabCellSynchronizer = gridTabCellSynchronizer; + this._logger = LogManager.getLogger(LOG_FACILITY); + } + + /** + * Sincroniza tabs de un layout (upsert + delete) + * @param {string} layoutId + * @param {Array} tabs + * @param {object} transaction + */ + async sync(layoutId, tabs, transaction) { + const incomingIds = tabs.filter((t) => t.id).map((t) => t.id); + const existingTabs = await this._tabRepository.findTabsByLayoutId(layoutId, { transaction }); + const existingIds = existingTabs.map((t) => t.id); + + const idsToDelete = existingIds.filter((id) => !incomingIds.includes(id)); + for (const id of idsToDelete) { + try { + const deletedCount = await this._tabRepository.delete(id, { transaction }); + if (deletedCount === 0) { + throw new NotFoundError(`Tab with id=${id} not found for deletion`); + } + } catch (error) { + this._logger.errorMessage(`Failed to delete tabId=${id}: ${error.message}`); + await transaction.rollback(); + throw error; + } + } + + for (const tab of tabs) { + tab.layout_id = layoutId; + try { + if (tab.id && existingIds.includes(tab.id)) { + await this._tabRepository.updateTab(tab.id, tab, { transaction }); + } else { + const tabRecord = await this._tabRepository.createTab(tab, { transaction }); + if (!tabRecord) { + throw new Error('Failed to create new tab'); + } + } + if (tab.objects && tab.objects.length) { + await this._gridTabCellSynchronizer.sync(tab.id, tab.objects, transaction); + } + } catch (error) { + this._logger.errorMessage(`Failed to upsert tab (id=${tab.id ?? 'new'}): ${error.message}`); + await transaction.rollback(); + throw error; + } + } + } +} diff --git a/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js new file mode 100644 index 000000000..f5074ecdc --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { deepStrictEqual, strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { ChartOptionsSynchronizer } from '../../../../../lib/services/layout/helpers/chartOptionsSynchronizer.js'; + +export const chartOptionsSynchronizerTestSuite = async () => { + suite('ChartOptionsSynchronizer Test Suite', () => { + let mockChartOptionRepository = null; + let mockOptionsRepository = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + // Mock repositories + mockChartOptionRepository = { + findChartOptionsByChartId: () => Promise.resolve([]), + 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', () => { + const chartRepo = { test: 'chartRepo' }; + const optionsRepo = { test: 'optionsRepo' }; + const sync = new ChartOptionsSynchronizer(chartRepo, optionsRepo); + + strictEqual(sync._chartOptionRepository, chartRepo); + strictEqual(sync._optionsRepository, optionsRepo); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should return early when chart has no options', async () => { + const chart = { id: 1 }; + let findCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => { + findCalled = true; + return Promise.resolve([]); + }; + + await synchronizer.sync(chart, mockTransaction); + strictEqual(findCalled, false, 'Should not call repository when no options'); + }); + + test('should return early when chart has empty options array', async () => { + const chart = { id: 1, options: [] }; + let findCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => { + findCalled = true; + return Promise.resolve([]); + }; + + await synchronizer.sync(chart, mockTransaction); + strictEqual(findCalled, false, 'Should not call repository when options array is empty'); + }); + + test('should create new chart options when none exist', async () => { + const chart = { id: 1, options: ['option1', 'option2'] }; + const createdOptions = []; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = (name) => Promise.resolve({ id: name === 'option1' ? 10 : 20, name }); + mockChartOptionRepository.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 () => { + let rollbackCalled = false; + const chart = { id: 1, options: ['option1'] }; + const error = new Error('Database connection failed'); + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + await rejects( + async () => await synchronizer.sync(chart, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + + test('should throw error when create fails', async () => { + const chart = { id: 1, options: ['option1'] }; + const error = new Error('Failed to create chart option'); + let rollbackCalled = false; + + mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); + mockOptionsRepository.findOptionByName = () => Promise.resolve({ id: 10, name: 'option1' }); + mockChartOptionRepository.create = () => Promise.reject(error); + + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects( + async () => await synchronizer.sync(chart, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js new file mode 100644 index 000000000..49c7a2b2c --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { deepStrictEqual, strictEqual, rejects, throws } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { GridTabCellSynchronizer } from '../../../../../lib/services/layout/helpers/gridTabCellSynchronizer.js'; +import { mapObjectToChartAndCell } from '../../../../../lib/services/layout/helpers/mapObjectToChartAndCell.js'; + +export const gridTabCellSynchronizerTestSuite = async () => { + suite('GridTabCellSynchronizer Test Suite', () => { + let mockGridTabCellRepository = null; + let mockChartRepository = null; + let mockChartOptionsSynchronizer = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + // Mock repositories + mockGridTabCellRepository = { + findByTabId: () => Promise.resolve([]), + 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', () => { + const gridTabCellRepo = { test: 'gridTabCellRepo' }; + const chartRepo = { test: 'chartRepo' }; + const chartOptionsSync = { test: 'chartOptionsSync' }; + const sync = new GridTabCellSynchronizer(gridTabCellRepo, chartRepo, chartOptionsSync); + + strictEqual(sync._gridTabCellRepository, gridTabCellRepo); + strictEqual(sync._chartRepository, chartRepo); + strictEqual(sync._chartOptionsSynchronizer, chartOptionsSync); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should create new charts and cells when none exist', async () => { + const tabId = 'test-tab'; + const objects = [{ id: 1, name: 'New Chart' }]; + const createdCharts = []; + const createdCells = []; + + mockGridTabCellRepository.findByTabId = () => Promise.resolve([]); + mockChartRepository.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 and rollback when operation fails', async () => { + const tabId = 'test-tab'; + const objects = []; + const error = new Error('Database connection failed'); + let rollbackCalled = false; + + mockGridTabCellRepository.findByTabId = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects( + async () => await synchronizer.sync(tabId, objects, mockTransaction), + error, + ); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + + suite('map to chart and cell function', () => { + const mockObject = { + id: 'chart1', + x: 0, + y: 0, + h: 2, + w: 3, + name: 'Test Chart', + ignoreDefaults: true, + }; + const mockTabId = 'tab1'; + test('should map object to chart and cell correctly', () => { + const { chart, cell } = mapObjectToChartAndCell(mockObject, mockTabId); + strictEqual(chart.id, 'chart1'); + strictEqual(chart.object_name, 'Test Chart'); + strictEqual(chart.ignore_defaults, true); + strictEqual(cell.tab_id, 'tab1'); + strictEqual(cell.chart_id, 'chart1'); + strictEqual(cell.row, 0); + strictEqual(cell.col, 0); + strictEqual(cell.row_span, 2); + strictEqual(cell.col_span, 3); + }); + test('should throw error for invalid input', () => { + throws(() => mapObjectToChartAndCell(null, mockTabId), /Invalid input/); + throws(() => mapObjectToChartAndCell(mockObject, null), /Invalid input/); + throws(() => mapObjectToChartAndCell('invalid', mockTabId), /Invalid input/); + }); + }); + }); +}; diff --git a/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js new file mode 100644 index 000000000..bc8d2371d --- /dev/null +++ b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { strictEqual, rejects } from 'node:assert'; +import { suite, test, beforeEach } from 'node:test'; + +import { TabSynchronizer } from '../../../../../lib/services/layout/helpers/tabSynchronizer.js'; + +export const tabSynchronizerTestSuite = async () => { + suite('TabSynchronizer Test Suite', () => { + let mockTabRepository = null; + let mockGridTabCellSynchronizer = null; + let mockTransaction = null; + let synchronizer = null; + + beforeEach(() => { + mockTabRepository = { + findTabsByLayoutId: () => Promise.resolve([]), + 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', () => { + const tabRepo = { test: 'tabRepo' }; + const gridSync = { test: 'gridSync' }; + const sync = new TabSynchronizer(tabRepo, gridSync); + + strictEqual(sync._tabRepository, tabRepo); + strictEqual(sync._gridTabCellSynchronizer, gridSync); + strictEqual(typeof sync._logger, 'object'); + }); + }); + + suite('sync() method', () => { + test('should create new tabs when none exist', async () => { + const layoutId = 'layout-1'; + const tabs = [{ name: 'New Tab', objects: [] }]; + const createdTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); + mockTabRepository.createTab = (tab) => { + createdTabs.push(tab); + return Promise.resolve({ id: 1 }); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(createdTabs.length, 1); + strictEqual(createdTabs[0].layout_id, layoutId); + }); + + test('should update existing tabs', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 1, name: 'Updated Tab', objects: [] }]; + const updatedTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([{ id: 1 }]); + mockTabRepository.updateTab = (id, tab) => { + updatedTabs.push({ id, tab }); + return Promise.resolve(1); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(updatedTabs.length, 1); + strictEqual(updatedTabs[0].id, 1); + strictEqual(updatedTabs[0].tab.layout_id, layoutId); + }); + + test('should delete tabs that are no longer present', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 2, name: 'Keep Tab' }]; + const deletedTabs = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([ + { id: 1 }, // Should be deleted + { id: 2 }, // Should remain + ]); + mockTabRepository.delete = (id) => { + deletedTabs.push(id); + return Promise.resolve(1); + }; + mockTabRepository.updateTab = () => Promise.resolve(1); + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(deletedTabs.length, 1); + strictEqual(deletedTabs[0], 1); + }); + + test('should sync grid tab cells when tab has objects', async () => { + const layoutId = 'layout-1'; + const tabs = [{ id: 1, name: 'Tab with objects', objects: [{ id: 'obj1' }] }]; + const syncCalls = []; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([{ id: 1 }]); + mockTabRepository.updateTab = () => Promise.resolve(1); + mockGridTabCellSynchronizer.sync = (tabId, objects, _transaction) => { + syncCalls.push({ tabId, objects }); + return Promise.resolve(); + }; + + await synchronizer.sync(layoutId, tabs, mockTransaction); + + strictEqual(syncCalls.length, 1); + strictEqual(syncCalls[0].tabId, 1); + strictEqual(syncCalls[0].objects.length, 1); + }); + + test('should throw error and rollback when operation fails', async () => { + const layoutId = 'layout-1'; + const tabs = [{ name: 'New Tab' }]; + const error = new Error('Database error'); + let rollbackCalled = false; + + mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); + mockTabRepository.createTab = () => Promise.reject(error); + mockTransaction.rollback = () => { + rollbackCalled = true; + }; + + await rejects(synchronizer.sync(layoutId, tabs, mockTransaction), error); + strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + }); + }); + }); +}; diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 964518733..c8731c9f7 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -57,6 +57,14 @@ import { objectControllerTestSuite } from './lib/controllers/ObjectController.te import { ccdbServiceTestSuite } from './lib/services/CcdbService.test.js'; import { statusServiceTestSuite } from './lib/services/StatusService.test.js'; import { bookkeepingServiceTestSuite } from './lib/services/BookkeepingService.test.js'; +import { 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 { commonLibraryQcObjectUtilsTestSuite } from './common/library/qcObject/utils.test.js'; import { commonLibraryUtilsDateTimeTestSuite } from './common/library/utils/dateTimeFormat.test.js'; @@ -70,10 +78,8 @@ import { apiPutLayoutTests } from './api/layouts/api-put-layout.test.js'; import { apiPatchLayoutTests } from './api/layouts/api-patch-layout.test.js'; import { layoutRepositoryTest } from './lib/repositories/LayoutRepository.test.js'; import { userRepositoryTest } from './lib/repositories/UserRepository.test.js'; -import { jsonFileServiceTestSuite } from './lib/services/JsonFileService.test.js'; import { userControllerTestSuite } from './lib/controllers/UserController.test.js'; import { chartRepositoryTest } from './lib/repositories/ChartRepository.test.js'; -import { filterServiceTestSuite } from './lib/services/FilterService.test.js'; import { apiGetLayoutsTests } from './api/layouts/api-get-layout.test.js'; import { apiGetObjectsTests } from './api/objects/api-get-object.test.js'; import { objectsGetValidationMiddlewareTest } from './lib/middlewares/objects/objectsGetValidation.middleware.test.js'; @@ -82,11 +88,8 @@ import { objectGetContentsValidationMiddlewareTest } 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'; 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 @@ -223,6 +226,11 @@ 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(); + }); }); suite('Middleware - Test Suite', async () => { From 380002b7f2e5179dfc8f968e2ea751fa24c44c15 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:06:27 +0200 Subject: [PATCH 05/35] make database intialization optional --- QualityControl/lib/QCModel.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index 3131fee3b..2f4c823ac 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -65,18 +65,17 @@ export const setupQcModel = async (eventEmitter) => { const jsonFileService = new JsonFileService(config.dbFile || `${__dirname}/../db.json`); - try { - const databaseConfig = config.database || {}; - if (!databaseConfig || Object.keys(databaseConfig).length === 0) { - logger.errorMessage('Database configuration is not provided. The application cannot be initialized'); - throw new Error('Database configuration is missing'); + const databaseConfig = config.database || {}; + if (Object.keys(databaseConfig).length > 0) { + try { + const sequelizeDatabase = new SequelizeDatabase(databaseConfig); + await initDatabase(sequelizeDatabase, { forceSeed: config?.database?.forceSeed, drop: config?.database?.drop }); + logger.infoMessage('Database initialized successfully'); + } catch (error) { + logger.errorMessage(`Database initialization failed: ${error.message}`); } - - const sequelizeDatabase = new SequelizeDatabase(databaseConfig); - initDatabase(sequelizeDatabase, { forceSeed: config?.database?.forceSeed, drop: config?.database?.drop }); - } catch (error) { - logger.errorMessage(`Database initialization failed: ${error.message}`); - throw error; + } else { + logger.warnMessage('No database configuration found, skipping database initialization'); } if (config?.kafka?.enabled) { From 8a05af1963c7e9439aece57429bc12d5ebfc77eb Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:33:25 +0200 Subject: [PATCH 06/35] update readme --- QualityControl/docs/Configuration.md | 30 ++++++----- QualityControl/docs/Database.md | 79 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 QualityControl/docs/Database.md 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 From a4f8b280183da0e859ce31ae5c1675a746f9579b Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:33:38 +0200 Subject: [PATCH 07/35] add jsdoc to seeders --- .../database/seeders/20250930071301-seed-users.mjs | 10 ++++++++++ .../database/seeders/20250930071308-seed-layouts.mjs | 10 ++++++++++ .../lib/database/seeders/20250930071313-seed-tabs.mjs | 11 +++++++++++ .../database/seeders/20250930071317-seed-charts.mjs | 11 +++++++++++ .../seeders/20250930071322-seed-gridtabcells.mjs | 10 ++++++++++ .../seeders/20250930071334-seed-chart-options.mjs | 8 +++++++- 6 files changed, 59 insertions(+), 1 deletion(-) diff --git a/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs index ddbc1ac9e..d56763f5b 100644 --- a/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs +++ b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs @@ -14,6 +14,12 @@ 'use strict'; +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed users + * @param {QueryInterface} queryInterface - The query interface + */ export const up = async (queryInterface) => { await queryInterface.bulkInsert('users', [ { @@ -23,6 +29,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 index 9fe89eaf6..9fc6242c4 100644 --- a/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs +++ b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs @@ -14,6 +14,12 @@ 'use strict'; +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed layouts + * @param {QueryInterface} queryInterface - The query interface + */ export const up = async (queryInterface) => { await queryInterface.bulkInsert('layouts', [ { @@ -37,6 +43,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 index 1c94650bd..f25727121 100644 --- a/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs +++ b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs @@ -13,6 +13,13 @@ 'use strict'; +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed tabs + * @param {QueryInterface} queryInterface - The query interface + */ + export const up = async (queryInterface) => { await queryInterface.bulkInsert('tabs', [ { @@ -43,6 +50,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 index 301484a85..f0653b973 100644 --- a/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs +++ b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs @@ -13,6 +13,13 @@ 'use strict'; +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + +/** + * Seed charts + * @param {QueryInterface} queryInterface - The query interface + */ + export const up = async (queryInterface) => { await queryInterface.bulkInsert('charts', [ { @@ -48,6 +55,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 index 30f317c0c..adf8211ab 100644 --- a/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs +++ b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs @@ -13,6 +13,12 @@ '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', [ { @@ -74,6 +80,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 index 216a6a070..c3a96be72 100644 --- a/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs +++ b/QualityControl/lib/database/seeders/20250930071334-seed-chart-options.mjs @@ -12,9 +12,11 @@ */ 'use strict'; +/** @typedef {import('sequelize').QueryInterface} QueryInterface */ + /** * Seed chart options - * @param {*} queryInterface - The query interface + * @param {QueryInterface} queryInterface - The query interface */ export const up = async (queryInterface) => { await queryInterface.bulkInsert('chart_options', [ @@ -53,6 +55,10 @@ export const up = async (queryInterface) => { ], {}); }; +/** + * 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 }); From a5bf4144150eadfbdfeb6355eddcef988c159432 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:24:47 +0200 Subject: [PATCH 08/35] create services to interact with the repositories --- .../lib/services/QcObject.service.js | 22 +- .../lib/services/layout/LayoutService.js | 244 +++++++++++++++ .../lib/services/layout/UserService.js | 108 +++++++ .../services/layout/helpers/layoutMapper.js | 32 +- .../lib/services/layout/LayoutService.test.js | 284 ++++++++++++++++++ .../lib/services/layout/UserService.test.js | 151 ++++++++++ QualityControl/test/test-index.js | 4 + 7 files changed, 808 insertions(+), 37 deletions(-) create mode 100644 QualityControl/lib/services/layout/LayoutService.js create mode 100644 QualityControl/lib/services/layout/UserService.js create mode 100644 QualityControl/test/lib/services/layout/LayoutService.test.js create mode 100644 QualityControl/test/lib/services/layout/UserService.test.js diff --git a/QualityControl/lib/services/QcObject.service.js b/QualityControl/lib/services/QcObject.service.js index b6d405ad1..50aabaef8 100644 --- a/QualityControl/lib/services/QcObject.service.js +++ b/QualityControl/lib/services/QcObject.service.js @@ -18,7 +18,7 @@ import QCObjectDto from '../dtos/QCObjectDto.js'; import QcObjectIdentificationDto from '../dtos/QcObjectIdentificationDto.js'; /** - * @typedef {import('../repositories/ChartRepository.js').ChartRepository} ChartRepository + * @typedef {import('./layout/LayoutService.js').LayoutService} LayoutService */ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/obj-service`; @@ -31,19 +31,19 @@ export class QcObjectService { /** * Setup service constructor and initialize needed dependencies * @param {CcdbService} dbService - CCDB service to retrieve raw information about the QC objects - * @param {ChartRepository} chartRepository - service to be used for retrieving configurations on saved layouts + * @param {LayoutService} layoutService - service to be used for retrieving configurations on saved layouts * @param {RootService} rootService - root library to be used for interacting with ROOT Objects */ - constructor(dbService, chartRepository, rootService) { + constructor(dbService, layoutService, rootService) { /** * @type {CcdbService} */ this._dbService = dbService; /** - * @type {ChartRepository} + * @type {LayoutService} */ - this._chartRepository = chartRepository; + this._layoutService = layoutService; /** * @type {RootService} @@ -181,15 +181,13 @@ export class QcObjectService { * @param {number|null} options.validFrom - timestamp in ms * @param {object} options.filters - filter as string to be sent to CCDB * @returns {Promise} - QC objects with information CCDB and root - * @throws {Error} - if object with specified id is not found */ async retrieveQcObjectByQcgId({ qcObjectId, id, validFrom = undefined, filters = {} }) { - const result = this._chartRepository.getObjectById(qcObjectId); - if (!result) { - throw new Error(`Object with id ${qcObjectId} not found`); - } - const { object, layoutName, tabName } = result; - const { name, options = {}, ignoreDefaults = false } = object; + const object = await this._layoutService.getObjectById(qcObjectId); + const { tab, chart } = object; + const { name: tabName, layout } = tab; + const { name: layoutName } = layout; + const { object_name: name, ignore_defaults: ignoreDefaults, chartOptions: options } = chart; const qcObject = await this.retrieveQcObject({ path: name, validFrom, id, filters }); return { ...qcObject, diff --git a/QualityControl/lib/services/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js new file mode 100644 index 000000000..ff7dd9e9c --- /dev/null +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -0,0 +1,244 @@ +/** + * @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'; + +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 {GridTabCellRepository} gridTabCellRepository Grid tab cell repository instance + * @param {UserService} userService User service instance + * @param {TabSynchronizer} tabSynchronizer Tab synchronizer instance + */ + constructor( + layoutRepository, + gridTabCellRepository, + userService, + tabSynchronizer, + ) { + this._logger = LogManager.getLogger(LOG_FACILITY); + this._layoutRepository = layoutRepository; + this._gridTabCellRepository = gridTabCellRepository; + this._userService = userService; + this._tabSynchronizer = tabSynchronizer; + } + + /** + * 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) { + 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 { + const layoutFoundById = await this._layoutRepository.findById(Number(id)); + const layoutFoundByOldId = await this._layoutRepository.findOne({ old_id: String(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; + } + } + + /** + * 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); + } + 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(); + } 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) { + try { + const updatedCount = await this._layoutRepository.updateLayout(layoutId, updateData, { transaction }); + if (updatedCount === 0) { + throw new NotFoundError(`Layout with id ${layoutId} not found`); + } + } catch (error) { + this._logger.errorMessage(`Error in _updateLayout: ${error.message || error}`); + throw error; + } + } + + /** + * 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 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/layoutMapper.js b/QualityControl/lib/services/layout/helpers/layoutMapper.js index b5c3a61dd..2fb16e53b 100644 --- a/QualityControl/lib/services/layout/helpers/layoutMapper.js +++ b/QualityControl/lib/services/layout/helpers/layoutMapper.js @@ -12,33 +12,24 @@ * or submit itself to any jurisdiction. */ -import { LogManager } from '@aliceo2/web-ui'; - -/** - * @typedef {import('../../../services/layout/UserService.js').UserService} UserService - */ - -const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/layout-mapper`; - /** * Helper to normalize layout data - * @param {*} patch partial layout data - * @param {*} layout original layout - * @param {*} isFull if true, patch is a full layout - * @param {*} userService user service to get username from id - * @returns + * @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, userService) => { - const logger = LogManager.getLogger(LOG_FACILITY); +export const normalizeLayout = async (patch, layout = {}, isFull = false) => { const source = isFull ? { ...layout, ...patch } : patch; const fieldMap = { - id: 'id', 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]) => { @@ -48,14 +39,5 @@ export const normalizeLayout = async (patch, layout = {}, isFull = false, userSe return acc; }, {}); - if ('owner_id' in source && userService?.getUsernameById) { - try { - const username = await userService.getUsernameById(source.owner_id); - data.owner_username = username; - } catch (error) { - logger.errorMessage('Failed to get username by id', error); - } - } - return data; }; 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..e915a971a --- /dev/null +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -0,0 +1,284 @@ +/** + * @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'; +import * as layoutMapper from '../../../../lib/services/layout/helpers/layoutMapper.js'; + +export const layoutServiceTestSuite = async () => { + suite('LayoutService Test Suite', () => { + let layoutService = null; + let layoutRepositoryMock = null; + let gridTabCellRepositoryMock = null; + let userServiceMock = null; + let tabSynchronizerMock = null; + const transactionMock = { commit: stub().resolves(), rollback: stub().resolves() }; + + beforeEach(() => { + layoutRepositoryMock = { + findById: stub(), + findOne: stub(), + model: { sequelize: { transaction: stub().resolves() } }, + updateLayout: stub(), + createLayout: stub(), + delete: stub(), + }; + userServiceMock = { getUsernameById: stub() }; + tabSynchronizerMock = { sync: stub() }; + gridTabCellRepositoryMock = { + findObjectByChartId: stub(), + }; + layoutService = new LayoutService( + layoutRepositoryMock, + gridTabCellRepositoryMock, + userServiceMock, + tabSynchronizerMock, + ); + }); + + 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); + tabSynchronizerMock.sync.resolves(); + const result = await layoutService.putLayout(123456, updatedData); + strictEqual(result, 123456); + strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); + strictEqual(tabSynchronizerMock.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 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); + tabSynchronizerMock.sync.resolves(); + const normalizeLayoutStub = stub(layoutMapper, 'normalizeLayout').resolves(normalizedLayout); + + await layoutService.patchLayout(123456, updateData); + strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); + strictEqual(tabSynchronizerMock.sync.called, false); + strictEqual(transactionMock.commit.called, true); + strictEqual(transactionMock.rollback.called, false); + normalizeLayoutStub.restore(); + }); + 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')); + }); + }); + suite('postLayout', () => { + test('should create new layout', async () => { + const layoutData = { + name: 'New Layout', + description: 'Layout Description', + displayTimestamp: true, + autoTabChange: 5, + ownerUsername: 'alice_username', + tabs: [{ name: 'Tab 1' }], + }; + const normalizedLayout = { + name: 'New Layout', + description: 'Layout Description', + display_timestamp: true, + auto_tab_change_interval: 5, + owner_username: 'alice_username', + }; + const createdLayout = { id: 1, ...normalizedLayout }; + layoutRepositoryMock.createLayout.resolves(createdLayout); + tabSynchronizerMock.sync.resolves(); + const normalizeLayoutStub = stub(layoutMapper, 'normalizeLayout').resolves(normalizedLayout); + + const result = await layoutService.postLayout(layoutData); + strictEqual(result, createdLayout); + strictEqual(layoutRepositoryMock.createLayout.calledWith(normalizedLayout), true); + strictEqual(tabSynchronizerMock.sync.calledWith(createdLayout.id, layoutData.tabs), true); + strictEqual(transactionMock.commit.called, true); + strictEqual(transactionMock.rollback.called, false); + normalizeLayoutStub.restore(); + }); + 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('Failed to create new layout')); + 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/test-index.js b/QualityControl/test/test-index.js index 30f498cc0..093525c33 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -65,6 +65,8 @@ 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 @@ -245,6 +247,8 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn await tabSynchronizerTestSuite(); await gridTabCellSynchronizerTestSuite(); await chartOptionsSynchronizerTestSuite(); + await userServiceTestSuite(); + await layoutServiceTestSuite(); }); }); From a41f05fffb86745cee7a93373ea0ae361458d3f2 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 10 Oct 2025 17:16:29 +0200 Subject: [PATCH 09/35] Potential fix for code scanning alert no. 260: Superfluous trailing arguments Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../lib/services/layout/helpers/layoutMapper.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js index 1e21895c6..8e8f14f99 100644 --- a/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js +++ b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js @@ -37,7 +37,7 @@ export const layoutMapperTestSuite = async () => { test('should patch a layout correctly', async () => { const patch = { isOfficial: true }; - const result = await normalizeLayout(patch, baseLayout, false, mockUserService); + const result = await normalizeLayout(patch, baseLayout, false); deepStrictEqual(result, { is_official: true, }); @@ -53,7 +53,7 @@ export const layoutMapperTestSuite = async () => { owner_id: 2, }; - const result = await normalizeLayout(fullUpdate, baseLayout, true, mockUserService); + const result = await normalizeLayout(fullUpdate, baseLayout, true); deepStrictEqual(result, { id: 10, @@ -68,19 +68,19 @@ export const layoutMapperTestSuite = async () => { test('should handle missing userService', async () => { const patch = { owner_id: 1 }; - const result = await normalizeLayout(patch, baseLayout, false, null); + const result = await normalizeLayout(patch, baseLayout, false); deepStrictEqual(result, {}); }); test('should handle missing fields', async () => { const patch = {}; - const result = await normalizeLayout(patch, baseLayout, false, mockUserService); + 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, mockUserService); + const result = await normalizeLayout(patch, baseLayout, false); deepStrictEqual(result, { owner_username: null }); }); }); From 48beefb18edad6f95542431c02dda38ede8f64d1 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 10 Oct 2025 17:18:23 +0200 Subject: [PATCH 10/35] Potential fix for code scanning alert no. 265: Unused variable, import, function or class Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../test/lib/services/layout/helpers/layoutMapper.test.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js index 8e8f14f99..fd2d40ad7 100644 --- a/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js +++ b/QualityControl/test/lib/services/layout/helpers/layoutMapper.test.js @@ -18,12 +18,6 @@ import { suite, test } from 'node:test'; export const layoutMapperTestSuite = async () => { suite('layoutMapper tests suite', () => { - const mockUserService = { - getUsernameById: async (id) => { - const users = { 1: 'alice', 2: 'bob' }; - return users[id] || null; - }, - }; const baseLayout = { id: 10, From 2829840ae824525343c9da4e20c4fa69f0b2c1bf Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:54:30 +0200 Subject: [PATCH 11/35] Revert changes to QCObjectService since the services created in this PR are not used yet --- .../lib/services/QcObject.service.js | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/QualityControl/lib/services/QcObject.service.js b/QualityControl/lib/services/QcObject.service.js index 2aba4c050..b6d405ad1 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('./layout/LayoutService.js').LayoutService} LayoutService + * @typedef {import('../repositories/ChartRepository.js').ChartRepository} ChartRepository */ 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 {LayoutService} layoutService - service to be used for retrieving configurations on saved layouts + * @param {ChartRepository} chartRepository - service to be used for retrieving configurations on saved layouts * @param {RootService} rootService - root library to be used for interacting with ROOT Objects */ - constructor(dbService, layoutService, rootService) { + constructor(dbService, chartRepository, rootService) { /** * @type {CcdbService} */ this._dbService = dbService; /** - * @type {LayoutService} + * @type {ChartRepository} */ - this._layoutService = layoutService; + this._chartRepository = chartRepository; /** * @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 @@ -181,13 +181,15 @@ export class QcObjectService { * @param {number|null} options.validFrom - timestamp in ms * @param {object} options.filters - filter as string to be sent to CCDB * @returns {Promise} - QC objects with information CCDB and root + * @throws {Error} - if object with specified id is not found */ async retrieveQcObjectByQcgId({ qcObjectId, id, validFrom = undefined, filters = {} }) { - const 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: options } = chart; + 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 qcObject = await this.retrieveQcObject({ path: name, validFrom, id, filters }); return { ...qcObject, From 8a0404684c0d14b567e95092ef131bfd58ff528d Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:54:41 +0200 Subject: [PATCH 12/35] fix layout service tests --- .../lib/services/layout/LayoutService.test.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/QualityControl/test/lib/services/layout/LayoutService.test.js b/QualityControl/test/lib/services/layout/LayoutService.test.js index e915a971a..f8dcf11fd 100644 --- a/QualityControl/test/lib/services/layout/LayoutService.test.js +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -18,7 +18,6 @@ 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'; -import * as layoutMapper from '../../../../lib/services/layout/helpers/layoutMapper.js'; export const layoutServiceTestSuite = async () => { suite('LayoutService Test Suite', () => { @@ -27,13 +26,14 @@ export const layoutServiceTestSuite = async () => { let gridTabCellRepositoryMock = null; let userServiceMock = null; let tabSynchronizerMock = null; - const transactionMock = { commit: stub().resolves(), rollback: stub().resolves() }; + let transactionMock = { }; beforeEach(() => { + transactionMock = { commit: stub().resolves(), rollback: stub().resolves() }; layoutRepositoryMock = { findById: stub(), findOne: stub(), - model: { sequelize: { transaction: stub().resolves() } }, + model: { sequelize: { transaction: () => transactionMock } }, updateLayout: stub(), createLayout: stub(), delete: stub(), @@ -166,7 +166,7 @@ export const layoutServiceTestSuite = async () => { layoutRepositoryMock.findById.resolves(null); await rejects(async () => { await layoutService.putLayout(999, { name: 'Nonexistent Layout' }); - }, new NotFoundError('Layout with id 999 not found')); + }, new NotFoundError('Layout with id: 999 was not found')); strictEqual(transactionMock.rollback.called, true); strictEqual(transactionMock.commit.called, false); }); @@ -198,14 +198,12 @@ export const layoutServiceTestSuite = async () => { }); layoutRepositoryMock.updateLayout.resolves(1); tabSynchronizerMock.sync.resolves(); - const normalizeLayoutStub = stub(layoutMapper, 'normalizeLayout').resolves(normalizedLayout); await layoutService.patchLayout(123456, updateData); strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); strictEqual(tabSynchronizerMock.sync.called, false); strictEqual(transactionMock.commit.called, true); strictEqual(transactionMock.rollback.called, false); - normalizeLayoutStub.restore(); }); test('should throw NotFoundError when layout to patch does not exist', async () => { layoutRepositoryMock.findById.resolves({ id: 123, name: 'Existing Layout' }); @@ -238,7 +236,7 @@ export const layoutServiceTestSuite = async () => { layoutRepositoryMock.delete.resolves(0); await rejects(async () => { await layoutService.removeLayout(999); - }, new NotFoundError('Layout with id 999 not found')); + }, new NotFoundError('Layout with id 999 not found for deletion')); }); }); suite('postLayout', () => { @@ -261,7 +259,6 @@ export const layoutServiceTestSuite = async () => { const createdLayout = { id: 1, ...normalizedLayout }; layoutRepositoryMock.createLayout.resolves(createdLayout); tabSynchronizerMock.sync.resolves(); - const normalizeLayoutStub = stub(layoutMapper, 'normalizeLayout').resolves(normalizedLayout); const result = await layoutService.postLayout(layoutData); strictEqual(result, createdLayout); @@ -269,13 +266,12 @@ export const layoutServiceTestSuite = async () => { strictEqual(tabSynchronizerMock.sync.calledWith(createdLayout.id, layoutData.tabs), true); strictEqual(transactionMock.commit.called, true); strictEqual(transactionMock.rollback.called, false); - normalizeLayoutStub.restore(); }); 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('Failed to create new layout')); + }, new Error('DB error')); strictEqual(transactionMock.rollback.called, true); strictEqual(transactionMock.commit.called, false); }); From c5d2f50faabbbd9cdc2be5a629ff47bf81440696 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:06:00 +0200 Subject: [PATCH 13/35] Synchronizers initialization --- .../lib/services/layout/LayoutService.js | 30 +++++++++++++++-- .../lib/services/layout/LayoutService.test.js | 33 +++++++++++++------ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/QualityControl/lib/services/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js index ff7dd9e9c..0fc6cb980 100644 --- a/QualityControl/lib/services/layout/LayoutService.js +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -14,6 +14,9 @@ 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`; @@ -31,21 +34,42 @@ 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, - gridTabCellRepository, userService, - tabSynchronizer, + tabRepository, + gridTabCellRepository, + chartRepository, + chartOptionRepository, + optionRepository, ) { this._logger = LogManager.getLogger(LOG_FACILITY); this._layoutRepository = layoutRepository; this._gridTabCellRepository = gridTabCellRepository; this._userService = userService; - this._tabSynchronizer = tabSynchronizer; + + // Synchronizers + this._chartOptionsSynchronizer = new ChartOptionsSynchronizer( + chartOptionRepository, + optionRepository, + ); + this._gridTabCellSynchronizer = new GridTabCellSynchronizer( + gridTabCellRepository, + chartRepository, + this._chartOptionsSynchronizer, + ); + this._tabSynchronizer = new TabSynchronizer( + tabRepository, + this._gridTabCellSynchronizer, + ); } /** diff --git a/QualityControl/test/lib/services/layout/LayoutService.test.js b/QualityControl/test/lib/services/layout/LayoutService.test.js index f8dcf11fd..1a2ed5b75 100644 --- a/QualityControl/test/lib/services/layout/LayoutService.test.js +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -25,7 +25,10 @@ export const layoutServiceTestSuite = async () => { let layoutRepositoryMock = null; let gridTabCellRepositoryMock = null; let userServiceMock = null; - let tabSynchronizerMock = null; + let chartRepositoryMock = null; + let chartOptionsRepositoryMock = null; + let optionRepositoryMock = null; + let tabRepositoryMock = null; let transactionMock = { }; beforeEach(() => { @@ -39,16 +42,26 @@ export const layoutServiceTestSuite = async () => { delete: stub(), }; userServiceMock = { getUsernameById: stub() }; - tabSynchronizerMock = { sync: stub() }; gridTabCellRepositoryMock = { findObjectByChartId: stub(), }; + chartRepositoryMock = {}; + chartOptionsRepositoryMock = {}; + optionRepositoryMock = {}; + tabRepositoryMock = {}; + layoutService = new LayoutService( layoutRepositoryMock, - gridTabCellRepositoryMock, userServiceMock, - tabSynchronizerMock, + tabRepositoryMock, + gridTabCellRepositoryMock, + chartRepositoryMock, + chartOptionsRepositoryMock, + optionRepositoryMock, ); + layoutService._tabSynchronizer = { + sync: stub(), + }; }); suite('getLayoutById', () => { @@ -154,11 +167,11 @@ export const layoutServiceTestSuite = async () => { tabs: [{ id: 1, name: 'Tab 1' }], }); layoutRepositoryMock.updateLayout.resolves(1); - tabSynchronizerMock.sync.resolves(); + layoutService._tabSynchronizer.sync.resolves(); const result = await layoutService.putLayout(123456, updatedData); strictEqual(result, 123456); strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); - strictEqual(tabSynchronizerMock.sync.calledWith(123456, updatedData.tabs), true); + strictEqual(layoutService._tabSynchronizer.sync.calledWith(123456, updatedData.tabs), true); strictEqual(transactionMock.commit.called, true); strictEqual(transactionMock.rollback.called, false); }); @@ -197,11 +210,11 @@ export const layoutServiceTestSuite = async () => { tabs: [{ id: 1, name: 'Tab 1' }], }); layoutRepositoryMock.updateLayout.resolves(1); - tabSynchronizerMock.sync.resolves(); + layoutService._tabSynchronizer.sync.resolves(); await layoutService.patchLayout(123456, updateData); strictEqual(layoutRepositoryMock.updateLayout.calledWith(123456, normalizedLayout), true); - strictEqual(tabSynchronizerMock.sync.called, false); + strictEqual(layoutService._tabSynchronizer.sync.called, false); strictEqual(transactionMock.commit.called, true); strictEqual(transactionMock.rollback.called, false); }); @@ -258,12 +271,12 @@ export const layoutServiceTestSuite = async () => { }; const createdLayout = { id: 1, ...normalizedLayout }; layoutRepositoryMock.createLayout.resolves(createdLayout); - tabSynchronizerMock.sync.resolves(); + layoutService._tabSynchronizer.sync.resolves(); const result = await layoutService.postLayout(layoutData); strictEqual(result, createdLayout); strictEqual(layoutRepositoryMock.createLayout.calledWith(normalizedLayout), true); - strictEqual(tabSynchronizerMock.sync.calledWith(createdLayout.id, layoutData.tabs), true); + strictEqual(layoutService._tabSynchronizer.sync.calledWith(createdLayout.id, layoutData.tabs), true); strictEqual(transactionMock.commit.called, true); strictEqual(transactionMock.rollback.called, false); }); From d61844de573dca5b1c4d1c7c8f8b23140c01ae00 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:24:21 +0200 Subject: [PATCH 14/35] adapt User controller --- .../lib/controllers/UserController.js | 22 ++++++++--------- .../lib/controllers/UserController.test.js | 24 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) 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/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, From fb85db38825a38839bc406d62eb5853feedccd77 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:37:52 +0200 Subject: [PATCH 15/35] updated layout controller --- .../lib/controllers/LayoutController.js | 97 +- .../lib/controllers/helpers/mapLayoutToAPI.js | 66 ++ .../test/demoData/layout/layout.mock.js | 104 +++ .../lib/controllers/LayoutController.test.js | 838 ++++-------------- .../helpers/mapLayoutToAPI.test.js | 43 + 5 files changed, 443 insertions(+), 705 deletions(-) create mode 100644 QualityControl/lib/controllers/helpers/mapLayoutToAPI.js create mode 100644 QualityControl/test/lib/controllers/helpers/mapLayoutToAPI.test.js diff --git a/QualityControl/lib/controllers/LayoutController.js b/QualityControl/lib/controllers/LayoutController.js index c014da497..7b17104f7 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 ? { 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,7 +124,7 @@ export class LayoutController { return; } try { - const layout = await this._layoutRepository.readLayoutByName(layoutName); + const layout = await this._layoutService.getLayoutByName(layoutName); res.status(200).json(layout); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); @@ -143,9 +141,9 @@ export class LayoutController { */ async putLayoutHandler(req, res) { const { id } = req.params; - let layoutProposed = {}; + let layoutProposed = req.body; try { - layoutProposed = await LayoutDto.validateAsync(req.body); + layoutProposed = await LayoutDto.validateAsync({ ...layoutProposed }); } catch (error) { updateAndSendExpressResponseFromNativeError( res, @@ -154,17 +152,8 @@ export class LayoutController { 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(id, layoutProposed); + res.status(200).json({ id: updatedLayoutId }); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); } @@ -179,10 +168,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 +193,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); } } @@ -238,16 +219,10 @@ export class LayoutController { 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}`)); + const updatedLayoutId = await this._layoutService.patchLayout(id, layout); + res.status(200).json({ id: updatedLayoutId }); + } catch (error) { + updateAndSendExpressResponseFromNativeError(res, error); return; } } 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/test/demoData/layout/layout.mock.js b/QualityControl/test/demoData/layout/layout.mock.js index 041c01a3e..53f3f4491 100644 --- a/QualityControl/test/demoData/layout/layout.mock.js +++ b/QualityControl/test/demoData/layout/layout.mock.js @@ -269,3 +269,107 @@ 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: [], +}; diff --git a/QualityControl/test/lib/controllers/LayoutController.test.js b/QualityControl/test/lib/controllers/LayoutController.test.js index f6411ce91..cb9699ad1 100644 --- a/QualityControl/test/lib/controllers/LayoutController.test.js +++ b/QualityControl/test/lib/controllers/LayoutController.test.js @@ -12,737 +12,287 @@ * 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, name: 'Owner 1' }, + tabs: [{ id: 1, name: 'Tab 1', gridTabCells: [] }], + is_official: true, + })); }); - - 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 = { + 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 update 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 = { + 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); - - 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); + layoutServiceMock.putLayout.resolves(10001); + await layoutController.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'); - }); - - 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.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(), - }; - }); + await layoutController.postLayoutHandler(req, res); - 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.status.calledWith(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: "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/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)); + }); + }); +}; From b28ed815f429281f6b1060c0837b53db90986db9 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:41:28 +0200 Subject: [PATCH 16/35] update middlewares --- .../middleware/layouts/layoutId.middleware.js | 46 ------------------- .../layouts/layoutOwner.middleware.js | 9 ++-- .../layouts/layoutService.middleware.js | 41 ----------------- QualityControl/test/test-index.js | 26 ++++++----- 4 files changed, 20 insertions(+), 102 deletions(-) delete mode 100644 QualityControl/lib/middleware/layouts/layoutId.middleware.js delete mode 100644 QualityControl/lib/middleware/layouts/layoutService.middleware.js 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..c9b5c208c 100644 --- a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js +++ b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js @@ -15,15 +15,15 @@ import { NotFoundError, UnauthorizedAccessError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; /** - * @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 @@ -35,7 +35,8 @@ export const layoutOwnerMiddleware = (layoutRepository) => try { const { id } = req.params; const { personid = '', name = '' } = req.session ?? {}; - const { owner_name = '', owner_id = '' } = await layoutRepository.readLayoutById(id) ?? {}; + const { owner } = await layoutService.getLayoutById(id) ?? {}; + const { id: owner_id, name: owner_name } = owner ?? {}; if (owner_id === '' || owner_name === '') { throw new NotFoundError('Unable to retrieve layout owner information'); } else if (personid === '' || name === '') { 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/test/test-index.js b/QualityControl/test/test-index.js index 965d346d0..a67f5729e 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -81,28 +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 { userControllerTestSuite } from './lib/controllers/UserController.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 { apiGetRunStatusTests } from './api/filters/api-get-run-status.test.js'; import { runModeTests } from './public/features/runMode.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 @@ -255,8 +258,6 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn }); 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()); @@ -268,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()); From 0947b23ecd2332dbc56868fbcd60bf8ae2df52b4 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:41:53 +0200 Subject: [PATCH 17/35] update qcobjectservice --- .../lib/services/QcObject.service.js | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/QualityControl/lib/services/QcObject.service.js b/QualityControl/lib/services/QcObject.service.js index b6d405ad1..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} @@ -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, From 63a130336fd2dbe00cac1e751ddea139321e1176 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:42:27 +0200 Subject: [PATCH 18/35] refactor api to use new services and updated middlewares --- QualityControl/lib/QCModel.js | 48 ++++++++++++++++++++++++----------- QualityControl/lib/api.js | 16 +++--------- 2 files changed, 36 insertions(+), 28 deletions(-) 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), ); From ddcd09e6c3f0627fbfe711aeac4194dc5e83e187 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:20:06 +0200 Subject: [PATCH 19/35] refactor layout middleware tests --- .../layouts/layoutOwner.middleware.js | 37 +++-- .../layouts/layoutId.middleware.test.js | 85 ----------- .../layouts/layoutOwner.middleware.test.js | 139 +++++++----------- .../layouts/layoutService.middleware.test.js | 70 --------- 4 files changed, 82 insertions(+), 249 deletions(-) delete mode 100644 QualityControl/test/lib/middlewares/layouts/layoutId.middleware.test.js delete mode 100644 QualityControl/test/lib/middlewares/layouts/layoutService.middleware.test.js diff --git a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js index c9b5c208c..a4a2a7190 100644 --- a/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js +++ b/QualityControl/lib/middleware/layouts/layoutOwner.middleware.js @@ -12,7 +12,13 @@ * 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('../../services/layout/LayoutService').LayoutService} LayoutService @@ -34,19 +40,32 @@ export const layoutOwnerMiddleware = (layoutService) => async (req, res, next) => { try { const { id } = req.params; - const { personid = '', name = '' } = req.session ?? {}; - const { owner } = await layoutService.getLayoutById(id) ?? {}; - const { id: owner_id, name: owner_name } = owner ?? {}; - 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/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'); - }, - ); - }); -}; From fb6251907949f4e9c490c5aeb7a102d11fc544c6 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:40:39 +0200 Subject: [PATCH 20/35] map ids properly --- QualityControl/lib/controllers/helpers/mapLayoutToAPI.js | 6 +++--- QualityControl/lib/dtos/LayoutDto.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js index 3811816c5..6e5508c30 100644 --- a/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js +++ b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js @@ -22,7 +22,7 @@ export function mapLayoutToAPI(layout, fields) { try { const layoutAdapted = { - id: layout.id, + id: layout.id?.toString(), name: layout.name, owner_id: layout.owner.id, owner_name: layout.owner.name, @@ -30,11 +30,11 @@ export function mapLayoutToAPI(layout, fields) { displayTimestamp: layout?.display_timestamp, autoTabChange: layout?.auto_tab_change_interval, tabs: layout.tabs.map((tab) => ({ - id: tab.id, + id: tab.id?.toString(), name: tab.name, columns: tab?.column_count || 2, objects: (tab.gridTabCells || []).map((cell) => ({ - id: cell.chart.id, + id: cell.chart.id?.toString(), x: cell.col || 0, y: cell.row || 0, h: cell.row_span || 1, diff --git a/QualityControl/lib/dtos/LayoutDto.js b/QualityControl/lib/dtos/LayoutDto.js index a285ddb94..6f93d2a84 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().required(), 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().required(), 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().required(), 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(), From 3ebb6d07bbb06d18d450db648ebdbe499c0a90b6 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:41:32 +0200 Subject: [PATCH 21/35] add root password for healthcheck access --- QualityControl/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a82e894bac6cbf2e849600730a2675e0ed983551 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:46:54 +0200 Subject: [PATCH 22/35] align seeders to tests --- .../migrations/20250424083717-create-tables.mjs | 1 + .../database/seeders/20250930071301-seed-users.mjs | 8 +++++++- .../seeders/20250930071308-seed-layouts.mjs | 9 +++++++++ .../database/seeders/20250930071313-seed-tabs.mjs | 12 ++++++++++++ .../seeders/20250930071317-seed-charts.mjs | 10 ++++++++++ .../seeders/20250930071322-seed-gridtabcells.mjs | 14 +++++++++++--- 6 files changed, 50 insertions(+), 4 deletions(-) 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/seeders/20250930071301-seed-users.mjs b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs index d56763f5b..fe7347b4c 100644 --- a/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs +++ b/QualityControl/lib/database/seeders/20250930071301-seed-users.mjs @@ -25,7 +25,13 @@ export const up = async (queryInterface) => { { id: 0, name: 'Anonymous', - username: 'anonymous' }, + username: 'anonymous', + }, + { + id: 99, + name: 'Some other owner', + username: 'some_other_owner', + }, ], {}); }; diff --git a/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs index 9fc6242c4..68b2541cc 100644 --- a/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs +++ b/QualityControl/lib/database/seeders/20250930071308-seed-layouts.mjs @@ -40,6 +40,15 @@ export const up = async (queryInterface) => { 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', + }, ], {}); }; diff --git a/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs index f25727121..2479c466f 100644 --- a/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs +++ b/QualityControl/lib/database/seeders/20250930071313-seed-tabs.mjs @@ -46,6 +46,18 @@ export const up = async (queryInterface) => { 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, + }, ], {}); }; diff --git a/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs index f0653b973..40502bdf6 100644 --- a/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs +++ b/QualityControl/lib/database/seeders/20250930071317-seed-charts.mjs @@ -52,6 +52,16 @@ export const up = async (queryInterface) => { 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, + }, ], {}); }; diff --git a/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs index adf8211ab..850bbe20c 100644 --- a/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs +++ b/QualityControl/lib/database/seeders/20250930071322-seed-gridtabcells.mjs @@ -70,10 +70,18 @@ export const up = async (queryInterface) => { col_span: 1, }, { - chart_id: 6, - row: 1, + chart_id: 7, + row: 0, col: 0, - tab_id: 3, + 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, }, From 68b93191f689dfa213844dba9aadeac3d23493c5 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:48:17 +0200 Subject: [PATCH 23/35] refactor LayoutService methods and add getLayoutByName --- .../lib/services/layout/LayoutService.js | 46 ++++++++++++++----- .../lib/services/layout/LayoutService.test.js | 2 +- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/QualityControl/lib/services/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js index 0fc6cb980..c12acd889 100644 --- a/QualityControl/lib/services/layout/LayoutService.js +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -79,7 +79,7 @@ export class LayoutService { */ async getLayoutsByFilters(filters = {}) { try { - if (filters.owner_id) { + if (filters.owner_id !== undefined) { filters = await this._addOwnerUsername(filters); } const layouts = await this._layoutRepository.findLayoutsByFilters(filters); @@ -115,8 +115,14 @@ export class LayoutService { */ async getLayoutById(id) { try { - const layoutFoundById = await this._layoutRepository.findById(Number(id)); - const layoutFoundByOldId = await this._layoutRepository.findOne({ old_id: String(id) }); + 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`); @@ -128,6 +134,25 @@ export class LayoutService { } } + /** + * 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 @@ -167,7 +192,7 @@ export class LayoutService { throw new NotFoundError(`Layout with id ${id} not found`); } if (updateData.tabs) { - await this._tabSynchronizer.sync(id, updateData.tabs); + await this._tabSynchronizer.sync(id, updateData.tabs, transaction); } await transaction.commit(); return id; @@ -197,6 +222,7 @@ export class LayoutService { 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}`); @@ -213,14 +239,10 @@ export class LayoutService { * @returns {Promise} */ async _updateLayout(layoutId, updateData, transaction) { - try { - const updatedCount = await this._layoutRepository.updateLayout(layoutId, updateData, { transaction }); - if (updatedCount === 0) { - throw new NotFoundError(`Layout with id ${layoutId} not found`); - } - } catch (error) { - this._logger.errorMessage(`Error in _updateLayout: ${error.message || error}`); - throw error; + 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`); } } diff --git a/QualityControl/test/lib/services/layout/LayoutService.test.js b/QualityControl/test/lib/services/layout/LayoutService.test.js index 1a2ed5b75..70ede3395 100644 --- a/QualityControl/test/lib/services/layout/LayoutService.test.js +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -209,7 +209,7 @@ export const layoutServiceTestSuite = async () => { is_official: false, tabs: [{ id: 1, name: 'Tab 1' }], }); - layoutRepositoryMock.updateLayout.resolves(1); + layoutRepositoryMock.updateLayout.resolves([1]); layoutService._tabSynchronizer.sync.resolves(); await layoutService.patchLayout(123456, updateData); From f5736c8631f7108696226dcbcca94dec695134a3 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:48:31 +0200 Subject: [PATCH 24/35] refactor tab/chart/grid synchronizers --- .../helpers/chartOptionsSynchronizer.js | 48 ++++--------- .../layout/helpers/gridTabCellSynchronizer.js | 69 +++++++------------ .../layout/helpers/tabSynchronizer.js | 68 +++++++++--------- .../helpers/ChartOptionsSynchronizer.test.js | 53 ++++++-------- .../helpers/GridTabCellSynchronizer.test.js | 30 +++----- .../layout/helpers/TabSynchronizer.test.js | 63 +++++++++++------ 6 files changed, 145 insertions(+), 186 deletions(-) diff --git a/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js index c4059e207..ceb3ff330 100644 --- a/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js +++ b/QualityControl/lib/services/layout/helpers/chartOptionsSynchronizer.js @@ -12,9 +12,7 @@ * or submit itself to any jurisdiction. */ -import { LogManager } from '@aliceo2/web-ui'; - -const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/chart-options-synchronizer`; +import { NotFoundError } from '@aliceo2/web-ui'; /** * @typedef {import('../../../database/repositories/ChartOptionsRepository.js') @@ -30,7 +28,6 @@ export class ChartOptionsSynchronizer { constructor(chartOptionRepository, optionsRepository) { this._chartOptionRepository = chartOptionRepository; this._optionsRepository = optionsRepository; - this._logger = LogManager.getLogger(LOG_FACILITY); } /** @@ -49,43 +46,28 @@ export class ChartOptionsSynchronizer { let incomingOptions = null; let incomingOptionIds = null; - try { - existingOptions = await this._chartOptionRepository.findChartOptionsByChartId(chart.id, { transaction }); - existingOptionIds = existingOptions.map((co) => co.option_id); - incomingOptions = await Promise.all(chart.options.map((o) => - this._optionsRepository.findOptionByName(o, { transaction }))); - incomingOptionIds = incomingOptions.map((o) => o.id); - } catch (error) { - this._logger.errorMessage(`Failed to fetch chart options: ${error.message}`); - await transaction.rollback(); - throw error; - } + 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) { - try { - await this._chartOptionRepository.delete({ chartId: chart.id, optionId }, { transaction }); - } catch (error) { - this._logger.errorMessage(`Failed to delete chart option: ${error.message}`); - transaction.rollback(); - throw error; + 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)) { - try { - const createdOption = await this._chartOptionRepository.create( - { chart_id: chart.id, option_id: option.id }, - { transaction }, - ); - if (!createdOption) { - throw new Error('Option creation returned null'); - } - } catch (error) { - this._logger.errorMessage(`Failed to create chart option: ${error.message}`); - transaction.rollback(); - throw error; + 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 index f4b542ab6..9dc2204cb 100644 --- a/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js +++ b/QualityControl/lib/services/layout/helpers/gridTabCellSynchronizer.js @@ -12,11 +12,9 @@ * or submit itself to any jurisdiction. */ -import { LogManager, NotFoundError } from '@aliceo2/web-ui'; +import { NotFoundError } from '@aliceo2/web-ui'; import { mapObjectToChartAndCell } from './mapObjectToChartAndCell.js'; -const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/grid-tab-cell-synchronizer`; - /** * Class to synchronize grid tab cells with the database. */ @@ -25,7 +23,6 @@ export class GridTabCellSynchronizer { this._gridTabCellRepository = gridTabCellRepository; this._chartRepository = chartRepository; this._chartOptionsSynchronizer = chartOptionsSynchronizer; - this._logger = LogManager.getLogger(LOG_FACILITY); } /** @@ -35,55 +32,39 @@ export class GridTabCellSynchronizer { * @param {object} transaction Sequelize transaction */ async sync(tabId, objects, transaction) { - this._logger.infoMessage(`[GridTabCellSynchronizer] syncing cells for tabId=${tabId}`); - - let existingCells = null; - try { - existingCells = await this._gridTabCellRepository.findByTabId(tabId, { transaction }); - } catch (error) { - this._logger.errorMessage(`Failed to fetch existing cells for tabId=${tabId}: ${error.message}`); - transaction.rollback(); - throw error; - } + const existingCells = await this._gridTabCellRepository.findByTabId(tabId, { transaction }); const existingChartIds = existingCells.map((cell) => cell.chart_id); - const incomingChartIds = objects.map((obj) => obj.id); - const toDelete = existingChartIds.filter((id) => !incomingChartIds.includes(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) { - try { - const deletedCount = await this._chartRepository.delete(chartId, { transaction }); - if (deletedCount === 0) { - throw new NotFoundError(`Chart with id=${chartId} not found for deletion`); - } - } catch (error) { - this._logger.errorMessage(`Failed to delete chartId=${chartId}: ${error.message}`); - transaction.rollback(); - throw error; + 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) { - try { - const { chart, cell } = mapObjectToChartAndCell(object, tabId); - if (existingChartIds.includes(chart.id)) { - const updatedRows = await this._chartRepository.update(chart.id, chart, { transaction }); - const updatedCells = + 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 }); - const createdCell = await this._gridTabCellRepository.create(cell, { transaction }); - if (!createdChart || !createdCell) { - throw new NotFoundError('Chart or cell not found for creation'); - } + 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 }, transaction); - } catch (error) { - this._logger.errorMessage(`Failed to sync chart/cell for object id=${object.id}: ${error.message}`); - transaction.rollback(); - throw error; } + await this._chartOptionsSynchronizer.sync({ ...chart, options: object?.options, id: chartId }, transaction); } } } diff --git a/QualityControl/lib/services/layout/helpers/tabSynchronizer.js b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js index 555b5b35f..f719c6448 100644 --- a/QualityControl/lib/services/layout/helpers/tabSynchronizer.js +++ b/QualityControl/lib/services/layout/helpers/tabSynchronizer.js @@ -12,8 +12,7 @@ * or submit itself to any jurisdiction. */ -const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/tab-synchronizer`; -import { LogManager, NotFoundError } from '@aliceo2/web-ui'; +import { NotFoundError } from '@aliceo2/web-ui'; /** * @typedef {import('../../database/repositories/TabRepository').TabRepository} TabRepository @@ -25,57 +24,52 @@ 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. - * @param {import('@aliceo2/web-ui').Logger} logger - Logger instance for logging operations. */ constructor(tabRepository, gridTabCellSynchronizer) { this._tabRepository = tabRepository; this._gridTabCellSynchronizer = gridTabCellSynchronizer; - this._logger = LogManager.getLogger(LOG_FACILITY); } /** - * Sincroniza tabs de un layout (upsert + delete) - * @param {string} layoutId - * @param {Array} tabs - * @param {object} transaction + * 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 incomingIds = tabs.filter((t) => t.id).map((t) => t.id); const existingTabs = await this._tabRepository.findTabsByLayoutId(layoutId, { transaction }); - const existingIds = existingTabs.map((t) => t.id); + const existingTabsByName = Object.fromEntries(existingTabs.map((t) => [t.name, t])); - const idsToDelete = existingIds.filter((id) => !incomingIds.includes(id)); - for (const id of idsToDelete) { - try { - const deletedCount = await this._tabRepository.delete(id, { transaction }); - if (deletedCount === 0) { - throw new NotFoundError(`Tab with id=${id} not found for deletion`); - } - } catch (error) { - this._logger.errorMessage(`Failed to delete tabId=${id}: ${error.message}`); - await transaction.rollback(); - throw error; + 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) { - tab.layout_id = layoutId; - try { - if (tab.id && existingIds.includes(tab.id)) { - await this._tabRepository.updateTab(tab.id, tab, { transaction }); - } else { - const tabRecord = await this._tabRepository.createTab(tab, { transaction }); - if (!tabRecord) { - throw new Error('Failed to create new tab'); - } + 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'); } - if (tab.objects && tab.objects.length) { - await this._gridTabCellSynchronizer.sync(tab.id, tab.objects, transaction); - } - } catch (error) { - this._logger.errorMessage(`Failed to upsert tab (id=${tab.id ?? 'new'}): ${error.message}`); - await transaction.rollback(); - throw error; + tab.id = tabRecord.id; + } + if (tab.objects && tab.objects.length) { + await this._gridTabCellSynchronizer.sync(tab.id, tab.objects, transaction); } } } diff --git a/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js index f5074ecdc..a03b89a0d 100644 --- a/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js +++ b/QualityControl/test/lib/services/layout/helpers/ChartOptionsSynchronizer.test.js @@ -42,13 +42,8 @@ export const chartOptionsSynchronizerTestSuite = async () => { suite('Constructor', () => { test('should successfully initialize ChartOptionsSynchronizer', () => { - const chartRepo = { test: 'chartRepo' }; - const optionsRepo = { test: 'optionsRepo' }; - const sync = new ChartOptionsSynchronizer(chartRepo, optionsRepo); - - strictEqual(sync._chartOptionRepository, chartRepo); - strictEqual(sync._optionsRepository, optionsRepo); - strictEqual(typeof sync._logger, 'object'); + strictEqual(synchronizer._chartOptionRepository, mockChartOptionRepository); + strictEqual(synchronizer._optionsRepository, mockOptionsRepository); }); }); @@ -193,40 +188,34 @@ export const chartOptionsSynchronizerTestSuite = async () => { }); test('should throw error when findOptionByName fails', async () => { - let rollbackCalled = false; const chart = { id: 1, options: ['option1'] }; - const error = new Error('Database connection failed'); mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); - mockOptionsRepository.findOptionByName = () => Promise.reject(error); - mockTransaction.rollback = () => { - rollbackCalled = true; - }; + mockOptionsRepository.findOptionByName = () => Promise.reject(new Error('DB error')); + await rejects( - async () => await synchronizer.sync(chart, mockTransaction), - error, + async () => { + await synchronizer.sync(chart, mockTransaction); + }, + { + message: 'DB error', + }, ); - strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); }); - test('should throw error when create fails', async () => { - const chart = { id: 1, options: ['option1'] }; - const error = new Error('Failed to create chart option'); - let rollbackCalled = false; + test('should throw error when delete operation fails', async () => { + const chart = { id: 1, options: ['Option1'] }; // provide at least one option - mockChartOptionRepository.findChartOptionsByChartId = () => Promise.resolve([]); - mockOptionsRepository.findOptionByName = () => Promise.resolve({ id: 10, name: 'option1' }); - mockChartOptionRepository.create = () => Promise.reject(error); + // 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 - mockTransaction.rollback = () => { - rollbackCalled = true; - }; - - await rejects( - async () => await synchronizer.sync(chart, mockTransaction), - error, - ); - strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + 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 index 49c7a2b2c..6d539c0d2 100644 --- a/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js +++ b/QualityControl/test/lib/services/layout/helpers/GridTabCellSynchronizer.test.js @@ -54,15 +54,9 @@ export const gridTabCellSynchronizerTestSuite = async () => { suite('Constructor', () => { test('should successfully initialize GridTabCellSynchronizer', () => { - const gridTabCellRepo = { test: 'gridTabCellRepo' }; - const chartRepo = { test: 'chartRepo' }; - const chartOptionsSync = { test: 'chartOptionsSync' }; - const sync = new GridTabCellSynchronizer(gridTabCellRepo, chartRepo, chartOptionsSync); - - strictEqual(sync._gridTabCellRepository, gridTabCellRepo); - strictEqual(sync._chartRepository, chartRepo); - strictEqual(sync._chartOptionsSynchronizer, chartOptionsSync); - strictEqual(typeof sync._logger, 'object'); + strictEqual(synchronizer._gridTabCellRepository, mockGridTabCellRepository); + strictEqual(synchronizer._chartRepository, mockChartRepository); + strictEqual(synchronizer._chartOptionsSynchronizer, mockChartOptionsSynchronizer); }); }); @@ -148,22 +142,18 @@ export const gridTabCellSynchronizerTestSuite = async () => { deepStrictEqual(syncCalls[0].options, ['option1']); }); - test('should throw error and rollback when operation fails', async () => { + test('should throw error when updating non-existing chart', async () => { const tabId = 'test-tab'; - const objects = []; - const error = new Error('Database connection failed'); - let rollbackCalled = false; + const objects = [{ id: 1, name: 'Non-existing Chart' }]; - mockGridTabCellRepository.findByTabId = () => Promise.reject(error); - mockTransaction.rollback = () => { - rollbackCalled = true; - }; + mockGridTabCellRepository.findByTabId = () => Promise.resolve([{ chart_id: 1 }]); + mockChartRepository.update = () => Promise.resolve(0); + mockGridTabCellRepository.update = () => Promise.resolve(0); await rejects( - async () => await synchronizer.sync(tabId, objects, mockTransaction), - error, + synchronizer.sync(tabId, objects, mockTransaction), + /Chart or cell not found for update/, ); - strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); }); }); diff --git a/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js index bc8d2371d..0d5c56a65 100644 --- a/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js +++ b/QualityControl/test/lib/services/layout/helpers/TabSynchronizer.test.js @@ -16,6 +16,7 @@ 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', () => { @@ -42,13 +43,8 @@ export const tabSynchronizerTestSuite = async () => { suite('Constructor', () => { test('should successfully initialize TabSynchronizer', () => { - const tabRepo = { test: 'tabRepo' }; - const gridSync = { test: 'gridSync' }; - const sync = new TabSynchronizer(tabRepo, gridSync); - - strictEqual(sync._tabRepository, tabRepo); - strictEqual(sync._gridTabCellSynchronizer, gridSync); - strictEqual(typeof sync._logger, 'object'); + strictEqual(synchronizer._tabRepository, mockTabRepository); + strictEqual(synchronizer._gridTabCellSynchronizer, mockGridTabCellSynchronizer); }); }); @@ -75,12 +71,17 @@ export const tabSynchronizerTestSuite = async () => { const tabs = [{ id: 1, name: 'Updated Tab', objects: [] }]; const updatedTabs = []; - mockTabRepository.findTabsByLayoutId = () => Promise.resolve([{ id: 1 }]); + 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); @@ -94,14 +95,17 @@ export const tabSynchronizerTestSuite = async () => { const deletedTabs = []; mockTabRepository.findTabsByLayoutId = () => Promise.resolve([ - { id: 1 }, // Should be deleted - { id: 2 }, // Should remain + { 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); @@ -128,20 +132,39 @@ export const tabSynchronizerTestSuite = async () => { strictEqual(syncCalls[0].objects.length, 1); }); - test('should throw error and rollback when operation fails', async () => { + test('should throw NotFoundError when delete returns 0', async () => { const layoutId = 'layout-1'; - const tabs = [{ name: 'New Tab' }]; - const error = new Error('Database error'); - let rollbackCalled = false; + const tabs = [{ id: 2, name: 'Keep Tab' }]; - mockTabRepository.findTabsByLayoutId = () => Promise.resolve([]); - mockTabRepository.createTab = () => Promise.reject(error); - mockTransaction.rollback = () => { - rollbackCalled = true; + 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), error); - strictEqual(rollbackCalled, true, 'Transaction should be rolled back on error'); + 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'), + ); }); }); }); From 50db69827bf37ca2c7793d1d89457dcc4bd21758 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:49:19 +0200 Subject: [PATCH 25/35] allow +-5ms margin --- .../services/external/AliEcsSynchronizer.test.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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); }); }; From 4844f8025cfdb5784dd87439ae089d801ef02506 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:31:41 +0200 Subject: [PATCH 26/35] refactor layout controller and id validation --- .../lib/controllers/LayoutController.js | 44 ++++++++++++------- .../lib/controllers/helpers/mapLayoutToAPI.js | 6 +-- QualityControl/lib/dtos/LayoutDto.js | 6 +-- .../lib/controllers/LayoutController.test.js | 17 ++++--- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/QualityControl/lib/controllers/LayoutController.js b/QualityControl/lib/controllers/LayoutController.js index 7b17104f7..cd7b8836c 100644 --- a/QualityControl/lib/controllers/LayoutController.js +++ b/QualityControl/lib/controllers/LayoutController.js @@ -74,7 +74,7 @@ export class LayoutController { } try { - const filters = owner_id ? { owner_id, ...filter } : { ...filter }; + const filters = owner_id !== undefined ? { owner_id, ...filter } : { ...filter }; const layouts = await this._layoutService.getLayoutsByFilters(filters); const adaptedLayouts = layouts.map((layout) => mapLayoutToAPI(layout, fields)); @@ -125,7 +125,8 @@ export class LayoutController { } try { const layout = await this._layoutService.getLayoutByName(layoutName); - res.status(200).json(layout); + const adaptedLayout = mapLayoutToAPI(layout); + res.status(200).json(adaptedLayout); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); } @@ -142,17 +143,26 @@ export class LayoutController { async putLayoutHandler(req, res) { const { id } = req.params; let layoutProposed = req.body; + const parsedId = parseInt(id, 10); try { + 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 updatedLayoutId = await this._layoutService.putLayout(id, layoutProposed); + const updatedLayoutId = await this._layoutService.putLayout(parsedId, layoutProposed); res.status(200).json({ id: updatedLayoutId }); } catch (error) { updateAndSendExpressResponseFromNativeError(res, error); @@ -208,21 +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); - } catch (error) { - updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(`Failed to validate layout: ${error?.details[0]?.message || ''}`), - ); - return; - } - try { - const updatedLayoutId = await this._layoutService.patchLayout(id, layout); + 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, error); + 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/helpers/mapLayoutToAPI.js b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js index 6e5508c30..3811816c5 100644 --- a/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js +++ b/QualityControl/lib/controllers/helpers/mapLayoutToAPI.js @@ -22,7 +22,7 @@ export function mapLayoutToAPI(layout, fields) { try { const layoutAdapted = { - id: layout.id?.toString(), + id: layout.id, name: layout.name, owner_id: layout.owner.id, owner_name: layout.owner.name, @@ -30,11 +30,11 @@ export function mapLayoutToAPI(layout, fields) { displayTimestamp: layout?.display_timestamp, autoTabChange: layout?.auto_tab_change_interval, tabs: layout.tabs.map((tab) => ({ - id: tab.id?.toString(), + id: tab.id, name: tab.name, columns: tab?.column_count || 2, objects: (tab.gridTabCells || []).map((cell) => ({ - id: cell.chart.id?.toString(), + id: cell.chart.id, x: cell.col || 0, y: cell.row || 0, h: cell.row_span || 1, diff --git a/QualityControl/lib/dtos/LayoutDto.js b/QualityControl/lib/dtos/LayoutDto.js index 6f93d2a84..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.number().required(), + id: Joi.number(), name: Joi.string().required(), x: Joi.number().min(0).default(0), y: Joi.number().min(0).default(0), @@ -56,7 +56,7 @@ const ObjectDto = Joi.object({ }); const TabsDto = Joi.object({ - id: Joi.number().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([]), @@ -68,7 +68,7 @@ export const UserDto = Joi.object({ }); export const LayoutDto = Joi.object({ - id: Joi.number().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/test/lib/controllers/LayoutController.test.js b/QualityControl/test/lib/controllers/LayoutController.test.js index cb9699ad1..9fa8ccaf3 100644 --- a/QualityControl/test/lib/controllers/LayoutController.test.js +++ b/QualityControl/test/lib/controllers/LayoutController.test.js @@ -158,9 +158,14 @@ export const layoutControllerTestSuite = async () => { ok(res.json.calledWith({ id: 10001, name: 'Test Layout 1', - owner: { id: 123, name: 'Owner 1' }, - tabs: [{ id: 1, name: 'Tab 1', gridTabCells: [] }], - is_official: true, + 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 when layoutService.getLayoutByName rejects', async () => { @@ -179,6 +184,7 @@ export const layoutControllerTestSuite = async () => { 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', }; @@ -187,7 +193,7 @@ export const layoutControllerTestSuite = async () => { ok(res.status.calledWith(400)); ok(res.json.calledWith({ - message: 'Failed to update layout: "tabs" must be an array', + message: 'Failed to validate layout: "tabs" must be an array', status: 400, title: 'Invalid Input', })); @@ -195,6 +201,7 @@ export const layoutControllerTestSuite = async () => { 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, @@ -277,7 +284,7 @@ export const layoutControllerTestSuite = async () => { ok(res.status.calledWith(400)); ok(res.json.calledWith({ - message: 'Failed to validate layout: "isOfficial" must be a boolean', + message: 'Failed to validate layout patch: "isOfficial" must be a boolean', status: 400, title: 'Invalid Input', })); From fb3f66a1e7a9ca973753e4a174f6d6705a1f82fd Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:33:17 +0200 Subject: [PATCH 27/35] fix findLayoutsByFilters to support more filters --- .../database/repositories/LayoutRepository.js | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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}%` } }, }, ], From b053400d748a833ad1354ce8a5750559be4d842c Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:34:08 +0200 Subject: [PATCH 28/35] remove objectId generator as it will be set by the repository, layout refactor --- QualityControl/public/common/Types.js | 6 +++--- QualityControl/public/common/utils.js | 9 -------- QualityControl/public/layout/Layout.js | 12 ++++------- QualityControl/public/layout/LayoutClass.js | 23 --------------------- QualityControl/public/layout/LayoutUtils.js | 15 +------------- 5 files changed, 8 insertions(+), 57 deletions(-) diff --git a/QualityControl/public/common/Types.js b/QualityControl/public/common/Types.js index 69b6f7107..b2d09946c 100644 --- a/QualityControl/public/common/Types.js +++ b/QualityControl/public/common/Types.js @@ -34,7 +34,7 @@ export function assertLayouts(array) { * @returns {boolean} true is correct */ export function assertLayout(obj) { - assertString(obj.id); + assertNumber(obj.id); assertString(obj.name); assertNumber(obj.owner_id); assertString(obj.owner_name); @@ -59,7 +59,7 @@ export function assertTabs(array) { * @returns {boolean} true is correct */ export function assertTab(obj) { - assertString(obj.id); + assertNumber(obj.id); assertString(obj.name); assertArray(obj.objects); return obj; @@ -71,7 +71,7 @@ export function assertTab(obj) { * @returns {boolean} true is correct */ export function assertTabObject(obj) { - assertString(obj.id); + 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..92374c64e 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: [], }, @@ -391,7 +392,6 @@ export default class Layout extends BaseViewModel { } this.item.tabs.push({ - id: objectId(), name: name, objects: [], }); @@ -454,7 +454,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 +599,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 +608,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 +723,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..d1d42b37a 100644 --- a/QualityControl/public/layout/LayoutClass.js +++ b/QualityControl/public/layout/LayoutClass.js @@ -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; } From fc7cc69c5944321e96db8f3661c245dd7ae71953 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:34:33 +0200 Subject: [PATCH 29/35] update api tests --- .../test/api/layouts/api-get-layout.test.js | 55 +++++++++------- .../test/api/layouts/api-patch-layout.test.js | 62 ++++++++++++------- .../test/api/layouts/api-put-layout.test.js | 39 ++++++++---- .../test/api/objects/api-get-object.test.js | 4 +- 4 files changed, 102 insertions(+), 58 deletions(-) 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); }); From ec0a7eb8f7588e0323cc5dc9e3aff59762b38631 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:35:19 +0200 Subject: [PATCH 30/35] add mocks and configuration --- QualityControl/test/config.js | 14 ++ .../test/demoData/layout/layout.mock.js | 185 ++++++++++++++++++ 2 files changed, 199 insertions(+) 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 53f3f4491..89f46e919 100644 --- a/QualityControl/test/demoData/layout/layout.mock.js +++ b/QualityControl/test/demoData/layout/layout.mock.js @@ -373,3 +373,188 @@ export const API_ADAPTED_LAYOUT_MOCK = { 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' }; From 2cfb1a981183cd30e2d9cf641b0a55a562319577 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:35:58 +0200 Subject: [PATCH 31/35] frontend tests --- .../test/public/pages/layout-list.test.js | 4 +- .../test/public/pages/layout-show.test.js | 70 ++++++++----------- .../object-view-from-layout-show.test.js | 12 ++-- .../test/setup/seeders/ccdbObjects.js | 2 +- 4 files changed, 38 insertions(+), 50 deletions(-) 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..7ae1c9912 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(); @@ -403,11 +390,12 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => const textareaPath = '#layout-json-editor'; const mockedJSON = JSON.stringify(editedMockedLayout); + console.log('Filling JSON editor with:', mockedJSON); await page.locator(textareaPath).fill(mockedJSON); + page.screenshot({ path: 'layout-show-before-update.png' }); 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, From 8179b70de43a1539442baf4ef1fd551cc094393d Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:42:13 +0200 Subject: [PATCH 32/35] add ownerUsername, fix layout navigation, and allow empty id in validation --- QualityControl/lib/services/layout/LayoutService.js | 2 ++ QualityControl/public/common/Types.js | 4 +++- QualityControl/public/layout/Layout.js | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/QualityControl/lib/services/layout/LayoutService.js b/QualityControl/lib/services/layout/LayoutService.js index c12acd889..26fc61712 100644 --- a/QualityControl/lib/services/layout/LayoutService.js +++ b/QualityControl/lib/services/layout/LayoutService.js @@ -273,6 +273,8 @@ export class LayoutService { 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) { diff --git a/QualityControl/public/common/Types.js b/QualityControl/public/common/Types.js index b2d09946c..e1dc083bd 100644 --- a/QualityControl/public/common/Types.js +++ b/QualityControl/public/common/Types.js @@ -34,7 +34,9 @@ export function assertLayouts(array) { * @returns {boolean} true is correct */ export function assertLayout(obj) { - assertNumber(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertNumber(obj.owner_id); assertString(obj.owner_name); diff --git a/QualityControl/public/layout/Layout.js b/QualityControl/public/layout/Layout.js index 92374c64e..a2823df07 100644 --- a/QualityControl/public/layout/Layout.js +++ b/QualityControl/public/layout/Layout.js @@ -218,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); From b8fed02ed650e846ba836db7bda8e30c873be0ff Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:42:59 +0200 Subject: [PATCH 33/35] fix lint error --- QualityControl/public/layout/LayoutClass.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/public/layout/LayoutClass.js b/QualityControl/public/layout/LayoutClass.js index d1d42b37a..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 From 1eea374fbe659fe90a561758b30ac80190b225c0 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:01:12 +0200 Subject: [PATCH 34/35] load item after save to get the created ids --- QualityControl/public/common/Types.js | 10 +++++++--- QualityControl/public/layout/Layout.js | 1 + QualityControl/test/public/pages/layout-show.test.js | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/common/Types.js b/QualityControl/public/common/Types.js index e1dc083bd..fb27808f2 100644 --- a/QualityControl/public/common/Types.js +++ b/QualityControl/public/common/Types.js @@ -40,7 +40,7 @@ export function assertLayout(obj) { assertString(obj.name); assertNumber(obj.owner_id); assertString(obj.owner_name); - assertArray(obj.tabs); + assertTabs(obj.tabs); return obj; } @@ -61,7 +61,9 @@ export function assertTabs(array) { * @returns {boolean} true is correct */ export function assertTab(obj) { - assertNumber(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertArray(obj.objects); return obj; @@ -73,7 +75,9 @@ export function assertTab(obj) { * @returns {boolean} true is correct */ export function assertTabObject(obj) { - assertNumber(obj.id); + if (obj.id !== undefined) { + assertNumber(obj.id); + } assertString(obj.name); assertArray(obj.options); assertNumber(obj.x); diff --git a/QualityControl/public/layout/Layout.js b/QualityControl/public/layout/Layout.js index a2823df07..3d4088d4a 100644 --- a/QualityControl/public/layout/Layout.js +++ b/QualityControl/public/layout/Layout.js @@ -258,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 { diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 7ae1c9912..cf9e5ee42 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -390,7 +390,6 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => const textareaPath = '#layout-json-editor'; const mockedJSON = JSON.stringify(editedMockedLayout); - console.log('Filling JSON editor with:', mockedJSON); await page.locator(textareaPath).fill(mockedJSON); page.screenshot({ path: 'layout-show-before-update.png' }); From 084f6c64ac54fa98e7c90adbed316e525e737ee5 Mon Sep 17 00:00:00 2001 From: Alejandro Mariscal Romero <87366244+mariscalromeroalejandro@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:14:34 +0200 Subject: [PATCH 35/35] fix tests to return id created --- .../lib/services/layout/LayoutService.test.js | 35 +++++++++++-------- .../test/public/pages/layout-show.test.js | 1 - 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/QualityControl/test/lib/services/layout/LayoutService.test.js b/QualityControl/test/lib/services/layout/LayoutService.test.js index 70ede3395..2de193212 100644 --- a/QualityControl/test/lib/services/layout/LayoutService.test.js +++ b/QualityControl/test/lib/services/layout/LayoutService.test.js @@ -255,31 +255,38 @@ export const layoutServiceTestSuite = async () => { suite('postLayout', () => { test('should create new layout', async () => { const layoutData = { - name: 'New Layout', - description: 'Layout Description', - displayTimestamp: true, - autoTabChange: 5, - ownerUsername: 'alice_username', - tabs: [{ name: 'Tab 1' }], + name: 'blabla', + owner_id: 0, + owner_name: 'Anonymous', + description: '', + displayTimestamp: false, + autoTabChange: 0, + tabs: [{ name: 'main', objects: [], columns: 2 }], + collaborators: [], }; - const normalizedLayout = { - name: 'New Layout', - description: 'Layout Description', - display_timestamp: true, - auto_tab_change_interval: 5, - owner_username: 'alice_username', + 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', }; - const createdLayout = { id: 1, ...normalizedLayout }; + userServiceMock.getUsernameById.resolves('anonymous'); layoutRepositoryMock.createLayout.resolves(createdLayout); layoutService._tabSynchronizer.sync.resolves(); const result = await layoutService.postLayout(layoutData); strictEqual(result, createdLayout); - strictEqual(layoutRepositoryMock.createLayout.calledWith(normalizedLayout), true); + 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 () => { diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index cf9e5ee42..9ae0e2bb1 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -392,7 +392,6 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => const mockedJSON = JSON.stringify(editedMockedLayout); await page.locator(textareaPath).fill(mockedJSON); - page.screenshot({ path: 'layout-show-before-update.png' }); const updateButtonPath = '#updateLayoutButton'; await page.locator(updateButtonPath).click(); await delay(50);