Add DateTime value object with comprehensive unit tests
- Implemented DateTime class for handling ISO date-time parsing, formatting, and validation. - Added InvalidDateTimeError for error handling in date-time operations. - Created extensive unit tests covering various scenarios for date-time creation, parsing, and formatting, ensuring robust functionality and error handling. - Included tests for timezone handling, invalid inputs, and edge cases to validate the DateTime implementation.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user