Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/domain/dtos/GetAllEnvironmentsDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
});

Expand Down
5 changes: 3 additions & 2 deletions lib/domain/dtos/filters/LhcFillsFilterDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
5 changes: 3 additions & 2 deletions lib/domain/dtos/filters/RunFilterDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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)),
Expand Down
14 changes: 9 additions & 5 deletions lib/usecases/environment/GetAllEnvironmentsUseCase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
39 changes: 26 additions & 13 deletions lib/utilities/rangeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` });
}
}
}
Expand All @@ -58,20 +71,20 @@ export const validateRange = (value, helpers) => {
* @returns {Set<Number>} 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);
}
}
});
Expand Down
11 changes: 11 additions & 0 deletions test/api/environments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions test/lib/utilities/rangeUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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' });
});

Expand Down
8 changes: 7 additions & 1 deletion test/public/envs/overview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Loading