diff --git a/src/index.js b/src/index.js index b813c6078..663bcf96c 100644 --- a/src/index.js +++ b/src/index.js @@ -14,7 +14,7 @@ import isIPRange from './lib/isIPRange'; import isFQDN from './lib/isFQDN'; import isDate from './lib/isDate'; import isTime from './lib/isTime'; - +import isDuration from './lib/isDuration'; import isBoolean from './lib/isBoolean'; import isLocale from './lib/isLocale'; @@ -242,6 +242,7 @@ const validator = { isTaxID, isDate, isTime, + isDuration, isLicensePlate, isVAT, ibanLocales, diff --git a/src/lib/isDuration.js b/src/lib/isDuration.js new file mode 100644 index 000000000..5a4416272 --- /dev/null +++ b/src/lib/isDuration.js @@ -0,0 +1,25 @@ +import assertString from './util/assertString'; + +/* eslint-disable max-len */ +// Matches the ms package (https://github.com/vercel/ms) StringValue format: +// A numeric value (optionally decimal, optionally negative) followed by an optional unit. +// Unit is optional — a bare number is treated as milliseconds. +// Format: `${number}`, `${number}${unit}`, or `${number} ${unit}` (case-insensitive) +const msFormatRegex = + /^(-?\d*\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mo|years?|yrs?|y)?$/i; +/* eslint-enable max-len */ + +export default function isDuration(str, options = {}) { + assertString(str); + + const allowNegative = + typeof options.allowNegative === 'boolean' ? options.allowNegative : true; + + const match = msFormatRegex.exec(str); + + if (!match) return false; + + if (!allowNegative && parseFloat(match[1]) < 0) return false; + + return true; +} diff --git a/test/validators/isDuration.test.js b/test/validators/isDuration.test.js new file mode 100644 index 000000000..813fa2b2f --- /dev/null +++ b/test/validators/isDuration.test.js @@ -0,0 +1,118 @@ +import test from '../testFunctions'; + +describe('isDuration', () => { + it('should validate ms-compatible duration strings (default options)', () => { + test({ + validator: 'isDuration', + valid: [ + // Bare numbers (implicit milliseconds) + '0', + '100', + '3.5', + // Short units, no space + '10ms', + '1s', + '2m', + '3h', + '4d', + '5w', + '6mo', + '7y', + // Short units, with space + '10 ms', + '1 s', + '2 m', + '3 h', + '4 d', + '5 w', + '6 mo', + '7 y', + // Long units (plural), case insensitive + '100 milliseconds', + '1 second', + '2 minutes', + '3 hours', + '4 days', + '5 weeks', + '6 months', + '7 years', + // Long units (singular) + '1 millisecond', + '1 hour', + '1 minute', + '1 day', + '1 week', + '1 month', + '1 year', + // Abbreviated aliases + '10 msecs', + '10 msec', + '30 secs', + '30 sec', + '45 mins', + '45 min', + '2 hrs', + '2 hr', + '2 yrs', + '2 yr', + // Mixed case + '2 Days', + '5 HOURS', + '10MS', + // Decimal values + '1.5h', + '0.5 days', + '2.75 weeks', + // Negative values (allowed by default) + '-1s', + '-2 hours', + '-3.5d', + ], + invalid: [ + // Empty string + '', + // Non-numeric strings + 'abc', + 'foo bar', + // Unknown unit + '10xyz', + '5 lightyears', + // Multiple components (not supported) + '1h 30m', + '2 days 5 hours', + // Missing number + 's', + 'ms', + ' ', + // Just a dot + '.', + // Number only with trailing garbage + '10sx', + ], + }); + }); + + it('should reject negative durations when allowNegative is false', () => { + test({ + validator: 'isDuration', + args: [{ allowNegative: false }], + valid: ['10s', '2 days', '0', '1.5h'], + invalid: ['-1s', '-2 hours', '-0.5d'], + }); + }); + + it('should allow negative durations when allowNegative is true (explicit)', () => { + test({ + validator: 'isDuration', + args: [{ allowNegative: true }], + valid: ['-1s', '-2 hours', '10ms'], + }); + }); + + it('should throw on non-string input', () => { + test({ + validator: 'isDuration', + error: [null, undefined, 123, [], {}], + }); + }); +});