From 1be53d804ade8365088c00297723bca5624a974d Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 10 Jul 2026 00:34:47 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(value-objects)!:=20=F0=9F=92=A5=20Enfor?= =?UTF-8?q?ce=20strict=20validation=20and=20immutability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/calendar-day.md | 22 +++--- docs/reference/number-value-object.md | 22 +++--- docs/reference/timestamp-interval.md | 28 ++++---- docs/reference/timestamp.md | 72 +++++++++---------- docs/reference/uuid.md | 8 +-- src/value-objects/NumberValueObject.ts | 2 +- src/value-objects/coordinates/Latitude.ts | 9 +-- src/value-objects/coordinates/Longitude.ts | 9 +-- src/value-objects/ids/UUID.ts | 3 +- src/value-objects/time/CalendarDay.ts | 52 ++++++++++---- src/value-objects/time/Duration.ts | 4 +- src/value-objects/time/Timestamp.ts | 4 ++ src/value-objects/time/TimestampInterval.ts | 16 +---- tests/value-objects/Integer.spec.ts | 12 ++-- tests/value-objects/NumberValueObject.spec.ts | 14 ++++ tests/value-objects/ids/UUID.spec.ts | 14 +++- tests/value-objects/time/CalendarDay.spec.ts | 16 +++++ tests/value-objects/time/Duration.spec.ts | 12 ++++ tests/value-objects/time/Timestamp.spec.ts | 4 ++ .../time/TimestampInterval.spec.ts | 36 ---------- 20 files changed, 200 insertions(+), 159 deletions(-) diff --git a/docs/reference/calendar-day.md b/docs/reference/calendar-day.md index e8f91b5..4afbe3d 100644 --- a/docs/reference/calendar-day.md +++ b/docs/reference/calendar-day.md @@ -38,16 +38,16 @@ This class can throw: ## Methods -| Method | Description | -| --- | --- | -| `getYear()` | Returns the numeric year. | -| `getMonth()` | Returns a `Month`. | -| `getMonthOfYear()` | Returns a `MonthOfYear`. | -| `getDay()` | Returns a `Day`. | -| `getDayOfWeek()` | Returns a `DayOfWeek`. | -| `toTimestamp()` | Returns a UTC timestamp for the day. | -| `isBefore(date)` | Compares by `YYYY-MM-DD` string value. | -| `isAfter(date)` | Compares by `YYYY-MM-DD` string value. | +| Method | Description | +| ------------------ | -------------------------------------- | +| `getYear()` | Returns the numeric year. | +| `getMonth()` | Returns a `Month`. | +| `getMonthOfYear()` | Returns a `MonthOfYear`. | +| `getDay()` | Returns a `Day`. | +| `getDayOfWeek()` | Returns a `DayOfWeek`. | +| `toTimestamp()` | Returns a UTC timestamp for the day. | +| `isBefore(date)` | Compares by `YYYY-MM-DD` string value. | +| `isAfter(date)` | Compares by `YYYY-MM-DD` string value. | ## Example @@ -64,6 +64,8 @@ day.getDayOfWeek().toString(); // depends on date ## Notes - When no value is provided, it uses the current timestamp. +- Numeric zero represents the Unix epoch (`1970-01-01`). +- Calendar dates that do not exist, such as `2022-02-31`, are rejected instead of normalized. - Internally it uses UTC date getters through `Timestamp`. ## Related diff --git a/docs/reference/number-value-object.md b/docs/reference/number-value-object.md index 613cd88..1ef960a 100644 --- a/docs/reference/number-value-object.md +++ b/docs/reference/number-value-object.md @@ -37,17 +37,17 @@ This class can throw: ## Methods -| Method | Description | -| --- | --- | -| `isZero()` | Returns true when the number is exactly 0. | -| `isGreaterThan(other)` | Returns true when the current value is greater than `other`. | +| Method | Description | +| ----------------------------- | ------------------------------------------------------------------------ | +| `isZero()` | Returns true when the number is exactly 0. | +| `isGreaterThan(other)` | Returns true when the current value is greater than `other`. | | `isGreaterOrEqualThan(other)` | Returns true when the current value is greater than or equal to `other`. | -| `isLessThan(other)` | Returns true when the current value is less than `other`. | -| `isLessOrEqualThan(other)` | Returns true when the current value is less than or equal to `other`. | -| `add(other)` | Returns a new `NumberValueObject` with the sum. | -| `subtract(other)` | Returns a new `NumberValueObject` with the difference. | -| `multiply(other)` | Returns a new `NumberValueObject` with the product. | -| `divide(other)` | Returns a new `NumberValueObject` with the quotient. | +| `isLessThan(other)` | Returns true when the current value is less than `other`. | +| `isLessOrEqualThan(other)` | Returns true when the current value is less than or equal to `other`. | +| `add(other)` | Returns a new `NumberValueObject` with the sum. | +| `subtract(other)` | Returns a new `NumberValueObject` with the difference. | +| `multiply(other)` | Returns a new `NumberValueObject` with the product. | +| `divide(other)` | Returns a new `NumberValueObject` with the quotient. | ## Example @@ -65,7 +65,7 @@ a.isGreaterThan(b); // true ## Notes - Arithmetic helpers return new instances. -- `Infinity` is accepted because the current validation only rejects `NaN`. +- `NaN`, `Infinity`, and `-Infinity` are rejected. ## Related diff --git a/docs/reference/timestamp-interval.md b/docs/reference/timestamp-interval.md index 2ba6bc0..6d746ca 100644 --- a/docs/reference/timestamp-interval.md +++ b/docs/reference/timestamp-interval.md @@ -27,7 +27,7 @@ constructor(start: Timestamp, end: Timestamp) ## Validation -`start` must be before or equal to `end` in the constructor. `modifyStart()` and `modifyEnd()` require strict before. +`start` must be before or equal to `end` in the constructor. Intervals are immutable; create a new interval when either boundary changes. ## Throws @@ -37,19 +37,17 @@ This class can throw: ## Methods -| Method | Description | -| --- | --- | -| `static fromPrimitives(primitives)` | Restores from `{ start, end }` milliseconds. | -| `toPrimitives()` | Returns `{ start, end }`. | -| `getStart()` | Returns the current start timestamp. | -| `getEnd()` | Returns the current end timestamp. | -| `getDuration()` | Returns a `Duration`. | -| `getTotalDaysOfWeek(dayOfWeek)` | Counts occurrences of a weekday within the interval. | -| `modifyStart(start)` | Mutates the start timestamp after validation. | -| `modifyEnd(end)` | Mutates the end timestamp after validation. | -| `getDaysBetweenInterval()` | Returns all `CalendarDay` values between start and end, inclusive. | -| `getOverlappingInterval(searchInterval)` | Returns overlapping interval or `null`. | -| `includes(timestamp)` | Checks whether a timestamp is inside the interval, inclusive. | +| Method | Description | +| ---------------------------------------- | ------------------------------------------------------------------ | +| `static fromPrimitives(primitives)` | Restores from `{ start, end }` milliseconds. | +| `toPrimitives()` | Returns `{ start, end }`. | +| `getStart()` | Returns the current start timestamp. | +| `getEnd()` | Returns the current end timestamp. | +| `getDuration()` | Returns a `Duration`. | +| `getTotalDaysOfWeek(dayOfWeek)` | Counts occurrences of a weekday within the interval. | +| `getDaysBetweenInterval()` | Returns all `CalendarDay` values between start and end, inclusive. | +| `getOverlappingInterval(searchInterval)` | Returns overlapping interval or `null`. | +| `includes(timestamp)` | Checks whether a timestamp is inside the interval, inclusive. | ## Example @@ -66,7 +64,7 @@ interval.includes(start); // true ## Notes -- Unlike most value objects, `modifyStart()` and `modifyEnd()` mutate internal state. Use deliberately, because apparently time was not annoying enough already. +- Interval boundaries cannot be changed after construction. ## Related diff --git a/docs/reference/timestamp.md b/docs/reference/timestamp.md index eb08d98..d41f3d2 100644 --- a/docs/reference/timestamp.md +++ b/docs/reference/timestamp.md @@ -27,45 +27,45 @@ constructor(value?: number | Date | Timestamp | string) ## Validation -String values are parsed with `new Date(value)`. Invalid dates produce `NaN`; this class itself does not reject `NaN`. +String values are parsed with `new Date(value)`. Inputs that produce `NaN`, `Infinity`, or `-Infinity` are rejected with `InvalidNumberError`. ## Methods -| Method | Description | -| --- | --- | -| `static new(value)` | Creates a new `Timestamp`. | -| `static now()` | Creates a timestamp for the current time. | -| `static fromSeconds(seconds)` | Creates from seconds. | -| `toExactHour()` | Returns a timestamp rounded down to the exact UTC hour. | -| `toMilliseconds()` | Returns milliseconds. | -| `toSeconds()` | Returns rounded seconds. | -| `toDate()` | Returns a `Date`. | -| `isBefore(other)` | Compares milliseconds. | -| `isAfter(other)` | Compares milliseconds. | -| `isBeforeOrEqual(other)` | Compares milliseconds. | -| `isAfterOrEqual(other)` | Compares milliseconds. | -| `addMilliseconds(value)` | Returns a new timestamp. | -| `addSeconds(value)` | Returns a new timestamp. | -| `addMinutes(value)` | Returns a new timestamp. | -| `addHours(value)` | Returns a new timestamp. | -| `addDays(value)` | Returns a new timestamp. | -| `addWeeks(value)` | Returns a new timestamp. | -| `addMonths(value)` | Uses a fixed 30-day month factor. | -| `addYears(value)` | Uses a fixed 365-day year factor. | -| `addDuration(duration)` | Adds a `Duration`. | -| `isSameDay(other)` | Compares `CalendarDay`. | -| `isSameMonth(other)` | Compares `MonthOfYear`. | -| `isSameYear(other)` | Compares `Year`. | -| `getCalendarDay()` | Returns a `CalendarDay`. | -| `getDay()` | Returns a UTC `Day`. | -| `getMonth()` | Returns a UTC `Month`. | -| `getYear()` | Returns a UTC `Year`. | -| `getHours()` | Returns UTC hours. | -| `getMinutes()` | Returns UTC minutes. | -| `getSeconds()` | Returns UTC seconds. | -| `getMilliseconds()` | Returns UTC milliseconds. | -| `getDayOfWeek()` | Returns JavaScript UTC day number. | -| `getMonthOfYear()` | Returns `MonthOfYear`. | +| Method | Description | +| ----------------------------- | ------------------------------------------------------- | +| `static new(value)` | Creates a new `Timestamp`. | +| `static now()` | Creates a timestamp for the current time. | +| `static fromSeconds(seconds)` | Creates from seconds. | +| `toExactHour()` | Returns a timestamp rounded down to the exact UTC hour. | +| `toMilliseconds()` | Returns milliseconds. | +| `toSeconds()` | Returns rounded seconds. | +| `toDate()` | Returns a `Date`. | +| `isBefore(other)` | Compares milliseconds. | +| `isAfter(other)` | Compares milliseconds. | +| `isBeforeOrEqual(other)` | Compares milliseconds. | +| `isAfterOrEqual(other)` | Compares milliseconds. | +| `addMilliseconds(value)` | Returns a new timestamp. | +| `addSeconds(value)` | Returns a new timestamp. | +| `addMinutes(value)` | Returns a new timestamp. | +| `addHours(value)` | Returns a new timestamp. | +| `addDays(value)` | Returns a new timestamp. | +| `addWeeks(value)` | Returns a new timestamp. | +| `addMonths(value)` | Uses a fixed 30-day month factor. | +| `addYears(value)` | Uses a fixed 365-day year factor. | +| `addDuration(duration)` | Adds a `Duration`. | +| `isSameDay(other)` | Compares `CalendarDay`. | +| `isSameMonth(other)` | Compares `MonthOfYear`. | +| `isSameYear(other)` | Compares `Year`. | +| `getCalendarDay()` | Returns a `CalendarDay`. | +| `getDay()` | Returns a UTC `Day`. | +| `getMonth()` | Returns a UTC `Month`. | +| `getYear()` | Returns a UTC `Year`. | +| `getHours()` | Returns UTC hours. | +| `getMinutes()` | Returns UTC minutes. | +| `getSeconds()` | Returns UTC seconds. | +| `getMilliseconds()` | Returns UTC milliseconds. | +| `getDayOfWeek()` | Returns JavaScript UTC day number. | +| `getMonthOfYear()` | Returns `MonthOfYear`. | ## Example diff --git a/docs/reference/uuid.md b/docs/reference/uuid.md index 372ed11..c573638 100644 --- a/docs/reference/uuid.md +++ b/docs/reference/uuid.md @@ -27,7 +27,7 @@ constructor(value: string | StringValueObject) ## Validation -Must be exactly 36 characters and match the current lowercase alphanumeric/hyphen pattern. +Must use the standard `8-4-4-4-12` hexadecimal structure with a supported UUID version and RFC variant. ## Throws @@ -38,9 +38,9 @@ This class can throw: ## Methods -| Method | Description | -| --- | --- | -| `static generate()` | Generates a random UUID-like v4 value. | +| Method | Description | +| ------------------- | --------------------------------- | +| `static generate()` | Generates a random UUID v4 value. | ## Example diff --git a/src/value-objects/NumberValueObject.ts b/src/value-objects/NumberValueObject.ts index f8c5394..53348e9 100644 --- a/src/value-objects/NumberValueObject.ts +++ b/src/value-objects/NumberValueObject.ts @@ -15,7 +15,7 @@ export class NumberValueObject extends ValueObject { } private isValidNumber(value: number): boolean { - return !isNaN(value.valueOf()); + return Number.isFinite(value.valueOf()); } public isZero(): boolean { diff --git a/src/value-objects/coordinates/Latitude.ts b/src/value-objects/coordinates/Latitude.ts index daf65f1..e0360b4 100644 --- a/src/value-objects/coordinates/Latitude.ts +++ b/src/value-objects/coordinates/Latitude.ts @@ -7,16 +7,17 @@ export class Latitude extends NumberValueObject { private static MAX_VALUE = new NumberValueObject(90); private static isValid(value: number | NumberValueObject): boolean { - const numberValue = new NumberValueObject(value.valueOf()); + const primitive = value.valueOf(); return ( - numberValue.isGreaterOrEqualThan(this.MIN_VALUE) && - numberValue.isLessOrEqualThan(this.MAX_VALUE) + Number.isFinite(primitive) && + primitive >= this.MIN_VALUE.valueOf() && + primitive <= this.MAX_VALUE.valueOf() ); } constructor(value: number | NumberValueObject) { - super(value.valueOf()); assert(Latitude.isValid(value), new InvalidLatitudeError(value.valueOf())); + super(value.valueOf()); } } diff --git a/src/value-objects/coordinates/Longitude.ts b/src/value-objects/coordinates/Longitude.ts index a476a92..4d26dce 100644 --- a/src/value-objects/coordinates/Longitude.ts +++ b/src/value-objects/coordinates/Longitude.ts @@ -7,19 +7,20 @@ export class Longitude extends NumberValueObject { private static MAX_VALUE = new NumberValueObject(180); private static isValid(value: number | NumberValueObject): boolean { - const numberValue = new NumberValueObject(value.valueOf()); + const primitive = value.valueOf(); return ( - numberValue.isGreaterOrEqualThan(this.MIN_VALUE) && - numberValue.isLessOrEqualThan(this.MAX_VALUE) + Number.isFinite(primitive) && + primitive >= this.MIN_VALUE.valueOf() && + primitive <= this.MAX_VALUE.valueOf() ); } constructor(value: number | NumberValueObject) { - super(value.valueOf()); assert( Longitude.isValid(value), new InvalidLongitudeError(value.valueOf()), ); + super(value.valueOf()); } } diff --git a/src/value-objects/ids/UUID.ts b/src/value-objects/ids/UUID.ts index eba6fd2..c3fd85c 100644 --- a/src/value-objects/ids/UUID.ts +++ b/src/value-objects/ids/UUID.ts @@ -8,7 +8,8 @@ import { ValueObject } from '../ValueObject'; export class UUID extends ValueObject { private static readonly LENGTH = 36; - private static readonly PATTERN = new RegExp(`^[a-z0-9-]{${this.LENGTH}}$`); + private static readonly PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; private static fromRandomBytes(bytes: Buffer): string { const uuidBytes = Buffer.from(bytes); diff --git a/src/value-objects/time/CalendarDay.ts b/src/value-objects/time/CalendarDay.ts index d3411b5..cab2ecd 100644 --- a/src/value-objects/time/CalendarDay.ts +++ b/src/value-objects/time/CalendarDay.ts @@ -13,15 +13,6 @@ export class CalendarDay extends ValueObject { private readonly month: number; private readonly day: number; - private static ensureIsValidValue( - value: string | Date | number | Timestamp, - ): void { - if (typeof value == 'string') { - this.ensureIsValidString(value); - } - this.ensureIsValidDate(new Date(value.valueOf())); - } - private static ensureIsValidString(value: string): void { assert( value.match(/^\d{4}-\d{1,2}-\d{1,2}$/), @@ -37,15 +28,44 @@ export class CalendarDay extends ValueObject { } private static ensureIsValidDate(value: Date | number | Timestamp): void { - assert(!isNaN(value.valueOf()), new InvalidDayError(value)); + assert(Number.isFinite(value.valueOf()), new InvalidDayError(value)); + } + + private static timestampFromString(value: string): Timestamp { + CalendarDay.ensureIsValidString(value); + + const [year, month, day] = value.split('-').map(Number); + const date = new Date(0); + date.setUTCHours(0, 0, 0, 0); + date.setUTCFullYear(year, month - 1, day); + + assert( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day, + new InvalidDayError(value), + ); + + return new Timestamp(date); + } + + private static timestampFromValue( + value: string | Date | number | Timestamp, + ): Timestamp { + if (typeof value === 'string') { + return CalendarDay.timestampFromString(value); + } + + CalendarDay.ensureIsValidDate(value); + + return new Timestamp(value); } constructor(value?: string | Date | number | Timestamp) { let timestamp: Timestamp; - if (value) { - CalendarDay.ensureIsValidValue(value); - timestamp = new Timestamp(value); + if (value !== undefined) { + timestamp = CalendarDay.timestampFromValue(value); } else { timestamp = new Timestamp(); } @@ -86,6 +106,10 @@ export class CalendarDay extends ValueObject { } public toTimestamp(): Timestamp { - return new Timestamp(Date.UTC(this.year, this.month - 1, this.day)); + const date = new Date(0); + date.setUTCHours(0, 0, 0, 0); + date.setUTCFullYear(this.year, this.month - 1, this.day); + + return new Timestamp(date); } } diff --git a/src/value-objects/time/Duration.ts b/src/value-objects/time/Duration.ts index 4e13011..c9ae63c 100644 --- a/src/value-objects/time/Duration.ts +++ b/src/value-objects/time/Duration.ts @@ -102,7 +102,7 @@ export class Duration extends NumberValueObject { return new NumberValueObject( Math.floor( new Duration(this).divide(Duration.millisecondFactors.DAYS).valueOf(), - ) % Duration.SECOND_UNITS.DAYS, + ), ); } @@ -110,7 +110,7 @@ export class Duration extends NumberValueObject { return new NumberValueObject( Math.floor( new Duration(this).divide(Duration.millisecondFactors.HOURS).valueOf(), - ) % Duration.SECOND_UNITS.HOURS, + ) % 24, ); } diff --git a/src/value-objects/time/Timestamp.ts b/src/value-objects/time/Timestamp.ts index 8e69455..8f5a49c 100644 --- a/src/value-objects/time/Timestamp.ts +++ b/src/value-objects/time/Timestamp.ts @@ -1,3 +1,5 @@ +import { InvalidNumberError } from '../../errors/InvalidNumberError'; +import { assert } from '../../patterns/Assert'; import { NumberValueObject } from '../NumberValueObject'; import { ValueObject } from '../ValueObject'; import { CalendarDay } from './CalendarDay'; @@ -50,6 +52,8 @@ export class Timestamp extends ValueObject { public constructor(value?: TimestampValue) { super(Timestamp.valueMilliseconds(value)); + + assert(Number.isFinite(this.value), new InvalidNumberError(this.value)); } public toExactHour(): Timestamp { diff --git a/src/value-objects/time/TimestampInterval.ts b/src/value-objects/time/TimestampInterval.ts index 4d25f87..bc37129 100644 --- a/src/value-objects/time/TimestampInterval.ts +++ b/src/value-objects/time/TimestampInterval.ts @@ -19,8 +19,8 @@ export class TimestampInterval extends ValueObject { } constructor( - private start: Timestamp, - private end: Timestamp, + private readonly start: Timestamp, + private readonly end: Timestamp, ) { super(`${start.valueOf()}-${end.valueOf()}`); @@ -70,18 +70,6 @@ export class TimestampInterval extends ValueObject { return count; } - public modifyStart(start: Timestamp): void { - const end = this.end; - assert(start.isBefore(end), new InvalidTimestampIntervalError(start, end)); - this.start = start; - } - - public modifyEnd(end: Timestamp): void { - const start = this.start; - assert(start.isBefore(end), new InvalidTimestampIntervalError(start, end)); - this.end = end; - } - public getDaysBetweenInterval(): CalendarDay[] { const days: CalendarDay[] = []; diff --git a/tests/value-objects/Integer.spec.ts b/tests/value-objects/Integer.spec.ts index 2d34180..23c3361 100644 --- a/tests/value-objects/Integer.spec.ts +++ b/tests/value-objects/Integer.spec.ts @@ -22,17 +22,17 @@ describe('Integer', () => { expect(() => new Integer(value)).toThrow(`Invalid number ${value}`); }); - it('should throw InvalidIntegerError for Infinity', () => { + it('should throw InvalidNumberError for Infinity', () => { const value = Infinity; - expect(() => new Integer(value)).toThrow(InvalidIntegerError); - expect(() => new Integer(value)).toThrow(`Invalid integer value ${value}`); + expect(() => new Integer(value)).toThrow(InvalidNumberError); + expect(() => new Integer(value)).toThrow(`Invalid number ${value}`); }); - it('should throw InvalidIntegerError for -Infinity', () => { + it('should throw InvalidNumberError for -Infinity', () => { const value = -Infinity; - expect(() => new Integer(value)).toThrow(InvalidIntegerError); - expect(() => new Integer(value)).toThrow(`Invalid integer value ${value}`); + expect(() => new Integer(value)).toThrow(InvalidNumberError); + expect(() => new Integer(value)).toThrow(`Invalid number ${value}`); }); }); diff --git a/tests/value-objects/NumberValueObject.spec.ts b/tests/value-objects/NumberValueObject.spec.ts index 55800d9..9676dbb 100644 --- a/tests/value-objects/NumberValueObject.spec.ts +++ b/tests/value-objects/NumberValueObject.spec.ts @@ -26,6 +26,12 @@ describe('NumberValueObject', () => { `Invalid number ${value}`, ); }); + it.each([Infinity, -Infinity])( + 'should throw InvalidNumberError for non-finite value %s', + (value) => { + expect(() => new NumberValueObject(value)).toThrow(InvalidNumberError); + }, + ); it('should throw InvalidNumberError for a parsed string', () => { const value = parseInt('Not a number'); @@ -46,6 +52,14 @@ describe('NumberValueObject', () => { }); }); + describe('divide', () => { + it('should reject division by zero', () => { + expect(() => new NumberValueObject(1).divide(0)).toThrow( + InvalidNumberError, + ); + }); + }); + describe('isGreaterThan', () => { it('should return true when value is greater than other', () => { expect( diff --git a/tests/value-objects/ids/UUID.spec.ts b/tests/value-objects/ids/UUID.spec.ts index 0179127..29d0814 100644 --- a/tests/value-objects/ids/UUID.spec.ts +++ b/tests/value-objects/ids/UUID.spec.ts @@ -28,7 +28,7 @@ describe('UUID', () => { expect(() => new UUID('123')).toThrow(InvalidLengthError); }); - it("should be a valid id when it's 36 length string", () => { + it('should accept a structurally valid UUID', () => { expect( () => new UUID('8ab0b0f7-7324-4637-bc6b-109326f081c0'), ).not.toThrow(); @@ -37,6 +37,18 @@ describe('UUID', () => { ); }); + it('should reject 36-character strings without UUID structure', () => { + expect(() => new UUID('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toThrow( + InvalidFormatError, + ); + }); + + it('should reject UUIDs with an invalid variant', () => { + expect(() => new UUID('8ab0b0f7-7324-4637-7c6b-109326f081c0')).toThrow( + InvalidFormatError, + ); + }); + it("should be invalid id when it's 36 length but invalid characters", () => { expect(() => new UUID('8ab0b0f7-7324#5637-bc6b-109326f081c0')).toThrow( InvalidFormatError, diff --git a/tests/value-objects/time/CalendarDay.spec.ts b/tests/value-objects/time/CalendarDay.spec.ts index c9b12b5..c988729 100644 --- a/tests/value-objects/time/CalendarDay.spec.ts +++ b/tests/value-objects/time/CalendarDay.spec.ts @@ -58,6 +58,22 @@ describe('CalendarDay', () => { }); }); + it('should interpret zero as the Unix epoch', () => { + expect(new CalendarDay(0).valueOf()).toBe('1970-01-01'); + }); + + it('should reject a calendar date that does not exist', () => { + expect(() => new CalendarDay('2022-02-31')).toThrow(InvalidDayError); + }); + + it('should accept a valid leap day', () => { + expect(new CalendarDay('2024-02-29').valueOf()).toBe('2024-02-29'); + }); + + it('should reject a leap day in a non-leap year', () => { + expect(() => new CalendarDay('2023-02-29')).toThrow(InvalidDayError); + }); + it('should throw InvalidDateFormatError when provided with an invalid date string', () => { expect(() => new CalendarDay('2022-01-001')).toThrow( InvalidDayFormatError, diff --git a/tests/value-objects/time/Duration.spec.ts b/tests/value-objects/time/Duration.spec.ts index e750c22..ed441f8 100644 --- a/tests/value-objects/time/Duration.spec.ts +++ b/tests/value-objects/time/Duration.spec.ts @@ -142,6 +142,12 @@ describe('Duration', () => { }); describe('getDays', () => { + it('should return all whole days for very long durations', () => { + const time = Duration.fromDays(100000); + + expect(time.getDays().valueOf()).toBe(100000); + }); + it('should return the days', () => { const milliseconds = 2 * 24 * 60 * 60 * 1000; const time = Duration.fromMilliseconds(milliseconds); @@ -173,6 +179,12 @@ describe('Duration', () => { }); describe('getHours', () => { + it('should return the hour component after whole days', () => { + const time = Duration.fromHours(26); + + expect(time.getHours().valueOf()).toBe(2); + }); + it('should return the hours', () => { const milliseconds = 2 * 60 * 60 * 1000; const time = Duration.fromMilliseconds(milliseconds); diff --git a/tests/value-objects/time/Timestamp.spec.ts b/tests/value-objects/time/Timestamp.spec.ts index 131786e..8794b3c 100644 --- a/tests/value-objects/time/Timestamp.spec.ts +++ b/tests/value-objects/time/Timestamp.spec.ts @@ -5,10 +5,14 @@ import { Day, Duration, CalendarDay, + InvalidNumberError, MonthOfYear, } from '../../../src'; describe('Timestamp', () => { + it('should reject invalid date strings', () => { + expect(() => new Timestamp('not-a-date')).toThrow(InvalidNumberError); + }); describe('constructor', () => { it('should create a Timestamp with a Date value', () => { expect(new Timestamp(new Date())).toBeInstanceOf(Timestamp); diff --git a/tests/value-objects/time/TimestampInterval.spec.ts b/tests/value-objects/time/TimestampInterval.spec.ts index d7daad4..6dac85e 100644 --- a/tests/value-objects/time/TimestampInterval.spec.ts +++ b/tests/value-objects/time/TimestampInterval.spec.ts @@ -152,42 +152,6 @@ describe('TimestampInterval', () => { ); }); - describe('modifyStart', () => { - it('should modify the start timestamp', () => { - const newStartTimestamp = new Timestamp(1721606400000); - timestampInterval.modifyStart(newStartTimestamp); - expect(timestampInterval).toMatchObject({ - start: newStartTimestamp, - end: endTimestamp, - }); - }); - - it('should throw error if the start is after the end', () => { - const newStartTimestamp = new Timestamp(1722124800000); - expect(() => timestampInterval.modifyStart(newStartTimestamp)).toThrow( - InvalidTimestampIntervalError, - ); - }); - }); - - describe('modifyEnd', () => { - it('should modify the end timestamp', () => { - const newEndTimestamp = new Timestamp(1722124800000); - timestampInterval.modifyEnd(newEndTimestamp); - expect(timestampInterval).toMatchObject({ - start: startTimestamp, - end: newEndTimestamp, - }); - }); - - it('should throw error if the start is after the end', () => { - const newEndTimestamp = new Timestamp(1721606400000); - expect(() => timestampInterval.modifyEnd(newEndTimestamp)).toThrow( - InvalidTimestampIntervalError, - ); - }); - }); - describe('getDaysBetweenInterval', () => { it('should return an array of dates in ISO format for the given interval', () => { const start = new Timestamp('2024-11-18T00:00:00.000Z'); From 0571b908de64228697e5196fbf969203b6a8c324 Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 10 Jul 2026 16:25:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(validation):=20=F0=9F=90=9B=20Handle=20?= =?UTF-8?q?Date=20bounds=20and=20sentinel=20UUIDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/timestamp.md | 2 +- docs/reference/uuid.md | 2 +- src/value-objects/ids/UUID.ts | 8 +++++++- src/value-objects/time/Timestamp.ts | 5 ++++- tests/value-objects/ids/UUID.spec.ts | 7 +++++++ tests/value-objects/time/Timestamp.spec.ts | 15 +++++++++++++++ 6 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/reference/timestamp.md b/docs/reference/timestamp.md index d41f3d2..da668c3 100644 --- a/docs/reference/timestamp.md +++ b/docs/reference/timestamp.md @@ -27,7 +27,7 @@ constructor(value?: number | Date | Timestamp | string) ## Validation -String values are parsed with `new Date(value)`. Inputs that produce `NaN`, `Infinity`, or `-Infinity` are rejected with `InvalidNumberError`. +String values are parsed with `new Date(value)`. Inputs that produce an invalid ECMAScript `Date`, including non-finite and out-of-range millisecond values, are rejected with `InvalidNumberError`. ## Methods diff --git a/docs/reference/uuid.md b/docs/reference/uuid.md index c573638..b44a136 100644 --- a/docs/reference/uuid.md +++ b/docs/reference/uuid.md @@ -27,7 +27,7 @@ constructor(value: string | StringValueObject) ## Validation -Must use the standard `8-4-4-4-12` hexadecimal structure with a supported UUID version and RFC variant. +Must use the standard `8-4-4-4-12` hexadecimal structure with a supported UUID version and RFC variant. The Nil and Max sentinel UUIDs defined by RFC 9562 are also accepted. ## Throws diff --git a/src/value-objects/ids/UUID.ts b/src/value-objects/ids/UUID.ts index c3fd85c..b4a18a8 100644 --- a/src/value-objects/ids/UUID.ts +++ b/src/value-objects/ids/UUID.ts @@ -11,6 +11,9 @@ export class UUID extends ValueObject { private static readonly PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + private static readonly SENTINEL_PATTERN = + /^(?:00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; + private static fromRandomBytes(bytes: Buffer): string { const uuidBytes = Buffer.from(bytes); uuidBytes[6] = (uuidBytes[6] % 16) + 64; @@ -45,6 +48,9 @@ export class UUID extends ValueObject { value.length === UUID.LENGTH, new InvalidLengthError(this.value, UUID.LENGTH), ); - assert(UUID.PATTERN.test(value), new InvalidFormatError(this.value)); + assert( + UUID.PATTERN.test(value) || UUID.SENTINEL_PATTERN.test(value), + new InvalidFormatError(this.value), + ); } } diff --git a/src/value-objects/time/Timestamp.ts b/src/value-objects/time/Timestamp.ts index 8f5a49c..f352073 100644 --- a/src/value-objects/time/Timestamp.ts +++ b/src/value-objects/time/Timestamp.ts @@ -53,7 +53,10 @@ export class Timestamp extends ValueObject { public constructor(value?: TimestampValue) { super(Timestamp.valueMilliseconds(value)); - assert(Number.isFinite(this.value), new InvalidNumberError(this.value)); + assert( + Number.isFinite(new Date(this.value).valueOf()), + new InvalidNumberError(this.value), + ); } public toExactHour(): Timestamp { diff --git a/tests/value-objects/ids/UUID.spec.ts b/tests/value-objects/ids/UUID.spec.ts index 29d0814..a193edc 100644 --- a/tests/value-objects/ids/UUID.spec.ts +++ b/tests/value-objects/ids/UUID.spec.ts @@ -49,6 +49,13 @@ describe('UUID', () => { ); }); + it.each([ + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + ])('should accept the reserved sentinel UUID %s', (value) => { + expect(new UUID(value).valueOf()).toBe(value); + }); + it("should be invalid id when it's 36 length but invalid characters", () => { expect(() => new UUID('8ab0b0f7-7324#5637-bc6b-109326f081c0')).toThrow( InvalidFormatError, diff --git a/tests/value-objects/time/Timestamp.spec.ts b/tests/value-objects/time/Timestamp.spec.ts index 8794b3c..7dcb2e8 100644 --- a/tests/value-objects/time/Timestamp.spec.ts +++ b/tests/value-objects/time/Timestamp.spec.ts @@ -13,6 +13,21 @@ describe('Timestamp', () => { it('should reject invalid date strings', () => { expect(() => new Timestamp('not-a-date')).toThrow(InvalidNumberError); }); + + it.each([8640000000000001, -8640000000000001])( + 'should reject finite milliseconds outside the Date range: %s', + (milliseconds) => { + expect(() => new Timestamp(milliseconds)).toThrow(InvalidNumberError); + }, + ); + + it.each([8640000000000000, -8640000000000000])( + 'should accept milliseconds at the Date range boundary: %s', + (milliseconds) => { + expect(new Timestamp(milliseconds).valueOf()).toBe(milliseconds); + }, + ); + describe('constructor', () => { it('should create a Timestamp with a Date value', () => { expect(new Timestamp(new Date())).toBeInstanceOf(Timestamp);