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(); }); }); });