From 2770c649c6ef55e7ef486b46634a00f409bab202 Mon Sep 17 00:00:00 2001 From: devlikepro Date: Thu, 27 Aug 2026 12:12:12 +0700 Subject: [PATCH] feat: IsDuration nestjs field validation --- src/nestjs/validation/IsDuration.test.ts | 66 ++++++++++++++++++++++++ src/nestjs/validation/IsDuration.ts | 41 +++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/nestjs/validation/IsDuration.test.ts create mode 100644 src/nestjs/validation/IsDuration.ts diff --git a/src/nestjs/validation/IsDuration.test.ts b/src/nestjs/validation/IsDuration.test.ts new file mode 100644 index 00000000..966de839 --- /dev/null +++ b/src/nestjs/validation/IsDuration.test.ts @@ -0,0 +1,66 @@ +import { validate } from 'class-validator'; + +import { IsDuration, parseDurationMs } from './IsDuration'; + +describe('parseDurationMs', () => { + it('parses human duration strings into milliseconds', () => { + expect(parseDurationMs('1s')).toBe(1000); + expect(parseDurationMs('30m')).toBe(30 * 60 * 1000); + expect(parseDurationMs('24h')).toBe(24 * 60 * 60 * 1000); + expect(parseDurationMs('7d')).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseDurationMs('7 days')).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('parses bare numbers as milliseconds', () => { + expect(parseDurationMs('500')).toBe(500); + }); + + it('rejects invalid values', () => { + expect(parseDurationMs('junk')).toBeNull(); + expect(parseDurationMs('')).toBeNull(); + expect(parseDurationMs(' ')).toBeNull(); + expect(parseDurationMs(null)).toBeNull(); + expect(parseDurationMs(undefined)).toBeNull(); + expect(parseDurationMs(123)).toBeNull(); + expect(parseDurationMs({})).toBeNull(); + }); + + it('rejects negative durations', () => { + expect(parseDurationMs('-1h')).toBeNull(); + expect(parseDurationMs('-5')).toBeNull(); + }); +}); + +class TestDto { + @IsDuration() + ttl: string; +} + +async function validateTtl(ttl: any) { + const dto = new TestDto(); + dto.ttl = ttl; + return await validate(dto); +} + +describe('IsDuration', () => { + it('accepts valid duration strings', async () => { + expect(await validateTtl('30m')).toHaveLength(0); + expect(await validateTtl('24h')).toHaveLength(0); + expect(await validateTtl('7d')).toHaveLength(0); + }); + + it('rejects invalid values with a helpful message', async () => { + const errors = await validateTtl('junk'); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ + isDuration: 'must be a duration string like "30m", "24h" or "7d".', + }); + }); + + it('rejects negative durations and non-strings', async () => { + expect(await validateTtl('-1h')).toHaveLength(1); + expect(await validateTtl(3600)).toHaveLength(1); + expect(await validateTtl(undefined)).toHaveLength(1); + }); +}); diff --git a/src/nestjs/validation/IsDuration.ts b/src/nestjs/validation/IsDuration.ts new file mode 100644 index 00000000..90d02881 --- /dev/null +++ b/src/nestjs/validation/IsDuration.ts @@ -0,0 +1,41 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; +import * as ms from 'ms'; + +/** + * Parse a human duration ('30m', '24h', '7d') into milliseconds. + * Returns null when the value is not a valid non-negative duration. + */ +export function parseDurationMs(value: any): number | null { + if (typeof value !== 'string' || !value.trim()) { + return null; + } + let duration: number | undefined; + try { + duration = ms(value as ms.StringValue); + } catch { + return null; + } + if (typeof duration !== 'number' || Number.isNaN(duration) || duration < 0) { + return null; + } + return duration; +} + +export function IsDuration(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'isDuration', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return parseDurationMs(value) !== null; + }, + defaultMessage() { + return 'must be a duration string like "30m", "24h" or "7d".'; + }, + }, + }); + }; +}