diff --git a/lib/domain/dtos/GetAllEnvironmentsDto.js b/lib/domain/dtos/GetAllEnvironmentsDto.js index 8eb1cfba85..e1ebbd60f2 100644 --- a/lib/domain/dtos/GetAllEnvironmentsDto.js +++ b/lib/domain/dtos/GetAllEnvironmentsDto.js @@ -14,6 +14,7 @@ const Joi = require('joi'); const PaginationDto = require('./PaginationDto'); const { FromToFilterDto } = require('./filters/FromToFilterDto'); +const { validateRange, RANGE_INVALID } = require('../../utilities/rangeUtils.js'); /** * Separate filter DTO for get all environments, because EnvironmentsFilterDto @@ -23,7 +24,10 @@ const FilterDto = Joi.object({ ids: Joi.string().trim().optional(), currentStatus: Joi.string().trim().optional(), statusHistory: Joi.string().trim().optional(), - runNumbers: Joi.string().trim().optional(), + runNumbers: Joi.string().trim().custom(validateRange).messages({ + [RANGE_INVALID]: '{{#message}}', + 'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', + }).optional(), created: FromToFilterDto, }); diff --git a/lib/domain/dtos/filters/LhcFillsFilterDto.js b/lib/domain/dtos/filters/LhcFillsFilterDto.js index 9db91e46d1..7ca51c076c 100644 --- a/lib/domain/dtos/filters/LhcFillsFilterDto.js +++ b/lib/domain/dtos/filters/LhcFillsFilterDto.js @@ -11,13 +11,14 @@ * or submit itself to any jurisdiction. */ const Joi = require('joi'); -const { validateRange } = require('../../../utilities/rangeUtils'); +const { validateRange, RANGE_INVALID } = require('../../../utilities/rangeUtils'); const { validateTimeDuration } = require('../../../utilities/validateTime'); exports.LhcFillsFilterDto = Joi.object({ hasStableBeams: Joi.boolean(), fillNumbers: Joi.string().trim().custom(validateRange).messages({ - 'any.invalid': '{{#message}}', + [RANGE_INVALID]: '{{#message}}', + 'string.base': 'Fill numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', }), beamDuration: validateTimeDuration, }); diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index 0feda0ddbc..2dc9ce98ad 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -18,7 +18,7 @@ const { IntegerComparisonDto, FloatComparisonDto } = require('./NumericalCompari const { RUN_CALIBRATION_STATUS } = require('../../enums/RunCalibrationStatus.js'); const { RUN_DEFINITIONS } = require('../../enums/RunDefinition.js'); const { singleRunsCollectionCustomCheck } = require('../utils.js'); -const { validateRange } = require('../../../utilities/rangeUtils.js'); +const { validateRange, RANGE_INVALID } = require('../../../utilities/rangeUtils.js'); const DetectorsFilterDto = Joi.object({ operator: Joi.string().valid('or', 'and', 'none').required(), @@ -33,7 +33,8 @@ const EorReasonFilterDto = Joi.object({ exports.RunFilterDto = Joi.object({ runNumbers: Joi.string().trim().custom(validateRange).messages({ - 'any.invalid': '{{#message}}', + [RANGE_INVALID]: '{{#message}}', + 'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', }), calibrationStatuses: Joi.array().items(...RUN_CALIBRATION_STATUS), definitions: CustomJoi.stringArray().items(Joi.string().uppercase().trim().valid(...RUN_DEFINITIONS)), diff --git a/lib/usecases/environment/GetAllEnvironmentsUseCase.js b/lib/usecases/environment/GetAllEnvironmentsUseCase.js index fd01f05813..c742c53b62 100644 --- a/lib/usecases/environment/GetAllEnvironmentsUseCase.js +++ b/lib/usecases/environment/GetAllEnvironmentsUseCase.js @@ -21,6 +21,8 @@ const { ApiConfig } = require('../../config/index.js'); const { Op } = require('sequelize'); const { dataSource } = require('../../database/DataSource.js'); const { statusAcronyms } = require('../../domain/enums/StatusAcronyms.js'); +const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); +const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); /** * Subquery to select the latest history item for each environment. @@ -182,19 +184,21 @@ class GetAllEnvironmentsUseCase { } if (runNumbersExpression) { - // Convert the string of run numbers to an array of numbers - const filters = runNumbersExpression.split(',').map((filter) => Number(filter.trim())); + const runNumberCriteria = splitStringToStringsTrimmed(runNumbersExpression, ','); - if (filters.length) { + const finalRunNumberList = Array.from(unpackNumberRange(runNumberCriteria)); + + // Check that the final run numbers list contains at least one valid run number + if (finalRunNumberList.length > 0) { filterQueryBuilder.include({ association: 'runs', where: { // Filter should be like with only one filter and exact with more than one filter - runNumber: { [filters.length === 1 ? Op.substring : Op.in]: filters }, + runNumber: { [finalRunNumberList.length === 1 ? Op.substring : Op.in]: finalRunNumberList }, }, }); } - } + }; const filteredEnvironmentsIds = (await EnvironmentRepository.findAll(filterQueryBuilder)).map(({ id }) => id); // If no environments match the filter, return an empty result diff --git a/lib/utilities/rangeUtils.js b/lib/utilities/rangeUtils.js index 4cc9a385de..dea1155878 100644 --- a/lib/utilities/rangeUtils.js +++ b/lib/utilities/rangeUtils.js @@ -19,30 +19,43 @@ * @param {*} helpers The helpers object * @returns {Object} The value if validation passes */ +export const RANGE_INVALID = 'range.invalid'; + export const validateRange = (value, helpers) => { const MAX_RANGE_SIZE = 100; const numbers = value.split(',').map((number) => number.trim()); for (const number of numbers) { + // Check for empty strings (e.g., from trailing commas or double commas) + if (number === '') { + return helpers.error(RANGE_INVALID, { message: 'Empty value found' }); + } + if (number.includes('-')) { // Check if '-' occurs more than once in this part of the range if (number.lastIndexOf('-') !== number.indexOf('-')) { - return helpers.error('any.invalid', { message: `Invalid range: ${number}` }); + return helpers.error(RANGE_INVALID, { message: `Invalid range: ${number}` }); + } + const parts = number.split('-'); + // Ensure exactly 2 parts and both are non-empty + if (parts.length !== 2 || parts[0].trim() === '' || parts[1].trim() === '') { + return helpers.error(RANGE_INVALID, { message: `Invalid range: ${number}` }); } - const [start, end] = number.split('-').map((n) => Number(n)); + const [start, end] = parts.map((n) => Number(n)); if (Number.isNaN(start) || Number.isNaN(end) || start > end) { - return helpers.error('any.invalid', { message: `Invalid range: ${number}` }); + return helpers.error(RANGE_INVALID, { message: `Invalid range: ${number}` }); } const rangeSize = end - start + 1; if (rangeSize > MAX_RANGE_SIZE) { - return helpers.error('any.invalid', { message: `Given range exceeds max size of ${MAX_RANGE_SIZE} range: ${number}` }); + return helpers.error(RANGE_INVALID, { message: `Given range exceeds max size of ${MAX_RANGE_SIZE} range: ${number}` }); } } else { - // Prevent non-numeric input. - if (isNaN(number)) { - return helpers.error('any.invalid', { message: `Invalid number: ${number}` }); + // Single number - prevent non-numeric input using Number.isNaN for consistency + const num = Number(number); + if (Number.isNaN(num)) { + return helpers.error(RANGE_INVALID, { message: `Invalid number: ${number}` }); } } } @@ -58,20 +71,20 @@ export const validateRange = (value, helpers) => { * @returns {Set} set containing the unpacked range. */ export function unpackNumberRange(numbersRanges, rangeSplitter = '-') { - // Set to prevent duplicate values. const resultNumbers = new Set(); numbersRanges.forEach((number) => { if (number.includes(rangeSplitter)) { - const [start, end] = number.split(rangeSplitter).map((n) => parseInt(n, 10)); - if (!Number.isNaN(start) && !Number.isNaN(end)) { + const [start, end] = number.split(rangeSplitter).map((n) => Number(n)); + if (!Number.isNaN(start) && !Number.isNaN(end) && start <= end) { for (let i = start; i <= end; i++) { - resultNumbers.add(Number(i)); + resultNumbers.add(i); } } } else { - if (!isNaN(number)) { - resultNumbers.add(Number(number)); + const num = Number(number); + if (!Number.isNaN(num)) { + resultNumbers.add(num); } } }); diff --git a/test/api/environments.test.js b/test/api/environments.test.js index 770df255a9..201c31a94e 100644 --- a/test/api/environments.test.js +++ b/test/api/environments.test.js @@ -225,6 +225,17 @@ module.exports = () => { expect(environments[0].id).to.be.equal('TDI59So3d'); expect(environments[1].id).to.be.equal('Dxi029djX'); }); + + it('should successfully filter environments with query on run number range', async () => { + const response = await request(server).get('/api/environments?filter[runNumbers]=100-105'); + + expect(response.status).to.equal(200); + const environments = response.body.data; + expect(environments.length).to.be.equal(2); + // Should include all environments with run numbers between 100 and 105 + expect(environments[0].id).to.be.equal('TDI59So3d'); + expect(environments[1].id).to.be.equal('Dxi029djX'); + }); }); describe('POST /api/environments', () => { it('should return 201 if valid data is provided', async () => { diff --git a/test/lib/utilities/rangeUtils.test.js b/test/lib/utilities/rangeUtils.test.js index 509db85a4f..a7802888ca 100644 --- a/test/lib/utilities/rangeUtils.test.js +++ b/test/lib/utilities/rangeUtils.test.js @@ -12,7 +12,7 @@ */ const Sinon = require('sinon'); -const { validateRange, unpackNumberRange } = require('../../../lib/utilities/rangeUtils.js'); +const { validateRange, unpackNumberRange, RANGE_INVALID } = require('../../../lib/utilities/rangeUtils.js'); const { expect } = require('chai'); module.exports = () => { @@ -65,7 +65,7 @@ module.exports = () => { const input = '5,a,7'; validateRange(input, helpers); expect(helpers.error.calledOnce).to.be.true; - expect(helpers.error.firstCall.args[0]).to.equal('any.invalid'); + expect(helpers.error.firstCall.args[0]).to.equal(RANGE_INVALID); expect(helpers.error.firstCall.args[1]).to.deep.equal({ message: 'Invalid number: a' }); }); diff --git a/test/public/envs/overview.test.js b/test/public/envs/overview.test.js index f4613bbe8f..73eecd9a4f 100644 --- a/test/public/envs/overview.test.js +++ b/test/public/envs/overview.test.js @@ -296,9 +296,15 @@ module.exports = () => { await expectAttributeValue(page, '.runs-filter input', 'placeholder', 'e.g. 553203, 553221, ...'); await expectAttributeValue(page, '.historyItems-filter input', 'placeholder', 'e.g. D-R-X'); + // range of runNumbers + await fillInput(page, '.runs-filter input', '103-104', ['change']); + await waitForTableLength(page, 1); + // substring of a runNumber + await fillInput(page, '.runs-filter input', '10', ['change']); + await fillInput(page, '.id-filter input', 'Dxi029djX, TDI59So3d', ['change']); await page.$eval('.status-filter #checkboxes-checkbox-DESTROYED', (element) => element.click()); - await fillInput(page, '.runs-filter input', '10', ['change']); + await fillInput(page, '.historyItems-filter input', 'C-R-D-X', ['change']); const createdAtPopoverSelector = await getPopoverSelector(await page.$('.createdAt-filter .popover-trigger'));