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 01/12] 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 02/12] 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 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 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 09/12] 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 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 10/12] 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 11/12] 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 12/12] 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); }); };