diff --git a/src/common/value-objects/date-time/date-time.spec.ts b/src/common/value-objects/date-time/date-time.spec.ts new file mode 100644 index 0000000..c7dba3d --- /dev/null +++ b/src/common/value-objects/date-time/date-time.spec.ts @@ -0,0 +1,302 @@ +import { DateTime } from './date-time'; +import { InvalidDateTimeError } from './invalid-date-time.error'; + +describe('DateTime', () => { + const originalTimezone = process.env.DEFAULT_TIMEZONE; + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.DEFAULT_TIMEZONE; + } else { + process.env.DEFAULT_TIMEZONE = originalTimezone; + } + }); + + describe('create', () => { + it('parses ISO with +07:00 offset into UTC unix milliseconds', () => { + const dt = DateTime.create('2026-08-20T17:00:00+07:00'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('parses ISO with Z into the same instant as an equivalent offset', () => { + const withOffset = DateTime.create('2026-08-20T17:00:00+07:00'); + const withZ = DateTime.create('2026-08-20T10:00:00.000Z'); + + expect(withZ.value).toBe(withOffset.value); + expect(withZ.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('parses compact offset without colon', () => { + const dt = DateTime.create('2026-08-20T17:00:00.123+0700'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 123)); + }); + + it('parses hour-only ISO offset', () => { + const dt = DateTime.create('2026-08-20T17:00:00+07'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('accepts lowercase z designator', () => { + const dt = DateTime.create('2026-08-20T10:00:00z'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('truncates fractional seconds beyond milliseconds', () => { + const dt = DateTime.create('2026-08-20T10:00:00.123456Z'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 123)); + }); + + it('trims surrounding whitespace', () => { + const dt = DateTime.create(' 2026-08-20T10:00:00Z '); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('interprets naive ISO as wall clock in DEFAULT_TIMEZONE (GMT+7)', () => { + delete process.env.DEFAULT_TIMEZONE; + + const dt = DateTime.create('2026-08-20T17:00:00'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('interprets naive ISO with fractional seconds using env timezone', () => { + process.env.DEFAULT_TIMEZONE = 'GMT+7'; + + const dt = DateTime.create('2026-08-20T17:00:00.500'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 500)); + }); + + it('uses a custom DEFAULT_TIMEZONE for naive ISO parse', () => { + process.env.DEFAULT_TIMEZONE = 'UTC+0'; + + const dt = DateTime.create('2026-08-20T17:00:00'); + + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 17, 0, 0, 0)); + }); + + it('does not change value of offset-aware instant when DEFAULT_TIMEZONE changes', () => { + process.env.DEFAULT_TIMEZONE = 'GMT+7'; + const dt = DateTime.create('2026-08-20T10:00:00Z'); + const valueBefore = dt.value; + + process.env.DEFAULT_TIMEZONE = 'UTC-5'; + + expect(dt.value).toBe(valueBefore); + expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + }); + + it('rejects date-only strings', () => { + expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError); + }); + + it('rejects empty string', () => { + expect(() => DateTime.create('')).toThrow(InvalidDateTimeError); + }); + + it('rejects whitespace-only input', () => { + expect(() => DateTime.create(' ')).toThrow(InvalidDateTimeError); + }); + + it('rejects non-string input', () => { + expect(() => DateTime.create(null as unknown as string)).toThrow( + InvalidDateTimeError, + ); + expect(() => DateTime.create(123 as unknown as string)).toThrow( + InvalidDateTimeError, + ); + }); + + it('rejects junk', () => { + expect(() => DateTime.create('not-a-date')).toThrow(InvalidDateTimeError); + }); + + it('rejects overflow calendar dates', () => { + expect(() => DateTime.create('2026-02-30T10:00:00Z')).toThrow( + InvalidDateTimeError, + ); + }); + + it('rejects out-of-range ISO offsets', () => { + expect(() => DateTime.create('2026-08-20T10:00:00+15:00')).toThrow( + InvalidDateTimeError, + ); + expect(() => DateTime.create('2026-08-20T10:00:00+14:30')).toThrow( + InvalidDateTimeError, + ); + expect(() => DateTime.create('2026-08-20T10:00:00-12:30')).toThrow( + InvalidDateTimeError, + ); + }); + + it('rejects invalid DEFAULT_TIMEZONE when parsing naive ISO', () => { + process.env.DEFAULT_TIMEZONE = 'Asia/Jakarta'; + + expect(() => DateTime.create('2026-08-20T17:00:00')).toThrow( + InvalidDateTimeError, + ); + }); + + it('throws a generic message that does not echo the input', () => { + expect(() => DateTime.create('not-a-date')).toThrow('Invalid date time'); + }); + }); + + describe('fromUnixMs', () => { + it('reconstructs from a unix millisecond timestamp', () => { + const ms = Date.UTC(2026, 7, 20, 10, 0, 0, 0); + const dt = DateTime.fromUnixMs(ms); + + expect(dt.value).toBe(ms); + }); + + it('round-trips with create', () => { + const created = DateTime.create('2026-08-20T17:00:00+07:00'); + const restored = DateTime.fromUnixMs(created.value); + + expect(restored.equals(created)).toBe(true); + }); + + it('rejects non-finite numbers', () => { + expect(() => DateTime.fromUnixMs(Number.NaN)).toThrow( + InvalidDateTimeError, + ); + expect(() => DateTime.fromUnixMs(Number.POSITIVE_INFINITY)).toThrow( + InvalidDateTimeError, + ); + }); + + it('rejects non-safe integers', () => { + expect(() => DateTime.fromUnixMs(1.5)).toThrow(InvalidDateTimeError); + expect(() => DateTime.fromUnixMs(Number.MAX_SAFE_INTEGER + 2)).toThrow( + InvalidDateTimeError, + ); + }); + + it('rejects values outside the Date time-value range', () => { + expect(() => DateTime.fromUnixMs(8_640_000_000_000_001)).toThrow( + InvalidDateTimeError, + ); + expect(() => DateTime.fromUnixMs(-8_640_000_000_000_001)).toThrow( + InvalidDateTimeError, + ); + }); + + it('accepts pre-1970 negative timestamps', () => { + const ms = Date.UTC(1969, 11, 31, 23, 59, 59, 0); + const dt = DateTime.fromUnixMs(ms); + + expect(dt.value).toBe(ms); + expect(dt.format('UTC')).toBe('1969-12-31T23:59:59.000+00:00'); + }); + + it('rejects non-number input', () => { + expect(() => + DateTime.fromUnixMs('1724150000000' as unknown as number), + ).toThrow(InvalidDateTimeError); + }); + }); + + describe('format / toString', () => { + it('formats in DEFAULT_TIMEZONE (GMT+7) by default', () => { + delete process.env.DEFAULT_TIMEZONE; + const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + + expect(dt.format()).toBe('2026-08-20T17:00:00.000+07:00'); + expect(dt.toString()).toBe('2026-08-20T17:00:00.000+07:00'); + }); + + it('formats in an explicit timezone override', () => { + const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + + expect(dt.format('UTC')).toBe('2026-08-20T10:00:00.000+00:00'); + expect(dt.format('Z')).toBe('2026-08-20T10:00:00.000+00:00'); + expect(dt.format('+00:00')).toBe('2026-08-20T10:00:00.000+00:00'); + }); + + it('changes render output when DEFAULT_TIMEZONE changes', () => { + const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + + process.env.DEFAULT_TIMEZONE = 'GMT+7'; + expect(dt.format()).toBe('2026-08-20T17:00:00.000+07:00'); + + process.env.DEFAULT_TIMEZONE = 'UTC-5'; + expect(dt.format()).toBe('2026-08-20T05:00:00.000-05:00'); + }); + + it('rejects invalid timezone override', () => { + const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + + expect(() => dt.format('Asia/Jakarta')).toThrow(InvalidDateTimeError); + }); + + it('rejects out-of-range timezone override', () => { + const dt = DateTime.fromUnixMs(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); + + expect(() => dt.format('+15:00')).toThrow(InvalidDateTimeError); + expect(() => dt.format('GMT-13')).toThrow(InvalidDateTimeError); + }); + + it('rejects formatting when offset shift overflows Date range', () => { + const dt = DateTime.fromUnixMs(8_640_000_000_000_000); + + expect(() => dt.format('GMT+7')).toThrow(InvalidDateTimeError); + }); + }); + + describe('equals', () => { + it('returns true for the same instant from different inputs', () => { + const a = DateTime.create('2026-08-20T17:00:00+07:00'); + const b = DateTime.create('2026-08-20T10:00:00Z'); + + expect(a.equals(b)).toBe(true); + }); + + it('returns false for different instants', () => { + const a = DateTime.create('2026-08-20T10:00:00Z'); + const b = DateTime.create('2026-08-20T11:00:00Z'); + + expect(a.equals(b)).toBe(false); + }); + + it('returns false for null or undefined', () => { + const dt = DateTime.create('2026-08-20T10:00:00Z'); + + expect(dt.equals(null as unknown as DateTime)).toBe(false); + expect(dt.equals(undefined as unknown as DateTime)).toBe(false); + }); + }); + + describe('serialization', () => { + it('toJSON returns unix milliseconds', () => { + const ms = Date.UTC(2026, 7, 20, 10, 0, 0, 0); + const dt = DateTime.fromUnixMs(ms); + + expect(dt.toJSON()).toBe(ms); + expect(JSON.stringify({ at: dt })).toBe(`{"at":${ms}}`); + }); + }); + + describe('immutability', () => { + it('is frozen after creation', () => { + const dt = DateTime.create('2026-08-20T10:00:00Z'); + + expect(Object.isFrozen(dt)).toBe(true); + }); + + it('cannot be constructed with new from outside', () => { + expect( + () => + new (DateTime as unknown as new (value: number) => DateTime)( + Date.UTC(2026, 7, 20, 10, 0, 0, 0), + ), + ).toThrow(); + }); + }); +}); diff --git a/src/common/value-objects/date-time/date-time.ts b/src/common/value-objects/date-time/date-time.ts new file mode 100644 index 0000000..661950b --- /dev/null +++ b/src/common/value-objects/date-time/date-time.ts @@ -0,0 +1,227 @@ +import { InvalidDateTimeError } from './invalid-date-time.error'; + +const DEFAULT_TZ_FALLBACK = 'GMT+7'; + +/** ECMAScript Date time value limits (±100_000_000 days from epoch). */ +const MAX_UNIX_MS = 8_640_000_000_000_000; + +/** ISO datetime with optional fractional seconds and optional Z / offset. */ +const ISO_DATETIME = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i; + +/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */ +const TZ_OFFSET = + /^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i; + +function pad2(n: number): string { + return n.toString().padStart(2, '0'); +} + +function pad3(n: number): string { + return n.toString().padStart(3, '0'); +} + +function parseFractionalMs(raw: string | undefined): number { + if (raw === undefined) { + return 0; + } + // Truncate beyond millisecond precision (do not round — avoids second carry). + return Number.parseInt(raw.slice(0, 3).padEnd(3, '0'), 10); +} + +function assertValidOffset(hours: number, minutes: number, sign: number): void { + if (minutes > 59) { + throw new InvalidDateTimeError(); + } + // Real fixed-offset range is -12:00 to +14:00. + const total = sign * (hours * 60 + minutes); + if (total < -12 * 60 || total > 14 * 60) { + throw new InvalidDateTimeError(); + } +} + +/** + * Parses a fixed-offset timezone string into minutes east of UTC. + * Accepts: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700, Z, UTC, GMT (and negatives). + */ +export function parseTimezoneOffsetMinutes(raw: string): number { + const trimmed = raw.trim(); + const match = TZ_OFFSET.exec(trimmed); + if (!match) { + throw new InvalidDateTimeError(); + } + + // Z / UTC / GMT alone leave the sign capture unset. + if (match[1] === undefined) { + return 0; + } + + const sign = match[1] === '-' ? -1 : 1; + const hours = Number.parseInt(match[2], 10); + const minutes = match[3] !== undefined ? Number.parseInt(match[3], 10) : 0; + assertValidOffset(hours, minutes, sign); + + return sign * (hours * 60 + minutes); +} + +export function resolveDefaultTimezone(): string { + const fromEnv = process.env.DEFAULT_TIMEZONE; + if (fromEnv === undefined || fromEnv.trim() === '') { + return DEFAULT_TZ_FALLBACK; + } + return fromEnv.trim(); +} + +function resolveDefaultOffsetMinutes(): number { + return parseTimezoneOffsetMinutes(resolveDefaultTimezone()); +} + +function formatOffset(offsetMinutes: number): string { + const sign = offsetMinutes < 0 ? '-' : '+'; + const abs = Math.abs(offsetMinutes); + const hours = Math.floor(abs / 60); + const minutes = abs % 60; + return `${sign}${pad2(hours)}:${pad2(minutes)}`; +} + +function utcMsFromParts( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + ms: number, + offsetMinutes: number, +): number { + const asUtc = Date.UTC(year, month - 1, day, hour, minute, second, ms); + const d = new Date(asUtc); + + // Reject overflow (e.g. Feb 30) via round-trip on calendar components. + if ( + d.getUTCFullYear() !== year || + d.getUTCMonth() !== month - 1 || + d.getUTCDate() !== day || + d.getUTCHours() !== hour || + d.getUTCMinutes() !== minute || + d.getUTCSeconds() !== second || + d.getUTCMilliseconds() !== ms + ) { + throw new InvalidDateTimeError(); + } + + return asUtc - offsetMinutes * 60_000; +} + +function formatInOffset(utcMs: number, offsetMinutes: number): string { + const shifted = new Date(utcMs + offsetMinutes * 60_000); + if (Number.isNaN(shifted.getTime())) { + throw new InvalidDateTimeError(); + } + const y = shifted.getUTCFullYear(); + const m = pad2(shifted.getUTCMonth() + 1); + const d = pad2(shifted.getUTCDate()); + const h = pad2(shifted.getUTCHours()); + const min = pad2(shifted.getUTCMinutes()); + const s = pad2(shifted.getUTCSeconds()); + const ms = pad3(shifted.getUTCMilliseconds()); + return `${y}-${m}-${d}T${h}:${min}:${s}.${ms}${formatOffset(offsetMinutes)}`; +} + +export class DateTime { + private static readonly createToken = Symbol('DateTime.create'); + + private constructor( + private readonly unixMs: number, + token: symbol, + ) { + if (token !== DateTime.createToken) { + throw new TypeError( + 'DateTime can only be created via DateTime.create() or DateTime.fromUnixMs()', + ); + } + Object.freeze(this); + } + + static create(raw: string): DateTime { + if (typeof raw !== 'string') { + throw new InvalidDateTimeError(); + } + + const trimmed = raw.trim(); + const match = ISO_DATETIME.exec(trimmed); + if (!match) { + throw new InvalidDateTimeError(); + } + + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + const hour = Number.parseInt(match[4], 10); + const minute = Number.parseInt(match[5], 10); + const second = Number.parseInt(match[6], 10); + const ms = parseFractionalMs(match[7]); + + let offsetMinutes: number; + if (match[8] !== undefined && match[8].toUpperCase() === 'Z') { + offsetMinutes = 0; + } else if (match[9] !== undefined) { + const sign = match[9] === '-' ? -1 : 1; + const oh = Number.parseInt(match[10], 10); + const om = match[11] !== undefined ? Number.parseInt(match[11], 10) : 0; + assertValidOffset(oh, om, sign); + offsetMinutes = sign * (oh * 60 + om); + } else { + offsetMinutes = resolveDefaultOffsetMinutes(); + } + + const utcMs = utcMsFromParts( + year, + month, + day, + hour, + minute, + second, + ms, + offsetMinutes, + ); + + return new DateTime(utcMs, DateTime.createToken); + } + + static fromUnixMs(ms: number): DateTime { + if ( + typeof ms !== 'number' || + !Number.isSafeInteger(ms) || + Math.abs(ms) > MAX_UNIX_MS + ) { + throw new InvalidDateTimeError(); + } + + return new DateTime(ms, DateTime.createToken); + } + + get value(): number { + return this.unixMs; + } + + equals(other: DateTime): boolean { + return other instanceof DateTime && this.unixMs === other.unixMs; + } + + format(tz?: string): string { + const offsetMinutes = + tz === undefined + ? resolveDefaultOffsetMinutes() + : parseTimezoneOffsetMinutes(tz); + return formatInOffset(this.unixMs, offsetMinutes); + } + + toString(): string { + return this.format(); + } + + toJSON(): number { + return this.unixMs; + } +} diff --git a/src/common/value-objects/date-time/invalid-date-time.error.ts b/src/common/value-objects/date-time/invalid-date-time.error.ts new file mode 100644 index 0000000..8528a8a --- /dev/null +++ b/src/common/value-objects/date-time/invalid-date-time.error.ts @@ -0,0 +1,6 @@ +export class InvalidDateTimeError extends Error { + constructor() { + super('Invalid date time'); + this.name = 'InvalidDateTimeError'; + } +}