diff --git a/QualityControl/common/library/runStatus.enum.js b/QualityControl/common/library/runStatus.enum.js index 5048acc1e..4ed34f412 100644 --- a/QualityControl/common/library/runStatus.enum.js +++ b/QualityControl/common/library/runStatus.enum.js @@ -16,4 +16,5 @@ export const RunStatus = Object.freeze({ ENDED: 'ENDED', ONGOING: 'ONGOING', NOT_FOUND: 'NOT_FOUND', + UNKNOWN: 'UNKNOWN', }); diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 3ad304198..541b3137d 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -19,6 +19,8 @@ import { layoutOwnerMiddleware } from './middleware/layouts/layoutOwner.middlewa import { layoutIdMiddleware } from './middleware/layouts/layoutId.middleware.js'; import { layoutServiceMiddleware } from './middleware/layouts/layoutService.middleware.js'; import { statusComponentMiddleware } from './middleware/status/statusComponent.middleware.js'; +import { runStatusFilterMiddleware } from './middleware/filters/runStatusFilter.middleware.js'; +import { runModeMiddleware } from './middleware/filters/runMode.middleware.js'; /** * Adds paths and binds websocket to instance of HttpServer passed @@ -55,7 +57,12 @@ export const setup = (http, ws) => { http.get('/object/:id', objectGetByIdValidation, objectController.getObjectById.bind(objectController)); http.get('/object', objectGetContentsValidation, objectController.getObjectContent.bind(objectController)); - http.get('/objects', objectsGetValidation, objectController.getObjects.bind(objectController), { public: true }); + http.get( + '/objects', + objectsGetValidation, + runModeMiddleware, + objectController.getObjects.bind(objectController), + ); http.get('/layouts', layoutController.getLayoutsHandler.bind(layoutController)); http.get('/layout/:id', layoutController.getLayoutHandler.bind(layoutController)); @@ -94,4 +101,9 @@ export const setup = (http, ws) => { http.get('/checkUser', userController.addUserHandler.bind(userController)); http.get('/filter/configuration', filterController.getFilterConfigurationHandler.bind(filterController)); + http.get( + '/filter/run-status/:runNumber', + runStatusFilterMiddleware, + filterController.getRunStatusHandler.bind(filterController), + ); }; diff --git a/QualityControl/lib/controllers/FilterController.js b/QualityControl/lib/controllers/FilterController.js index 48ffd8e6c..f4178c3b9 100644 --- a/QualityControl/lib/controllers/FilterController.js +++ b/QualityControl/lib/controllers/FilterController.js @@ -12,6 +12,12 @@ * or submit itself to any jurisdiction. */ +import { + LogManager, + updateAndSendExpressResponseFromNativeError, +} + from '@aliceo2/web-ui'; + /** * Gateaway class to be used to retrieve data with regard to filters */ @@ -25,6 +31,24 @@ export class FilterController { * @type {FilterService} */ this._filterService = filterService; + this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'qcg'}/filter-ctrl`); + } + + /** + * HTTP GET endpoint for retrieving the status of a run from Bookkeeping + * @param {Request} req - HTTP request + * @param {Response} res - HTTP response to provide run status information + */ + async getRunStatusHandler(req, res) { + try { + const runStatus = await this._filterService.getRunStatus(req.params.runNumber); + res.status(200).json({ + runStatus: runStatus, + }); + } catch (error) { + this._logger.errorMessage('Error getting run status:', error); + updateAndSendExpressResponseFromNativeError(res, error); + } } /** diff --git a/QualityControl/lib/controllers/ObjectController.js b/QualityControl/lib/controllers/ObjectController.js index d64327e75..022c334e2 100644 --- a/QualityControl/lib/controllers/ObjectController.js +++ b/QualityControl/lib/controllers/ObjectController.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ 'use strict'; -import { InvalidInputError, LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { LogManager, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; /** * Gateway for all QC Objects requests @@ -48,19 +48,10 @@ export class ObjectController { try { const { prefix, fields, filters = {}, inRunMode = false } = req.query; - const { RunNumber: runNumber } = filters; - const parsedRunNumber = parseInt(runNumber, 10); - - if (inRunMode && (!runNumber || isNaN(parsedRunNumber))) { - return updateAndSendExpressResponseFromNativeError( - res, - new InvalidInputError(!runNumber - ? 'RunNumber is required when in run mode' - : 'RunNumber must be a number'), - ); - } else if (inRunMode && runNumber && !isNaN(parsedRunNumber)) { - const { paths, runStatus } = await this._runModeService.retrievePathsAndSetRunStatus(parsedRunNumber, prefix); - return res.status(200).json({ paths, runStatus }); + if (inRunMode) { + const runNumber = filters?.RunNumber; + const { paths } = await this._runModeService.retrievePathsAndSetRunStatus(runNumber); + return res.status(200).json({ paths }); } const objectsData = await this._objService.retrieveLatestVersionOfObjects({ diff --git a/QualityControl/lib/dtos/filters/RunNumberDto.js b/QualityControl/lib/dtos/filters/RunNumberDto.js new file mode 100644 index 000000000..02f3d8923 --- /dev/null +++ b/QualityControl/lib/dtos/filters/RunNumberDto.js @@ -0,0 +1,28 @@ +/** + * @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 Joi from 'joi'; + +export const RunNumberDto = Joi.number() + .required() + .integer() + .min(0) + .max(999999) + .messages({ + 'any.required': 'Run number is required', + 'number.base': 'Run number must be a number', + 'number.integer': 'Run number must be an integer', + 'number.min': 'Run number must be positive', + 'number.max': 'Run number must not exceed 999999', + }); diff --git a/QualityControl/lib/middleware/filters/runMode.middleware.js b/QualityControl/lib/middleware/filters/runMode.middleware.js new file mode 100644 index 000000000..f012a3c6e --- /dev/null +++ b/QualityControl/lib/middleware/filters/runMode.middleware.js @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { InvalidInputError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { RunNumberDto } from '../../dtos/filters/RunNumberDto.js'; + +/** + * Middleware function to validate the run number if in run mode. + * @param {object} req - The request object. + * @param {object} res - The response object. + * @param {Function} next - The next middleware function in the stack. + * @returns {Promise} + */ +export const runModeMiddleware = async (req, res, next) => { + const { inRunMode = false, filters = {} } = req.query; + if (!inRunMode) { + next(); + return; + } + try { + const validatedRunNumber = await RunNumberDto.validateAsync(filters?.RunNumber); + req.query.filters = { ...filters, RunNumber: validatedRunNumber }; + next(); + } catch (error) { + updateAndSendExpressResponseFromNativeError( + res, + error.isJoi + ? new InvalidInputError(error.details[0].message) + : error, + ); + } +}; diff --git a/QualityControl/lib/middleware/filters/runStatusFilter.middleware.js b/QualityControl/lib/middleware/filters/runStatusFilter.middleware.js new file mode 100644 index 000000000..9b18170f2 --- /dev/null +++ b/QualityControl/lib/middleware/filters/runStatusFilter.middleware.js @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { InvalidInputError, updateAndSendExpressResponseFromNativeError } from '@aliceo2/web-ui'; +import { RunNumberDto } from '../../dtos/filters/RunNumberDto.js'; + +/** + * Middleware function to validate the run number and attach it to the request object. +f + * @param {object} req - The request object. + * @param {object} res - The response object. + * @param {Function} next - The next middleware function in the stack. + * @returns {Promise} + */ +export const runStatusFilterMiddleware = async (req, res, next) => { + try { + const validatedRunNumber = await RunNumberDto.validateAsync(req.params.runNumber); + req.params.runNumber = validatedRunNumber; + next(); + } catch (error) { + updateAndSendExpressResponseFromNativeError( + res, + error.isJoi + ? new InvalidInputError(error.details[0].message) + : error, + ); + } +}; diff --git a/QualityControl/lib/services/FilterService.js b/QualityControl/lib/services/FilterService.js index 36a0042b7..c5778f148 100644 --- a/QualityControl/lib/services/FilterService.js +++ b/QualityControl/lib/services/FilterService.js @@ -13,7 +13,8 @@ */ import { LogManager } from '@aliceo2/web-ui'; -const logger = LogManager.getLogger('filter/service'); +import { RunStatus } from '../../common/library/runStatus.enum.js'; +const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/filter-svc`; /** * High level service that composes, processes and maps data from the bookkeeping service @@ -25,6 +26,7 @@ export class FilterService { * @param {object} config - Config object file that defines the refresh intervals for checking run status and runtypes */ constructor(bookkeepingService, config) { + this._logger = LogManager.getLogger(LOG_FACILITY); this._bookkeepingService = bookkeepingService; this._runTypes = []; @@ -32,7 +34,7 @@ export class FilterService { (config?.bookkeeping ? 24 * 60 * 60 * 1000 : -1); this.initFilters().catch((error) => { - logger.errorMessage(`FilterService initialization failed: ${error.message || error}`); + this._logger.errorMessage(`FilterService initialization failed: ${error.message || error}`); }); } @@ -61,7 +63,7 @@ export class FilterService { } this._runTypes.sort(); } catch (error) { - logger.errorMessage(`Error while retrieving run types: ${error.message || error}`); + this._logger.errorMessage(`Error while retrieving run types: ${error.message || error}`); this._runTypes = []; } } @@ -81,4 +83,25 @@ export class FilterService { get runTypes() { return [...this._runTypes]; } + + /** + * This method is used to retrieve the run status from the bookkeeping service + * @param {number} runNumber - run number to retrieve the status for + * @returns {Promise} - resolves with the run status + */ + async getRunStatus(runNumber) { + try { + const runStatus = await this._bookkeepingService.retrieveRunStatus(runNumber); + + if (!runStatus || !Object.values(RunStatus).includes(runStatus)) { + this._logger.warnMessage(`Invalid run status received for run ${runNumber}: ${runStatus}`); + return RunStatus.UNKNOWN; + } + return runStatus; + } catch (error) { + const message = `Error while retrieving run status for run ${runNumber}: ${error.message || error}`; + this._logger.errorMessage(message); + return RunStatus.UNKNOWN; + } + } } diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index f1ed6f7c5..354dc91d0 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -54,7 +54,7 @@ export class RunModeService { async retrievePathsAndSetRunStatus(runNumber) { if (this._ongoingRuns.has(runNumber)) { const cachedPaths = parseObjects(this._ongoingRuns.get(runNumber), QCObjectDto); - return { paths: cachedPaths, runStatus: RunStatus.ONGOING }; + return { paths: cachedPaths }; } const runStatus = await this._bookkeepingService.retrieveRunStatus(runNumber); @@ -70,7 +70,6 @@ export class RunModeService { return { paths: parsedPaths, - runStatus, }; } diff --git a/QualityControl/test/api/filters/api-get-run-status.test.js b/QualityControl/test/api/filters/api-get-run-status.test.js new file mode 100644 index 000000000..c5fc67343 --- /dev/null +++ b/QualityControl/test/api/filters/api-get-run-status.test.js @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { suite, test } from 'node:test'; +import { URL_ADDRESS, OWNER_TEST_TOKEN } from '../config.js'; +import request from 'supertest'; + +export const apiGetRunStatusTests = () => { + suite('GET /filter/run-status/:runNumber', () => { + test('should return a 403 error if no authentication token is provided', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/123456`) + .get('') + .expect(403); + }); + + test('should return a 404 error if run number is not provided', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/`) + .get(`?token=${OWNER_TEST_TOKEN}`) + .expect(404, { + error: '404 - Page not found', + message: 'The requested URL was not found on this server.', + }); + }); + + test('should return a 400 error for invalid run number (negative)', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/-1`) + .get(`?token=${OWNER_TEST_TOKEN}`) + .expect(400, { + message: 'Run number must be positive', + status: 400, + title: 'Invalid Input', + }); + }); + + test('should return a 400 error for invalid run number (too large)', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/1000000`) + .get(`?token=${OWNER_TEST_TOKEN}`) + .expect(400, { + message: 'Run number must not exceed 999999', + status: 400, + title: 'Invalid Input', + }); + }); + + test('should return a 400 error for invalid run number (not a number)', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/invalid`) + .get(`?token=${OWNER_TEST_TOKEN}`) + .expect(400, { + message: 'Run number must be a number', + status: 400, + title: 'Invalid Input', + }); + }); + + test('should successfully get run status for valid run number', async () => { + await request(`${URL_ADDRESS}/api/filter/run-status/123456`) + .get(`?token=${OWNER_TEST_TOKEN}`) + .expect(200); + }); + }); +}; diff --git a/QualityControl/test/lib/controllers/FiltersController.test.js b/QualityControl/test/lib/controllers/FiltersController.test.js index 030e5648e..004a9e0b6 100644 --- a/QualityControl/test/lib/controllers/FiltersController.test.js +++ b/QualityControl/test/lib/controllers/FiltersController.test.js @@ -17,6 +17,7 @@ import { suite, test } from 'node:test'; import { FilterController } from '../../../lib/controllers/FilterController.js'; import sinon from 'sinon'; import { FilterService } from '../../../lib/services/FilterService.js'; +import { RunStatus } from '../../../common/library/runStatus.enum.js'; const VALID_CONFIG = { bookkeeping: { url: 'http://localhost:4000', @@ -68,4 +69,81 @@ export const filtersControllerTestSuite = async () => { ); }); }); + + suite('getRunStatusHandler', async () => { + test('should successfully retrieve run status from FilterService', async () => { + const filterService = sinon.createStubInstance(FilterService); + filterService.getRunStatus.resolves(RunStatus.ONGOING); + + const req = { + params: { + runNumber: 123456, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + const filterController = new FilterController(filterService); + await filterController.getRunStatusHandler(req, res); + + ok(filterService.getRunStatus.calledWith(123456), 'FilterService.getRunStatus should be called with run number'); + ok(res.status.calledWith(200), 'Response status should be 200'); + ok(res.json.calledWith({ + runStatus: RunStatus.ONGOING, + }), 'Response should contain the run status'); + }); + + test('should handle errors from FilterService and send error response', async () => { + const filterService = sinon.createStubInstance(FilterService); + const testError = new Error('Bookkeeping service unavailable'); + filterService.getRunStatus.rejects(testError); + + const req = { + params: { + runNumber: 123456, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + const filterController = new FilterController(filterService); + await filterController.getRunStatusHandler(req, res); + + ok(filterService.getRunStatus.calledWith(123456), 'FilterService.getRunStatus should be called with run number'); + ok(res.status.calledWith(500), 'Response status should be 500 for service errors'); + ok(res.json.calledWithMatch({ + message: 'Bookkeeping service unavailable', + status: 500, + title: 'Unknown Error', + }), 'Response should contain error details'); + }); + + test('should return UNKNOWN status when FilterService returns invalid status', async () => { + const filterService = sinon.createStubInstance(FilterService); + filterService.getRunStatus.resolves('UNKNOWN'); + + const req = { + params: { + runNumber: 999999, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + const filterController = new FilterController(filterService); + await filterController.getRunStatusHandler(req, res); + + ok(filterService.getRunStatus.calledWith(999999), 'FilterService.getRunStatus should be called with run number'); + ok(res.status.calledWith(200), 'Response status should be 200'); + ok(res.json.calledWith({ + runStatus: RunStatus.UNKNOWN, + }), 'Response should contain UNKNOWN status'); + }); + }); }; diff --git a/QualityControl/test/lib/controllers/ObjectController.test.js b/QualityControl/test/lib/controllers/ObjectController.test.js index 02fd4cf7a..d3bbdb04f 100644 --- a/QualityControl/test/lib/controllers/ObjectController.test.js +++ b/QualityControl/test/lib/controllers/ObjectController.test.js @@ -56,46 +56,19 @@ export const objectControllerTestSuite = async () => { { objectName: 'object2', path: 'qc/path/object2' }, ]; - test('should return an invalid input error if no run number is provided in run mode', async () => { - reqMock.query.inRunMode = true; - reqMock.query.filters = {}; - await objectController.getObjects(reqMock, resMock); - ok(resMock.status.calledWith(400)); - ok(resMock.json.calledWithMatch({ - message: 'RunNumber is required when in run mode', - status: 400, - title: 'Invalid Input', - })); - }); - - test('should return an invalid input error if run number is not a number in run mode', async () => { - reqMock.query.inRunMode = true; - reqMock.query.filters = { RunNumber: 'abc' }; - await objectController.getObjects(reqMock, resMock); - ok(resMock.status.calledWith(400)); - ok(resMock.json.calledWithMatch({ - message: 'RunNumber must be a number', - status: 400, - title: 'Invalid Input', - })); - }); - - test('should retrieve paths and set run status in run mode with run number', async () => { reqMock.query.inRunMode = true; reqMock.query.filters = { RunNumber: 123 }; RunMonitoringServiceMock.retrievePathsAndSetRunStatus.resolves({ paths: mockObjectsList, - runStatus: 'ONGOING', }); await objectController.getObjects(reqMock, resMock); - ok(RunMonitoringServiceMock.retrievePathsAndSetRunStatus.calledWith(123, undefined)); + ok(RunMonitoringServiceMock.retrievePathsAndSetRunStatus.calledWith(123)); ok(resMock.status.calledWith(200)); ok(resMock.json.calledWith({ paths: mockObjectsList, - runStatus: 'ONGOING', })); }); diff --git a/QualityControl/test/lib/middlewares/filters/runMode.middleware.test.js b/QualityControl/test/lib/middlewares/filters/runMode.middleware.test.js new file mode 100644 index 000000000..dfc6d6ac2 --- /dev/null +++ b/QualityControl/test/lib/middlewares/filters/runMode.middleware.test.js @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { suite, test } from 'node:test'; +import { ok } from 'node:assert'; +import sinon from 'sinon'; +import { runModeMiddleware } from '../../../../lib/middleware/filters/runMode.middleware.js'; + +/** + * Test suite for the run mode middleware that validates run numbers when in run mode + */ +export const runModeMiddlewareTest = () => { + suite('Run mode middleware', () => { + test('should call next() immediately if not in run mode', async () => { + const req = { + query: { + inRunMode: false, + }, + }; + const res = {}; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(next.calledOnce, 'next() should be called once'); + }); + + test('should call next() immediately if inRunMode is not provided', async () => { + const req = { + query: {}, + }; + const res = {}; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(next.calledOnce, 'next() should be called once'); + }); + + test('should return 400 error if in run mode but RunNumber filter is invalid', async () => { + const req = { + query: { + inRunMode: true, + filters: { + RunNumber: 'invalid', + }, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number must be a number', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should return 400 error if in run mode but RunNumber is negative', async () => { + const req = { + query: { + inRunMode: true, + filters: { + RunNumber: -1, + }, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number must be positive', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should successfully validate and parse RunNumber when in run mode', async () => { + const req = { + query: { + inRunMode: true, + filters: { + RunNumber: '123456', + }, + }, + }; + const res = {}; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(req.query.filters.RunNumber === 123456, 'RunNumber should be parsed to integer'); + ok(next.calledOnce, 'next() should be called once'); + }); + + test('should handle missing filters object when in run mode', async () => { + const req = { + query: { + inRunMode: true, + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runModeMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number is required', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + }); +}; diff --git a/QualityControl/test/lib/middlewares/filters/runStatusFilter.middleware.test.js b/QualityControl/test/lib/middlewares/filters/runStatusFilter.middleware.test.js new file mode 100644 index 000000000..7cd72cf14 --- /dev/null +++ b/QualityControl/test/lib/middlewares/filters/runStatusFilter.middleware.test.js @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { suite, test } from 'node:test'; +import { ok } from 'node:assert'; +import sinon from 'sinon'; +import { runStatusFilterMiddleware } from '../../../../lib/middleware/filters/runStatusFilter.middleware.js'; + +/** + * Test suite for the run status middleware that validates run numbers from URL parameters + */ +export const runStatusFilterMiddlewareTest = () => { + suite('Run status middleware', () => { + test('should return 400 error if run number parameter is missing', async () => { + const req = { + params: {}, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number is required', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should return 400 error if run number is not a valid number', async () => { + const req = { + params: { + runNumber: 'invalid', + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number must be a number', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should return 400 error if run number is negative', async () => { + const req = { + params: { + runNumber: '-1', + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number must be positive', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should return 400 error if run number exceeds maximum value', async () => { + const req = { + params: { + runNumber: '1000000', + }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(res.status.calledWith(400), 'Status should be 400'); + ok(res.json.calledWith({ + message: 'Run number must not exceed 999999', + status: 400, + title: 'Invalid Input', + }), 'Should return validation error message'); + ok(!next.called, 'next() should not be called'); + }); + + test('should successfully validate and attach run number to request', async () => { + const req = { + params: { + runNumber: '123456', + }, + }; + const res = {}; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(req.params.runNumber === 123456, 'Run number should be parsed and attached to request'); + ok(next.calledOnce, 'next() should be called once'); + }); + + test('should handle numeric run number parameter', async () => { + const req = { + params: { + runNumber: 654321, + }, + }; + const res = {}; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(req.params.runNumber === 654321, 'Numeric run number should be validated and attached'); + ok(next.calledOnce, 'next() should be called once'); + }); + + test('should validate run number at boundary values', async () => { + const req = { + params: { + runNumber: '999999', + }, + }; + const res = {}; + const next = sinon.stub(); + + await runStatusFilterMiddleware(req, res, next); + + ok(req.params.runNumber === 999999, 'Maximum valid run number should be accepted'); + ok(next.calledOnce, 'next() should be called once'); + }); + }); +}; diff --git a/QualityControl/test/lib/services/FilterService.test.js b/QualityControl/test/lib/services/FilterService.test.js index 940de044f..0df1af87a 100644 --- a/QualityControl/test/lib/services/FilterService.test.js +++ b/QualityControl/test/lib/services/FilterService.test.js @@ -15,6 +15,7 @@ import { deepStrictEqual } 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'; import { stub, restore } from 'sinon'; export const filterServiceTestSuite = async () => { @@ -30,6 +31,7 @@ export const filterServiceTestSuite = async () => { bookkeepingServiceMock = { connect: stub(), retrieveRunTypes: stub(), + retrieveRunStatus: stub(), active: true, // assume the bookkeeping service is active by default }; filterService = new FilterService(bookkeepingServiceMock, configMock); @@ -114,4 +116,70 @@ export const filterServiceTestSuite = async () => { deepStrictEqual(filterService.runTypes, []); }); }); + + suite('getRunStatus', async () => { + test('should return run status from bookkeeping service when valid', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.ONGOING); + + const result = await filterService.getRunStatus(123456); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); + deepStrictEqual(result, RunStatus.ONGOING); + }); + + test('should return ENDED status from bookkeeping service', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.ENDED); + + const result = await filterService.getRunStatus(789012); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(789012), true); + deepStrictEqual(result, RunStatus.ENDED); + }); + + test('should return NOT_FOUND status from bookkeeping service', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.NOT_FOUND); + + const result = await filterService.getRunStatus(345678); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(345678), true); + deepStrictEqual(result, RunStatus.NOT_FOUND); + }); + + test('should return UNKNOWN when bookkeeping service returns null', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves(null); + + const result = await filterService.getRunStatus(123456); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); + deepStrictEqual(result, RunStatus.UNKNOWN); + }); + + test('should return UNKNOWN when bookkeeping service returns undefined', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves(undefined); + + const result = await filterService.getRunStatus(123456); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); + deepStrictEqual(result, RunStatus.UNKNOWN); + }); + + test('should return UNKNOWN when bookkeeping service returns invalid status', async () => { + bookkeepingServiceMock.retrieveRunStatus.resolves('INVALID_STATUS'); + + const result = await filterService.getRunStatus(123456); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); + deepStrictEqual(result, RunStatus.UNKNOWN); + }); + + test('should return UNKNOWN when bookkeeping service throws error', async () => { + const testError = new Error('Bookkeeping service unavailable'); + bookkeepingServiceMock.retrieveRunStatus.rejects(testError); + + const result = await filterService.getRunStatus(123456); + + deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); + deepStrictEqual(result, RunStatus.UNKNOWN); + }); + }); }; diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index feee6cf07..940378365 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -48,7 +48,6 @@ export const runModeServiceTestSuite = async () => { deepStrictEqual(result, { paths: [{ name: '/run/path1' }], - runStatus: RunStatus.ONGOING, }); strictEqual(runModeService._ongoingRuns.has(runNumber), true); @@ -66,7 +65,6 @@ export const runModeServiceTestSuite = async () => { deepStrictEqual(result, { paths: [{ name: '/ended/path' }], - runStatus: RunStatus.ENDED, }); strictEqual(runModeService._ongoingRuns.has(runNumber), false); }); @@ -80,7 +78,6 @@ export const runModeServiceTestSuite = async () => { deepStrictEqual(result, { paths: [{ name: '/cached/path' }], - runStatus: 'ONGOING', }); sinon.assert.notCalled(bookkeepingService.retrieveRunStatus); diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 4a941c3b5..abb9b81d1 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -64,6 +64,8 @@ import { layoutIdMiddlewareTest } from './lib/middlewares/layouts/layoutId.middl import { layoutOwnerMiddlewareTest } from './lib/middlewares/layouts/layoutOwner.middleware.test.js'; import { layoutServiceMiddlewareTest } from './lib/middlewares/layouts/layoutService.middleware.test.js'; import { statusComponentMiddlewareTest } from './lib/middlewares/status/statusComponent.middleware.test.js'; +import { runModeMiddlewareTest } from './lib/middlewares/filters/runMode.middleware.test.js'; +import { runStatusFilterMiddlewareTest } from './lib/middlewares/filters/runStatusFilter.middleware.test.js'; import { 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'; @@ -82,6 +84,7 @@ import { objectGetByIdValidationMiddlewareTest } 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'; 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 @@ -186,6 +189,7 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn suite('Layout PUT request test suite', async () => apiPutLayoutTests()); suite('Layout PATCH request test suite', async () => apiPatchLayoutTests()); suite('Object GET request test suite', async () => apiGetObjectsTests()); + suite('Filters GET run status test suite', async () => await apiGetRunStatusTests()); }); suite('Back-end test suite', { timeout: BACK_END_TIMEOUT }, async () => { @@ -219,6 +223,8 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn suite('LayoutIdMiddleware test suite', async () => layoutIdMiddlewareTest()); suite('LayoutOwnerMiddleware test suite', async () => layoutOwnerMiddlewareTest()); suite('StatusComponentMiddleware test suite', async () => statusComponentMiddlewareTest()); + suite('RunModeMiddleware test suite', async () => runModeMiddlewareTest()); + suite('RunStatusFilterMiddleware test suite', async () => runStatusFilterMiddlewareTest()); suite('BookkeepingServiceTest test suite', async () => await bookkeepingServiceTestSuite()); suite('ObjectsGetValidationMiddleware test suite', async () => objectsGetValidationMiddlewareTest()); suite('ObjectGetContentsValidationMiddleware test suite', async () =>