diff --git a/QualityControl/common/library/typedef/DetectorSummary.js b/QualityControl/common/library/typedef/DetectorSummary.js new file mode 100644 index 000000000..481c887ea --- /dev/null +++ b/QualityControl/common/library/typedef/DetectorSummary.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * @typedef {object} DetectorSummary + * @property {string} name - Human-readable detector name. + * @property {string} type - Detector type identifier. + */ diff --git a/QualityControl/lib/controllers/FilterController.js b/QualityControl/lib/controllers/FilterController.js index 0fcd51908..476da42d3 100644 --- a/QualityControl/lib/controllers/FilterController.js +++ b/QualityControl/lib/controllers/FilterController.js @@ -63,12 +63,11 @@ export class FilterController { */ async getFilterConfigurationHandler(req, res) { try { - let runTypes = []; - if (this._filterService) { - runTypes = await this._filterService.runTypes; - } + const runTypes = this._filterService?.runTypes ?? []; + const detectors = this._filterService?.detectors ?? []; res.status(200).json({ runTypes, + detectors, }); } catch (error) { res.status(503).json({ error: error.message || error }); diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index f5d9f6353..33da3dd6d 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -20,6 +20,7 @@ import { wrapRunStatus } from '../dtos/BookkeepingDto.js'; const GET_BKP_DATABASE_STATUS_PATH = '/api/status/database'; const GET_RUN_TYPES_PATH = '/api/runTypes'; const GET_RUN_PATH = '/api/runs'; +const GET_DETECTORS_PATH = '/api/detectors'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/bkp-service`; @@ -181,6 +182,23 @@ export class BookkeepingService { } } + /** + * Retrieves the information about the detectors from the Bookkeeping service. + * @returns {Promise} Array of detector summaries. + */ + async retrieveDetectorSummaries() { + const { data } = await httpGetJson( + this._hostname, + this._port, + this._createPath(GET_DETECTORS_PATH), + { + protocol: this._protocol, + rejectUnauthorized: false, + }, + ); + return Array.isArray(data) ? data : []; + } + /** * Helper method to construct a URL path with the required authentication token. * Appends the service's token as a query parameter to the provided path. diff --git a/QualityControl/lib/services/FilterService.js b/QualityControl/lib/services/FilterService.js index d6da52c57..dd191ac34 100644 --- a/QualityControl/lib/services/FilterService.js +++ b/QualityControl/lib/services/FilterService.js @@ -31,7 +31,7 @@ export class FilterService { this._logger = LogManager.getLogger(LOG_FACILITY); this._bookkeepingService = bookkeepingService; this._runTypes = []; - this.initFilters(); + this._detectors = Object.freeze([]); this._runTypesRefreshInterval = config?.bookkeeping?.runTypesRefreshInterval ?? (config?.bookkeeping ? 24 * 60 * 60 * 1000 : -1); @@ -48,6 +48,7 @@ export class FilterService { async initFilters() { await this._bookkeepingService.connect(); await this.getRunTypes(); + await this._initializeDetectors(); } /** @@ -71,6 +72,31 @@ export class FilterService { } } + /** + * This method is used to retrieve the list of detectors from the bookkeeping service + * @returns {Promise} Resolves when the list of detectors is available + */ + async _initializeDetectors() { + try { + if (!this._bookkeepingService.active) { + return; + } + + const detectorSummaries = await this._bookkeepingService.retrieveDetectorSummaries(); + this._detectors = Object.freeze(detectorSummaries.map(({ name, type }) => Object.freeze({ name, type }))); + } catch (error) { + this._logger.errorMessage(`Failed to retrieve detectors: ${error?.message || error}`); + } + } + + /** + * Returns a list of detector summaries. + * @returns {Readonly} An immutable array of detector summaries. + */ + get detectors() { + return this._detectors; + } + /** * Returns the interval in milliseconds for how often the list of run types should be refreshed. * @returns {number} Interval in milliseconds for refreshing the list of run types. diff --git a/QualityControl/test/lib/controllers/FiltersController.test.js b/QualityControl/test/lib/controllers/FiltersController.test.js index a5cdd026e..eaf513114 100644 --- a/QualityControl/test/lib/controllers/FiltersController.test.js +++ b/QualityControl/test/lib/controllers/FiltersController.test.js @@ -38,10 +38,17 @@ export const filtersControllerTestSuite = async () => { }); suite('getFilterConfigurationHandler', async () => { - test('should successfully retrieve run types from Bookkeeping service', async () => { + test('should successfully retrieve run types and detectors from Bookkeeping service', async () => { const filterService = sinon.createStubInstance(FilterService); const mockedRunTypes = ['runType1', 'runType2']; + const mockedDetectors = [ + { + name: 'Detector human-readable name', + type: 'Detector type identifier', + }, + ]; sinon.stub(filterService, 'runTypes').get(() => mockedRunTypes); + sinon.stub(filterService, 'detectors').get(() => mockedDetectors); const res = { status: sinon.stub().returnsThis(), @@ -51,9 +58,12 @@ export const filtersControllerTestSuite = async () => { const filterController = new FilterController(filterService); await filterController.getFilterConfigurationHandler(req, res); ok(res.status.calledWith(200), 'Response status was not 200'); - ok(res.json.calledWith({ runTypes: mockedRunTypes }), 'Run types were not sent back'); + ok( + res.json.calledWith({ runTypes: mockedRunTypes, detectors: mockedDetectors }), + 'Response should include runTypes and detectors', + ); }); - test('should return an empty array if bookkeeping service is not defined', async () => { + test('should return an empty arrays if bookkeeping service is not defined', async () => { const bkpService = null; const res = { status: sinon.stub().returnsThis(), @@ -64,8 +74,8 @@ export const filtersControllerTestSuite = async () => { await filterController.getFilterConfigurationHandler(req, res); ok(res.status.calledWith(200), 'Response status was not 200'); ok( - res.json.calledWith({ runTypes: [] }), - 'Run types were not sent as an empty array', + res.json.calledWith({ runTypes: [], detectors: [] }), + 'runTypes and detectors were not sent as an empty array', ); }); }); diff --git a/QualityControl/test/lib/services/BookkeepingService.test.js b/QualityControl/test/lib/services/BookkeepingService.test.js index 9b1c0de5e..1502d896a 100644 --- a/QualityControl/test/lib/services/BookkeepingService.test.js +++ b/QualityControl/test/lib/services/BookkeepingService.test.js @@ -288,6 +288,80 @@ export const bookkeepingServiceTestSuite = async () => { ok(Object.values(RunStatus).includes(runStatus)); }); + suite('Retrieve detector summaries', () => { + const GET_DETECTORS_PATH = '/api/detectors'; + + let bkpService = null; + + beforeEach(() => { + bkpService = new BookkeepingService(VALID_CONFIG.bookkeeping); + bkpService.validateConfig(); // ensures internal fields like _hostname/_port/_token are set + bkpService.connect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test('should handle all detector types correctly', async () => { + const mockResponse = { + data: [ + { id: 1, name: 'ACO', type: 'PHYSICAL', createdAt: 1765468282000, updatedAt: 1765468282000 }, + { id: 2, name: 'EVS', type: 'AOT-EVENT', createdAt: 1765468282000, updatedAt: 1765468282000 }, + { id: 3, name: 'GLO', type: 'QC', createdAt: 1765468282000, updatedAt: 1765468282000 }, + { id: 4, name: 'MUD', type: 'MUON-GLO', createdAt: 1765468282000, updatedAt: 1765468282000 }, + { id: 5, name: 'VTX', type: 'AOT-GLO', createdAt: 1765468282000, updatedAt: 1765468282000 }, + { id: 6, name: 'TST', type: 'VIRTUAL', createdAt: 1765468282000, updatedAt: 1765468282000 }, + ], + }; + + nock(VALID_CONFIG.bookkeeping.url) + .get(GET_DETECTORS_PATH) + .query({ token: VALID_CONFIG.bookkeeping.token }) + .reply(200, mockResponse); + + const result = await bkpService.retrieveDetectorSummaries(); + + ok(Array.isArray(result)); + strictEqual(result.length, mockResponse.data.length); + + // Verify detector data is preserved + deepStrictEqual(result, mockResponse.data); + }); + + test('should return empty array when data is not an array', async () => { + const mockResponse = { + data: null, + }; + + nock(VALID_CONFIG.bookkeeping.url) + .get(GET_DETECTORS_PATH) + .query({ token: VALID_CONFIG.bookkeeping.token }) + .reply(200, mockResponse); + + const result = await bkpService.retrieveDetectorSummaries(); + + ok(Array.isArray(result)); + strictEqual(result.length, 0); + }); + + test('should return empty array when data is empty array', async () => { + const mockResponse = { + data: [], + }; + + nock(VALID_CONFIG.bookkeeping.url) + .get(GET_DETECTORS_PATH) + .query({ token: VALID_CONFIG.bookkeeping.token }) + .reply(200, mockResponse); + + const result = await bkpService.retrieveDetectorSummaries(); + + ok(Array.isArray(result)); + strictEqual(result.length, 0); + }); + }); + test('should return ONGOING status when timeO2End is not present', async () => { const mockResponse = { data: { timeO2End: undefined } }; diff --git a/QualityControl/test/lib/services/FilterService.test.js b/QualityControl/test/lib/services/FilterService.test.js index 248671f04..2f865e69d 100644 --- a/QualityControl/test/lib/services/FilterService.test.js +++ b/QualityControl/test/lib/services/FilterService.test.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { deepStrictEqual } from 'node:assert'; +import { deepStrictEqual, ok } from 'node:assert'; import { suite, test, beforeEach, afterEach } from 'node:test'; import { FilterService } from '../../../lib/services/FilterService.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; @@ -32,6 +32,7 @@ export const filterServiceTestSuite = async () => { connect: stub(), retrieveRunTypes: stub(), retrieveRunInformation: stub(), + retrieveDetectorSummaries: stub(), active: true, // assume the bookkeeping service is active by default }; filterService = new FilterService(bookkeepingServiceMock, configMock); @@ -63,6 +64,11 @@ export const filterServiceTestSuite = async () => { deepStrictEqual(filterServiceWithCustomConfig._runTypesRefreshInterval, 5000); }); + test('should init _detectors on instantiation', async () => { + deepStrictEqual(filterService._detectors, []); + ok(Object.isFrozen(filterService._detectors)); + }); + test('should init filters on instantiation', async () => { const initFiltersStub = stub(filterService, 'initFilters'); await filterService.initFilters(); @@ -71,7 +77,26 @@ export const filterServiceTestSuite = async () => { }); suite('initFilters', async () => { + test('should call _initializeDetectors', async () => { + const initializeDetectorsStub = stub(filterService, '_initializeDetectors'); + await filterService.initFilters(); + ok(initializeDetectorsStub.calledOnce); + }); + test('should set _detectors on _initializeDetectors call', async () => { + const DETECTOR_SUMMARIES = [ + { + name: 'Detector human-readable name', + type: 'Detector type identifier', + }, + ]; + + bookkeepingServiceMock.retrieveDetectorSummaries.resolves(DETECTOR_SUMMARIES); + await filterService._initializeDetectors(); + + deepStrictEqual(filterService._detectors, DETECTOR_SUMMARIES); + ok(Object.isFrozen(filterService._detectors)); + }); }); suite('getRunTypes', async () => { diff --git a/QualityControl/test/setup/testSetupForBkp.js b/QualityControl/test/setup/testSetupForBkp.js index a9c4c8a2c..c1c061d12 100644 --- a/QualityControl/test/setup/testSetupForBkp.js +++ b/QualityControl/test/setup/testSetupForBkp.js @@ -42,6 +42,48 @@ export const initializeNockForBkp = () => { }, }, }); + nock(BKP_URL) + .persist() + .get(`/api/detectors${TOKEN_PATH}`) + .reply(200, { + data: [ + { + id: 17, + name: 'ACO', + type: 'PHYSICAL', + createdAt: 1765468282000, + updatedAt: 1765468282000, + }, + { + id: 1, + name: 'CPV', + type: 'PHYSICAL', + createdAt: 1765468282000, + updatedAt: 1765468282000, + }, + { + id: 23, + name: 'EVS', + type: 'AOT-EVENT', + createdAt: 1765468282000, + updatedAt: 1765468282000, + }, + { + id: 21, + name: 'GLO', + type: 'QC', + createdAt: 1765468282000, + updatedAt: 1765468282000, + }, + { + id: 15, + name: 'TST', + type: 'VIRTUAL', + createdAt: 1765468282000, + updatedAt: 1765468282000, + }, + ], + }); nock(BKP_URL) .persist() .get(`/api/runs/0${TOKEN_PATH}`)