From 3ec3ad37dcb10530033f21c22f9f24bd998f7cc5 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Thu, 22 Oct 2026 15:41:53 +0200 Subject: [PATCH 1/5] [O2B-1623] Fix the GaqDetector typedef `GaqDetectorAdapter.toEntity` only returns the run number, the data pass id and the detector id, but the `GaqDetector` typedef also listed `createdAt` and `updatedAt`, which are never sent. --- lib/domain/entities/GaqDetector.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/domain/entities/GaqDetector.js b/lib/domain/entities/GaqDetector.js index 3478c2381c..54f6c26c6c 100644 --- a/lib/domain/entities/GaqDetector.js +++ b/lib/domain/entities/GaqDetector.js @@ -17,6 +17,4 @@ * @property {number} dataPassId * @property {number} runNumber * @property {number} detectorId - * @property {number} createdAt - * @property {number} updatedAt */ From 8810636c81d6573c548cdaf36e14fb0d31465a67 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Thu, 22 Oct 2026 15:41:53 +0200 Subject: [PATCH 2/5] [O2B-1623] Build GaqDetectorService on small functions `GaqDetectorService` queried three repositories directly (GAQ detectors, runs and detectors), with only `getOneDataPassOrFail` as a small function. It now relies on `getAllGaqDetectors`, `createGaqDetectors`, `removeGaqDetectors`, `getRunNumbersLinkedToDataPass`, `getRunsWithDetectorsByIds` and `getRunsWithDefaultGaqDetectors`, and on the existing `getAllDetectors`, and the six new functions have tests. The documentation of `setGaqDetectors` and `useDefaultGaqDetectors` said they resolve with nothing whereas they resolve with the created entries. The service makes the same calls and gives the same results as before. The only difference is that the detectors are now fetched through a query builder, which gives the same query with one more pair of parentheses around the condition. --- .../services/gaq/GaqDetectorsService.js | 79 +++++---------- lib/server/services/gaq/createGaqDetectors.js | 22 +++++ lib/server/services/gaq/getAllGaqDetectors.js | 32 ++++++ .../gaq/getRunNumbersLinkedToDataPass.js | 32 ++++++ .../gaq/getRunsWithDefaultGaqDetectors.js | 38 ++++++++ .../services/gaq/getRunsWithDetectorsByIds.js | 36 +++++++ lib/server/services/gaq/removeGaqDetectors.js | 31 ++++++ .../services/gaq/gaqDetectorsQueries.test.js | 97 +++++++++++++++++++ test/lib/server/services/gaq/index.js | 5 + 9 files changed, 317 insertions(+), 55 deletions(-) create mode 100644 lib/server/services/gaq/createGaqDetectors.js create mode 100644 lib/server/services/gaq/getAllGaqDetectors.js create mode 100644 lib/server/services/gaq/getRunNumbersLinkedToDataPass.js create mode 100644 lib/server/services/gaq/getRunsWithDefaultGaqDetectors.js create mode 100644 lib/server/services/gaq/getRunsWithDetectorsByIds.js create mode 100644 lib/server/services/gaq/removeGaqDetectors.js create mode 100644 test/lib/server/services/gaq/gaqDetectorsQueries.test.js diff --git a/lib/server/services/gaq/GaqDetectorsService.js b/lib/server/services/gaq/GaqDetectorsService.js index 99108bfddd..d92afeafcc 100644 --- a/lib/server/services/gaq/GaqDetectorsService.js +++ b/lib/server/services/gaq/GaqDetectorsService.js @@ -12,11 +12,16 @@ */ const { gaqDetectorAdapter, detectorAdapter } = require('../../../database/adapters'); -const { GaqDetectorRepository, RunRepository, DetectorRepository } = require('../../../database/repositories'); const { BadParameterError } = require('../../errors/BadParameterError'); const { dataSource } = require('../../../database/DataSource.js'); -const { Op } = require('sequelize'); const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail'); +const { getAllDetectors } = require('../detector/getAllDetectors.js'); +const { createGaqDetectors } = require('./createGaqDetectors.js'); +const { getAllGaqDetectors } = require('./getAllGaqDetectors.js'); +const { getRunNumbersLinkedToDataPass } = require('./getRunNumbersLinkedToDataPass.js'); +const { getRunsWithDefaultGaqDetectors } = require('./getRunsWithDefaultGaqDetectors.js'); +const { getRunsWithDetectorsByIds } = require('./getRunsWithDetectorsByIds.js'); +const { removeGaqDetectors } = require('./removeGaqDetectors.js'); /** * List of default GAQ detectors for pp runs @@ -39,7 +44,7 @@ class GaqDetectorService { * @param {number} dataPassId data pass id * @param {number[]} runNumbers list of run numbers * @param {number[]} detectorIds list of ids of detectors included in GAQ computation for the given runs and data pass - * @return {Promise} promise resolved once association between data pass, runs and detectors was set + * @return {Promise} promise resolved with the created associations between data pass, runs and detectors */ setGaqDetectors(dataPassId, runNumbers, detectorIds) { return dataSource.transaction(async () => { @@ -47,16 +52,11 @@ class GaqDetectorService { await this._verifyDataPassAndRunsAssociation(dataPassId, runNumbers); await this._verifyRunsAndDetectorsAssociations(runNumbers, detectorIds); - await GaqDetectorRepository.removeAll({ - where: { - dataPassId, - runNumber: { [Op.in]: runNumbers }, - }, - }); + await removeGaqDetectors(dataPassId, runNumbers); const gaqEntries = runNumbers .flatMap((runNumber) => detectorIds .map((detectorId) => ({ dataPassId, runNumber, detectorId }))); - const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + const createdEntries = await createGaqDetectors(gaqEntries); return createdEntries.map(gaqDetectorAdapter.toEntity); }); } @@ -70,37 +70,24 @@ class GaqDetectorService { * @param {number[]} runNumbers list of run numbers * @param {object} [options] additional options * @param {sequelize.Transaction} [options.transaction] sequelize transaction - * @return {Promise} promise resolved once association between data pass, runs and detectors was set + * @return {Promise} promise resolved with the created associations between data pass, runs and detectors */ useDefaultGaqDetectors(dataPassId, runNumbers, { transaction } = {}) { return dataSource.transaction(async () => { await getOneDataPassOrFail({ id: dataPassId }); await this._verifyDataPassAndRunsAssociation(dataPassId, runNumbers); - await GaqDetectorRepository.removeAll({ - where: { - dataPassId, - runNumber: { [Op.in]: runNumbers }, - }, - }); + await removeGaqDetectors(dataPassId, runNumbers); - const runsWithDetectors = await RunRepository.findAll({ - where: { runNumber: { [Op.in]: runNumbers } }, - include: [ - { - association: 'detectors', - where: { [Op.or]: [ - { '$Run.pdp_beam_type$': 'pp', name: { [Op.in]: DEFAULT_GAQ_DETECTORS_FOR_PROTON_PROTON_RUNS } }, - { '$Run.pdp_beam_type$': 'PbPb', name: { [Op.in]: DEFAULT_GAQ_DETECTORS_FOR_LEAD_LEAD_RUNS } }, - ] }, - }, - ], + const runsWithDetectors = await getRunsWithDefaultGaqDetectors(runNumbers, { + pp: DEFAULT_GAQ_DETECTORS_FOR_PROTON_PROTON_RUNS, + PbPb: DEFAULT_GAQ_DETECTORS_FOR_LEAD_LEAD_RUNS, }); const gaqEntries = runsWithDetectors .flatMap(({ runNumber, detectors }) => detectors .map(({ id: detectorId }) => ({ dataPassId, runNumber, detectorId }))); - const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + const createdEntries = await createGaqDetectors(gaqEntries); return createdEntries.map(gaqDetectorAdapter.toEntity); }, { transaction }); } @@ -113,13 +100,11 @@ class GaqDetectorService { * @return {Promise[]>} promise of detectors */ async getGaqDetectors(dataPassId, runNumber) { - const queryBuilder = dataSource.createQueryBuilder() + const gaqDetectors = await getAllGaqDetectors((queryBuilder) => queryBuilder .where('dataPassId').is(dataPassId) .where('runNumber').is(runNumber) .include({ association: 'detector' }) - .orderBy((sequelize) => sequelize.col('`detector`.name'), 'ASC'); - - const gaqDetectors = await GaqDetectorRepository.findAll(queryBuilder); + .orderBy((sequelize) => sequelize.col('`detector`.name'), 'ASC')); return gaqDetectors.map(({ detector }) => detectorAdapter.toMinifiedEntity(detector)); } @@ -132,12 +117,7 @@ class GaqDetectorService { * @throws {BadParameterError} if some association does not exist */ async _verifyDataPassAndRunsAssociation(dataPassId, runNumbers) { - const validRunNumbers = await RunRepository.findAll({ - attributes: ['runNumber'], - where: { runNumber: { [Op.in]: runNumbers } }, - include: [{ association: 'dataPass', where: { id: dataPassId }, required: true }], - }); - const validRunNumbersSet = new Set(validRunNumbers.map(({ runNumber }) => runNumber)); + const validRunNumbersSet = new Set(await getRunNumbersLinkedToDataPass(dataPassId, runNumbers)); const invalidRunNumbers = runNumbers.filter((runNumber) => !validRunNumbersSet.has(runNumber)); if (invalidRunNumbers.length > 0) { const errorMessage = `No association between data pass with id ${dataPassId} and following runs: ${invalidRunNumbers}`; @@ -156,9 +136,9 @@ class GaqDetectorService { * @throws {BadParameterError} if some association does not exist */ async _verifyRunsAndDetectorsAssociations(runNumbers, detectorIds) { - const detectors = await DetectorRepository.findAll({ - attributes: ['id', 'name'], - where: { id: { [Op.in]: detectorIds } }, + const detectors = await getAllDetectors((queryBuilder) => { + queryBuilder.set('attributes', ['id', 'name']); + queryBuilder.where('id').oneOf(...detectorIds); }); const validDetectorIdsSet = new Set(detectors.map(({ id }) => id)); const invalidIds = detectorIds.filter((detectorId) => !validDetectorIdsSet.has(detectorId)); @@ -167,19 +147,8 @@ class GaqDetectorService { } const validDetectorNames = detectors.map(({ name }) => name); - const runAndDetector = (await RunRepository.findAll({ - attributes: ['runNumber'], - where: { runNumber: { [Op.in]: runNumbers } }, - include: [ - { - association: 'detectors', - attributes: ['name'], - where: { id: { [Op.in]: detectorIds } }, - through: { attributes: [] }, - required: false, - }, - ], - })).map(({ runNumber, detectors }) => [runNumber, detectors.map(({ name }) => name)]); + const runAndDetector = (await getRunsWithDetectorsByIds(runNumbers, detectorIds)) + .map(({ runNumber, detectors }) => [runNumber, detectors.map(({ name }) => name)]); const validRunToDetectors = Object.fromEntries(runAndDetector); diff --git a/lib/server/services/gaq/createGaqDetectors.js b/lib/server/services/gaq/createGaqDetectors.js new file mode 100644 index 0000000000..b531b14d6c --- /dev/null +++ b/lib/server/services/gaq/createGaqDetectors.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { GaqDetectorRepository } = require('../../../database/repositories/index.js'); + +/** + * Create GAQ (global aggregated quality) detectors entries + * + * @param {{dataPassId: number, runNumber: number, detectorId: number}[]} gaqDetectors the entries to create + * @return {Promise} resolves with the entries created + */ +exports.createGaqDetectors = (gaqDetectors) => GaqDetectorRepository.insertAll(gaqDetectors); diff --git a/lib/server/services/gaq/getAllGaqDetectors.js b/lib/server/services/gaq/getAllGaqDetectors.js new file mode 100644 index 0000000000..357b7251fa --- /dev/null +++ b/lib/server/services/gaq/getAllGaqDetectors.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { dataSource } = require('../../../database/DataSource.js'); +const { GaqDetectorRepository } = require('../../../database/repositories/index.js'); + +/** + * Find and return the GAQ (global aggregated quality) detectors entries + * + * @param {(function(QueryBuilder):void)|null} [qbConfiguration=null] function called with the GAQ detectors find query builder as parameter to + * add specific configuration to the query + * @return {Promise} resolves with the GAQ detectors entries found + */ +exports.getAllGaqDetectors = (qbConfiguration = null) => { + const queryBuilder = dataSource.createQueryBuilder(); + + if (qbConfiguration) { + qbConfiguration(queryBuilder); + } + + return GaqDetectorRepository.findAll(queryBuilder); +}; diff --git a/lib/server/services/gaq/getRunNumbersLinkedToDataPass.js b/lib/server/services/gaq/getRunNumbersLinkedToDataPass.js new file mode 100644 index 0000000000..ba5ade0bc5 --- /dev/null +++ b/lib/server/services/gaq/getRunNumbersLinkedToDataPass.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { Op } = require('sequelize'); +const { RunRepository } = require('../../../database/repositories/index.js'); + +/** + * Return, among the given run numbers, the ones of the runs linked to a data pass + * + * @param {number} dataPassId the id of the data pass + * @param {number[]} runNumbers the run numbers to check + * @return {Promise} resolves with the run numbers of the runs linked to the data pass + */ +exports.getRunNumbersLinkedToDataPass = async (dataPassId, runNumbers) => { + const runs = await RunRepository.findAll({ + attributes: ['runNumber'], + where: { runNumber: { [Op.in]: runNumbers } }, + include: [{ association: 'dataPass', where: { id: dataPassId }, required: true }], + }); + + return runs.map(({ runNumber }) => runNumber); +}; diff --git a/lib/server/services/gaq/getRunsWithDefaultGaqDetectors.js b/lib/server/services/gaq/getRunsWithDefaultGaqDetectors.js new file mode 100644 index 0000000000..fd91036c8f --- /dev/null +++ b/lib/server/services/gaq/getRunsWithDefaultGaqDetectors.js @@ -0,0 +1,38 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { Op } = require('sequelize'); +const { RunRepository } = require('../../../database/repositories/index.js'); + +/** + * Find the given runs with, as detectors, the ones that are in the list of the default GAQ detectors of the PDP beam type of the run + * + * @param {number[]} runNumbers the run numbers of the runs to find + * @param {Object} defaultDetectorsNamesByPdpBeamType for each PDP beam type, the names of its default GAQ detectors. Runs of + * another PDP beam type are not returned + * @return {Promise} resolves with the runs found, each with its default GAQ `detectors` + */ +exports.getRunsWithDefaultGaqDetectors = (runNumbers, defaultDetectorsNamesByPdpBeamType) => RunRepository.findAll({ + where: { runNumber: { [Op.in]: runNumbers } }, + include: [ + { + association: 'detectors', + where: { + [Op.or]: Object.entries(defaultDetectorsNamesByPdpBeamType).map(([pdpBeamType, detectorsNames]) => ({ + '$Run.pdp_beam_type$': pdpBeamType, + name: { [Op.in]: detectorsNames }, + })), + }, + }, + ], +}); diff --git a/lib/server/services/gaq/getRunsWithDetectorsByIds.js b/lib/server/services/gaq/getRunsWithDetectorsByIds.js new file mode 100644 index 0000000000..f3947fc92c --- /dev/null +++ b/lib/server/services/gaq/getRunsWithDetectorsByIds.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { Op } = require('sequelize'); +const { RunRepository } = require('../../../database/repositories/index.js'); + +/** + * Find the given runs with their detectors (name only) restricted to the ones of the given ids + * + * @param {number[]} runNumbers the run numbers of the runs to find + * @param {number[]} detectorIds the ids of the detectors to include, the other detectors of the runs are ignored + * @return {Promise} resolves with the runs found, each with its `detectors` (an empty list if it has none of the given ones) + */ +exports.getRunsWithDetectorsByIds = (runNumbers, detectorIds) => RunRepository.findAll({ + attributes: ['runNumber'], + where: { runNumber: { [Op.in]: runNumbers } }, + include: [ + { + association: 'detectors', + attributes: ['name'], + where: { id: { [Op.in]: detectorIds } }, + through: { attributes: [] }, + required: false, + }, + ], +}); diff --git a/lib/server/services/gaq/removeGaqDetectors.js b/lib/server/services/gaq/removeGaqDetectors.js new file mode 100644 index 0000000000..4a55b2f8dc --- /dev/null +++ b/lib/server/services/gaq/removeGaqDetectors.js @@ -0,0 +1,31 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { Op } = require('sequelize'); +const { GaqDetectorRepository } = require('../../../database/repositories/index.js'); + +/** + * Remove all the GAQ (global aggregated quality) detectors entries of the given runs for a data pass + * + * @param {number} dataPassId the id of the data pass + * @param {number[]} runNumbers the run numbers of the runs for which entries must be removed + * @return {Promise} resolves once the entries have been removed + */ +exports.removeGaqDetectors = async (dataPassId, runNumbers) => { + await GaqDetectorRepository.removeAll({ + where: { + dataPassId, + runNumber: { [Op.in]: runNumbers }, + }, + }); +}; diff --git a/test/lib/server/services/gaq/gaqDetectorsQueries.test.js b/test/lib/server/services/gaq/gaqDetectorsQueries.test.js new file mode 100644 index 0000000000..99d8b91036 --- /dev/null +++ b/test/lib/server/services/gaq/gaqDetectorsQueries.test.js @@ -0,0 +1,97 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { expect } = require('chai'); +const { getAllGaqDetectors } = require('../../../../../lib/server/services/gaq/getAllGaqDetectors.js'); +const { createGaqDetectors } = require('../../../../../lib/server/services/gaq/createGaqDetectors.js'); +const { removeGaqDetectors } = require('../../../../../lib/server/services/gaq/removeGaqDetectors.js'); +const { getRunNumbersLinkedToDataPass } = require('../../../../../lib/server/services/gaq/getRunNumbersLinkedToDataPass.js'); +const { getRunsWithDetectorsByIds } = require('../../../../../lib/server/services/gaq/getRunsWithDetectorsByIds.js'); +const { getRunsWithDefaultGaqDetectors } = require('../../../../../lib/server/services/gaq/getRunsWithDefaultGaqDetectors.js'); +const { runService } = require('../../../../../lib/server/services/run/RunService.js'); + +const DATA_PASS_ID = 3; + +/** + * Return the GAQ detectors entries of a run for the data pass used in the tests + * + * @param {number} runNumber the run number + * @return {Promise} the entries + */ +const getEntriesOfRun = (runNumber) => getAllGaqDetectors((queryBuilder) => { + queryBuilder.where('dataPassId').is(DATA_PASS_ID); + queryBuilder.where('runNumber').is(runNumber); +}); + +module.exports = () => { + describe('getAllGaqDetectors, createGaqDetectors and removeGaqDetectors', () => { + it('should successfully remove, create and find the GAQ detectors entries of a run', async () => { + await removeGaqDetectors(DATA_PASS_ID, [49]); + expect(await getEntriesOfRun(49)).to.eql([]); + + const created = await createGaqDetectors([ + { dataPassId: DATA_PASS_ID, runNumber: 49, detectorId: 4 }, + { dataPassId: DATA_PASS_ID, runNumber: 49, detectorId: 7 }, + ]); + expect(created).to.lengthOf(2); + + const entries = await getEntriesOfRun(49); + expect(entries.map(({ detectorId }) => detectorId)).to.have.members([4, 7]); + + await removeGaqDetectors(DATA_PASS_ID, [49]); + expect(await getEntriesOfRun(49)).to.eql([]); + }); + }); + + describe('getRunNumbersLinkedToDataPass', () => { + it('should return only the run numbers of the runs linked to the data pass', async () => { + expect(await getRunNumbersLinkedToDataPass(DATA_PASS_ID, [49, 56, 1])).to.have.members([49, 56]); + }); + + it('should return an empty list when no run is linked to the data pass', async () => { + expect(await getRunNumbersLinkedToDataPass(DATA_PASS_ID, [1])).to.eql([]); + }); + }); + + describe('getRunsWithDetectorsByIds', () => { + it('should return the runs with only the requested detectors', async () => { + const [run] = await getRunsWithDetectorsByIds([56], [4, 7]); + + expect(run.runNumber).to.equal(56); + expect(run.detectors.map(({ name }) => name)).to.have.members(['ITS', 'FT0']); + }); + + it('should return the runs with no detectors when they have none of the requested ones', async () => { + const [run] = await getRunsWithDetectorsByIds([56], [1]); + + expect(run.runNumber).to.equal(56); + expect(run.detectors).to.eql([]); + }); + }); + + describe('getRunsWithDefaultGaqDetectors', () => { + it('should return the runs with only the default GAQ detectors of their PDP beam type', async () => { + await runService.create({ runNumber: 777780, pdpBeamType: 'pp', detectors: 'CPV,TPC,ITS' }); + await runService.create({ runNumber: 777781, pdpBeamType: 'PbPb', detectors: 'CPV,TPC,ZDC' }); + // None of its detectors is a default one: the run is not returned + await runService.create({ runNumber: 777782, pdpBeamType: 'pp', detectors: 'CPV' }); + + const runs = await getRunsWithDefaultGaqDetectors([777780, 777781, 777782], { pp: ['TPC', 'FT0'], PbPb: ['ZDC'] }); + const detectorsNamesByRun = Object.fromEntries( + runs.map(({ runNumber, detectors }) => [runNumber, detectors.map(({ name }) => name)]), + ); + + expect(detectorsNamesByRun).to.eql({ 777780: ['TPC'], 777781: ['ZDC'] }); + }); + }); +}; diff --git a/test/lib/server/services/gaq/index.js b/test/lib/server/services/gaq/index.js index 985f80fe84..42c939a992 100644 --- a/test/lib/server/services/gaq/index.js +++ b/test/lib/server/services/gaq/index.js @@ -11,8 +11,13 @@ * or submit itself to any jurisdiction. */ +const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); const GaqDetectorServiceSuite = require('./GaqDetectorService.test.js'); +const GaqDetectorsQueriesSuite = require('./gaqDetectorsQueries.test.js'); module.exports = () => { + after(resetDatabaseContent); + describe('GaqDetectorService', GaqDetectorServiceSuite); + describe('GAQ detectors small functions', GaqDetectorsQueriesSuite); }; From 61aaa54fd13368ca6c07bc90a8095c06b76e920e Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Thu, 22 Oct 2026 15:42:24 +0200 Subject: [PATCH 3/5] [O2B-1623] Validate the ids of the GAQ detectors endpoints and require the fields `POST /api/gaqDetectors` did not require its fields (`dataPassId`, `runNumbers`, `detectorIds`), so a missing one reached the service and failed there, and its ids were accepted as any number. `GET /api/gaqDetectors` accepted any number as data pass id and run number. All the ids are now validated with `EntityIdDto` (positive integers) and the three fields of the body are required, so that invalid requests are rejected with a 400 before reaching the service. API tests check missing fields and invalid values. --- .../controllers/gaqDetectors.controller.js | 11 +-- test/api/gaqDetectors.test.js | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/server/controllers/gaqDetectors.controller.js b/lib/server/controllers/gaqDetectors.controller.js index 04056f3b2b..39c2259cd4 100644 --- a/lib/server/controllers/gaqDetectors.controller.js +++ b/lib/server/controllers/gaqDetectors.controller.js @@ -13,6 +13,7 @@ const Joi = require('joi'); const { DtoFactory } = require('../../domain/dtos/DtoFactory.js'); +const EntityIdDto = require('../../domain/dtos/EntityIdDto.js'); const { dtoValidator } = require('../utilities/dtoValidator.js'); const { updateExpressResponseFromNativeError } = require('../express/updateExpressResponseFromNativeError.js'); const { gaqDetectorService } = require('../services/gaq/GaqDetectorsService.js'); @@ -26,9 +27,9 @@ const { gaqDetectorService } = require('../services/gaq/GaqDetectorsService.js') const setGaqDetectorsHandler = async (req, res) => { const validatedDTO = await dtoValidator( DtoFactory.bodyOnly(Joi.object({ - dataPassId: Joi.number(), - runNumbers: Joi.array().items(Joi.number()).unique(), - detectorIds: Joi.array().items(Joi.number()).unique(), + dataPassId: EntityIdDto.required(), + runNumbers: Joi.array().items(EntityIdDto).unique().required(), + detectorIds: Joi.array().items(EntityIdDto).unique().required(), })), req, res, @@ -63,8 +64,8 @@ const setGaqDetectorsHandler = async (req, res) => { const listGaqDetectorsHandler = async (req, res) => { const validatedDTO = await dtoValidator( DtoFactory.queryOnly(Joi.object({ - dataPassId: Joi.number().required(), - runNumber: Joi.number().required(), + dataPassId: EntityIdDto.required(), + runNumber: EntityIdDto.required(), })), req, res, diff --git a/test/api/gaqDetectors.test.js b/test/api/gaqDetectors.test.js index b146c07343..46a0ddd32c 100644 --- a/test/api/gaqDetectors.test.js +++ b/test/api/gaqDetectors.test.js @@ -18,6 +18,16 @@ const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js') const { DetectorType } = require('../../lib/domain/enums/DetectorTypes'); const { BkpRoles } = require('../../lib/domain/enums/BkpRoles'); +/** + * Return the validation errors of a response as an object mapping the pointer of each error to its detail + * + * @param {object} response the response + * @return {Object} the details of the errors by pointer + */ +const getValidationErrors = (response) => Object.fromEntries( + response.body.errors.map(({ source: { pointer }, detail }) => [pointer.replace('/data/attributes/', ''), detail]), +); + module.exports = () => { before(resetDatabaseContent); @@ -36,6 +46,43 @@ module.exports = () => { .flatMap((runNumber) => detectorIds.map((detectorId) => ({ dataPassId, runNumber, detectorId })))); }); + it('should fail to set GAQ detectors if a field is missing', async () => { + const response = await request(server).post(`/api/gaqDetectors?token=${BkpRoles.GAQ}`).send({}); + + expect(response.status).to.be.equal(400); + expect(getValidationErrors(response)).to.eql({ + 'body/dataPassId': '"body.dataPassId" is required', + 'body/runNumbers': '"body.runNumbers" is required', + 'body/detectorIds': '"body.detectorIds" is required', + }); + }); + + it('should fail to set GAQ detectors if an id is not a positive integer or is duplicated', async () => { + let response = await request(server).post(`/api/gaqDetectors?token=${BkpRoles.GAQ}`).send({ + dataPassId: 0, + runNumbers: [0], + detectorIds: [-1], + }); + expect(response.status).to.be.equal(400); + expect(getValidationErrors(response)).to.eql({ + 'body/dataPassId': '"body.dataPassId" must be a positive number', + 'body/runNumbers/0': '"body.runNumbers[0]" must be a positive number', + 'body/detectorIds/0': '"body.detectorIds[0]" must be a positive number', + }); + + response = await request(server).post(`/api/gaqDetectors?token=${BkpRoles.GAQ}`).send({ + dataPassId: 'abc', + runNumbers: [1.5], + detectorIds: [1, 1], + }); + expect(response.status).to.be.equal(400); + expect(getValidationErrors(response)).to.eql({ + 'body/dataPassId': '"body.dataPassId" must be a number', + 'body/runNumbers/0': '"body.runNumbers[0]" must be an integer', + 'body/detectorIds/1': '"body.detectorIds[1]" contains a duplicate value', + }); + }); + it('should fail to set GAQ detectors because of insufficient permission', async () => { const dataPassId = 3; const runNumbers = [49, 56]; @@ -53,6 +100,26 @@ module.exports = () => { }); describe('GET /api/gaqDetectors', () => { + it('should return 400 if the data pass id or the run number is missing', async () => { + const response = await request(server).get('/api/gaqDetectors'); + + expect(response.status).to.equal(400); + expect(getValidationErrors(response)).to.eql({ + 'query/dataPassId': '"query.dataPassId" is required', + 'query/runNumber': '"query.runNumber" is required', + }); + }); + + it('should return 400 if the data pass id or the run number is not a positive integer', async () => { + const response = await request(server).get('/api/gaqDetectors?dataPassId=-1&runNumber=abc'); + + expect(response.status).to.equal(400); + expect(getValidationErrors(response)).to.eql({ + 'query/dataPassId': '"query.dataPassId" must be a positive number', + 'query/runNumber': '"query.runNumber" must be a number', + }); + }); + it('should return 200 with the list of GAQ detectors', async () => { const response = await request(server).get('/api/gaqDetectors?dataPassId=3&runNumber=56'); From 684c57739b6d829fea0e2d9b816266b1bcab981a Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Thu, 22 Oct 2026 15:43:43 +0200 Subject: [PATCH 4/5] [O2B-1623] Document the GAQ detectors endpoints in the HTTP API specification --- lib/server/api-docs/openapi.yaml | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/lib/server/api-docs/openapi.yaml b/lib/server/api-docs/openapi.yaml index 86cbd3d200..bbb30a0192 100644 --- a/lib/server/api-docs/openapi.yaml +++ b/lib/server/api-docs/openapi.yaml @@ -53,6 +53,8 @@ tags: description: Monte Carlo simulation productions. - name: LHC periods statistics description: LHC periods with statistics on their physics runs. + - name: GAQ detectors + description: Detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass. paths: /tags: @@ -606,6 +608,84 @@ paths: application/json: schema: { $ref: '#/components/schemas/ErrorResponse' } + /gaqDetectors: + get: + tags: [GAQ detectors] + operationId: listGaqDetectors + summary: List the detectors contributing to the GAQ of a run in a data pass + description: | + Returns the detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass, sorted by name. Not paginated. + The list is empty when no detector is set for this run and data pass, **or when the run or the data pass does not exist** (never `404`). + parameters: + - name: dataPassId + in: query + required: true + description: Data pass identifier. + schema: { type: integer, minimum: 1, examples: [3] } + - name: runNumber + in: query + required: true + description: Run number. + schema: { type: integer, minimum: 1, examples: [56] } + responses: + '200': + description: The detectors. + content: + application/json: + schema: { $ref: '#/components/schemas/DetectorListResponse' } + example: + data: + - { id: 7, name: FT0, type: PHYSICAL } + - { id: 4, name: ITS, type: PHYSICAL } + '400': { $ref: '#/components/responses/ValidationError' } + post: + tags: [GAQ detectors] + operationId: setGaqDetectors + x-llm-exclude: true + x-required-roles: [admin, gaq] + summary: Set the detectors contributing to the GAQ of runs in a data pass + description: | + Requires the role `admin` or `gaq`. For each given run, replaces the detectors contributing to the GAQ in the data pass by the given ones. + Every run must be linked to the data pass and have every given detector. + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/SetGaqDetectors' } + example: + dataPassId: 3 + runNumbers: [49, 56] + detectorIds: [4, 7] + responses: + '201': + description: The created associations between runs and detectors, one per run and detector. + content: + application/json: + schema: { $ref: '#/components/schemas/GaqDetectorEntriesResponse' } + example: + data: + - { dataPassId: 3, runNumber: 49, detectorId: 4 } + - { dataPassId: 3, runNumber: 49, detectorId: 7 } + '400': + description: | + The request failed validation (see the validation errors format), **or** a run is not linked to the data pass, a detector does not exist or + a run does not have a given detector. In these last cases the error has `title: Service unavailable` and the reason in `detail`. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: + - { status: 400, title: Service unavailable, detail: 'No association between data pass with id 3 and following runs: 1' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': + description: The data pass does not exist. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: + - { status: 404, title: Not found, detail: Data pass with this id (99999) could not be found } + components: securitySchemes: jwtQuery: @@ -936,3 +1016,32 @@ components: type: array items: { $ref: '#/components/schemas/LhcPeriodStatistics' } meta: { $ref: '#/components/schemas/PageMeta' } + SetGaqDetectors: + type: object + required: [dataPassId, runNumbers, detectorIds] + properties: + dataPassId: { type: integer, minimum: 1 } + runNumbers: + type: array + description: Run numbers of the runs to update, without duplicates. + uniqueItems: true + items: { type: integer, minimum: 1 } + detectorIds: + type: array + description: Ids of the detectors contributing to the GAQ, without duplicates. + uniqueItems: true + items: { type: integer, minimum: 1 } + GaqDetectorEntry: + type: object + required: [dataPassId, runNumber, detectorId] + properties: + dataPassId: { type: integer } + runNumber: { type: integer } + detectorId: { type: integer } + GaqDetectorEntriesResponse: + type: object + required: [data] + properties: + data: + type: array + items: { $ref: '#/components/schemas/GaqDetectorEntry' } From 2f0bc6388aaf636afb0abc2962dced13eb7419a9 Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 5.5" Date: Thu, 22 Oct 2026 15:43:43 +0200 Subject: [PATCH 5/5] [O2B-1623] Regenerate the HTTP API documentation with the GAQ detectors endpoints --- lib/server/api-docs/API.md | 57 +++++++++++++++++++++ lib/server/api-docs/index.html | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/lib/server/api-docs/API.md b/lib/server/api-docs/API.md index 80b5632eb0..5441019907 100644 --- a/lib/server/api-docs/API.md +++ b/lib/server/api-docs/API.md @@ -491,3 +491,60 @@ Responses: ```bash curl -g "$BOOKKEEPING_URL/api/lhcPeriodsStatistics/1?token=$TOKEN" ``` + +## GAQ detectors + +Detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass. + +### `GET /gaqDetectors` - List the detectors contributing to the GAQ of a run in a data pass + +Operation id: `listGaqDetectors` + +Returns the detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass, sorted by name. Not paginated. +The list is empty when no detector is set for this run and data pass, **or when the run or the data pass does not exist** (never `404`). + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `dataPassId` | query | integer | yes | Data pass identifier. | +| `runNumber` | query | integer | yes | Run number. | + +Responses: + +- `200`: The detectors. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. + +```bash +curl -g "$BOOKKEEPING_URL/api/gaqDetectors?dataPassId=3&runNumber=56&token=$TOKEN" +``` + +### `POST /gaqDetectors` - Set the detectors contributing to the GAQ of runs in a data pass (not for LLM use) + +Operation id: `setGaqDetectors` + +Requires the role `admin` or `gaq`. For each given run, replaces the detectors contributing to the GAQ in the data pass by the given ones. +Every run must be linked to the data pass and have every given detector. + +**Not meant for LLM or MCP tool use** (`x-llm-exclude`). + +**Required role:** `admin`, `gaq` + +Request body (JSON): + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `dataPassId` | integer | yes | | +| `runNumbers` | array | yes | Run numbers of the runs to update, without duplicates. | +| `detectorIds` | array | yes | Ids of the detectors contributing to the GAQ, without duplicates. | + +Responses: + +- `201`: The created associations between runs and detectors, one per run and detector. +- `400`: The request failed validation (see the validation errors format), **or** a run is not linked to the data pass, a detector does not exist or a run does not have a given detector. In these last cases the error has `title: Service unavailable` and the reason in `detail`. +- `403`: The token lacks the role required by this operation. +- `404`: The data pass does not exist. + +```bash +curl -g -X POST "$BOOKKEEPING_URL/api/gaqDetectors?token=$TOKEN" \ + -H 'content-type: application/json' \ + -d '{"dataPassId":3,"runNumbers":[49,56],"detectorIds":[4,7]}' +``` diff --git a/lib/server/api-docs/index.html b/lib/server/api-docs/index.html index 39c3c7d91c..0025838022 100644 --- a/lib/server/api-docs/index.html +++ b/lib/server/api-docs/index.html @@ -896,6 +896,96 @@

LHC periods statistics

curl -g "$BOOKKEEPING_URL/api/lhcPeriodsStatistics/1?token=$TOKEN"
 
+

GAQ detectors

+

Detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass.

+
+

GET /gaqDetectors - List the detectors contributing to the GAQ of a run in a data pass

+ +

Operation id: listGaqDetectors

+

Returns the detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass, sorted by name. Not paginated. +The list is empty when no detector is set for this run and data pass, or when the run or the data pass does not exist (never 404).

+ + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
dataPassIdqueryintegeryesData pass identifier.
runNumberqueryintegeryesRun number.
+

Responses:

+
    +
  • 200: The detectors.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
+
curl -g "$BOOKKEEPING_URL/api/gaqDetectors?dataPassId=3&runNumber=56&token=$TOKEN"
+
+
+
+

POST /gaqDetectors - Set the detectors contributing to the GAQ of runs in a data pass not for LLM use

+ +

Operation id: setGaqDetectors

+

Requires the role admin or gaq. For each given run, replaces the detectors contributing to the GAQ in the data pass by the given ones. +Every run must be linked to the data pass and have every given detector.

+

Not meant for LLM or MCP tool use (x-llm-exclude).

+

Required role: admin, gaq

+

Request body (JSON):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredNotes
dataPassIdintegeryes
runNumbersarrayyesRun numbers of the runs to update, without duplicates.
detectorIdsarrayyesIds of the detectors contributing to the GAQ, without duplicates.
+

Responses:

+
    +
  • 201: The created associations between runs and detectors, one per run and detector.
  • +
  • 400: The request failed validation (see the validation errors format), or a run is not linked to the data pass, a detector does not exist or a run does not have a given detector. In these last cases the error has title: Service unavailable and the reason in detail.
  • +
  • 403: The token lacks the role required by this operation.
  • +
  • 404: The data pass does not exist.
  • +
+
curl -g -X POST "$BOOKKEEPING_URL/api/gaqDetectors?token=$TOKEN" \
+  -H 'content-type: application/json' \
+  -d '{"dataPassId":3,"runNumbers":[49,56],"detectorIds":[4,7]}'
+
+