feat: add DateUtils and StringUtils for date and string manipulation
- Implement DateUtils for handling date operations with timezone support. - Add unit tests for DateUtils to ensure functionality and edge cases. - Create StringUtils for string manipulation with a fluent API. - Include unit tests for StringUtils covering various string operations. - Introduce EncryptionUtils for secure data encryption and decryption. - Add tests for EncryptionUtils to validate encryption and decryption processes. - Update index.ts to export new utility modules. - Introduce encryption key management with a dedicated key file.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { DateUtils } from './date.utils';
|
||||
|
||||
describe('DateUtils', () => {
|
||||
// Setup standard environment before each test
|
||||
beforeEach(() => {
|
||||
// 1. Reset Global Timezone to UTC for deterministic results across environments
|
||||
DateUtils.setGlobalConfig('UTC');
|
||||
|
||||
// 2. Mock console warn/error to keep the terminal clean during error handling tests
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up all mocks and restore real timers after each test
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create an instance with the current time (now) using static method', () => {
|
||||
const date = DateUtils.now();
|
||||
expect(date).toBeInstanceOf(DateUtils);
|
||||
expect(date.toDate()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should create an instance from a specific ISO string', () => {
|
||||
// Use full ISO format (.000Z) for precision
|
||||
const input = '2025-01-01T10:00:00.000Z';
|
||||
const date = new DateUtils(input);
|
||||
expect(date.toISOString()).toBe(input);
|
||||
});
|
||||
|
||||
it('should create a clone from another DateUtils instance', () => {
|
||||
const original = new DateUtils('2025-01-01T00:00:00.000Z');
|
||||
const clone = new DateUtils(original);
|
||||
|
||||
expect(clone.toISOString()).toBe(original.toISOString());
|
||||
expect(clone).not.toBe(original); // Ensure references are different (Memory Address check)
|
||||
});
|
||||
|
||||
it('should handle invalid input by falling back to current time (Graceful Fallback)', () => {
|
||||
// Freeze system time for precise assertions
|
||||
const fixedTime = new Date('2025-01-01T12:00:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fixedTime);
|
||||
|
||||
const invalidDate = new DateUtils('invalid-date-string-xyz');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid date input'));
|
||||
expect(invalidDate.toISOString()).toBe(fixedTime.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global Configuration', () => {
|
||||
it('should update global timezone correctly', () => {
|
||||
DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
expect(DateUtils.getGlobalTimezone()).toBe('Asia/Jakarta');
|
||||
});
|
||||
|
||||
it('should handle invalid timezone gracefully', () => {
|
||||
const initialTz = DateUtils.getGlobalTimezone();
|
||||
DateUtils.setGlobalConfig('Mars/Alien_City');
|
||||
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
expect(DateUtils.getGlobalTimezone()).toBe(initialTz);
|
||||
});
|
||||
|
||||
it('should apply timezone to new instances', () => {
|
||||
const utcString = '2025-01-01T00:00:00Z';
|
||||
|
||||
// Test UTC
|
||||
DateUtils.setGlobalConfig('UTC');
|
||||
const dateUtc = new DateUtils(utcString);
|
||||
expect(dateUtc.format('HH:mm')).toBe('00:00');
|
||||
|
||||
// Test Jakarta (UTC+7)
|
||||
DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
const dateJkt = new DateUtils(utcString);
|
||||
expect(dateJkt.format('HH:mm')).toBe('07:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Immutability & Manipulation', () => {
|
||||
// This verifies that .clone() is working effectively
|
||||
it('should be immutable on "add"', () => {
|
||||
const start = new DateUtils('2025-01-01T00:00:00.000Z');
|
||||
const nextDay = start.add(1, 'day');
|
||||
|
||||
expect(start.format('DD')).toBe('01'); // Original instance MUST NOT change
|
||||
expect(nextDay.format('DD')).toBe('02'); // New instance should reflect the change
|
||||
expect(start).not.toBe(nextDay); // Ensure they are different objects
|
||||
});
|
||||
|
||||
it('should be immutable on "subtract"', () => {
|
||||
const start = new DateUtils('2025-01-02T00:00:00.000Z');
|
||||
const prevDay = start.subtract(1, 'day');
|
||||
|
||||
expect(start.format('DD')).toBe('02'); // Original instance MUST NOT change
|
||||
expect(prevDay.format('DD')).toBe('01');
|
||||
expect(start).not.toBe(prevDay);
|
||||
});
|
||||
|
||||
it('should be immutable on "startOf"', () => {
|
||||
const date = new DateUtils('2025-01-15T12:00:00.000Z');
|
||||
const startOfMonth = date.startOf('month');
|
||||
|
||||
expect(date.format('DD')).toBe('15'); // Original remains 15th
|
||||
expect(startOfMonth.format('DD')).toBe('01'); // New instance is 1st
|
||||
expect(startOfMonth).not.toBe(date);
|
||||
});
|
||||
|
||||
it('should be immutable on "endOf"', () => {
|
||||
const date = new DateUtils('2025-01-01T00:00:00.000Z');
|
||||
const endOfDay = date.endOf('day');
|
||||
|
||||
// Format HH:mm:ss depends on global timezone (currently UTC)
|
||||
expect(endOfDay.format('HH:mm:ss')).toBe('23:59:59');
|
||||
expect(endOfDay).not.toBe(date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Formatting & Comparison', () => {
|
||||
it('should format date string correctly', () => {
|
||||
const date = new DateUtils('2025-12-25T00:00:00.000Z');
|
||||
expect(date.format('DD/MM/YYYY')).toBe('25/12/2025');
|
||||
});
|
||||
|
||||
it('should return strict ISO 8601 string (always UTC)', () => {
|
||||
DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
// Input Jakarta local time 7 AM = 0 AM UTC
|
||||
const date = new DateUtils('2025-01-01T07:00:00+07:00');
|
||||
expect(date.toISOString()).toBe('2025-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should compare dates correctly', () => {
|
||||
const d1 = new DateUtils('2025-01-01T00:00:00.000Z');
|
||||
const d2 = new DateUtils('2025-01-02T00:00:00.000Z');
|
||||
|
||||
expect(d1.isBefore(d2)).toBe(true);
|
||||
expect(d2.isAfter(d1)).toBe(true);
|
||||
expect(d1.isSame(d1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should calculate precise diff', () => {
|
||||
const d1 = new DateUtils('2025-01-01T00:00:00.000Z');
|
||||
const d2 = new DateUtils('2025-01-03T00:00:00.000Z');
|
||||
expect(d2.diff(d1, 'day')).toBe(2);
|
||||
});
|
||||
|
||||
it('should calculate calendar day diff (ignoring time)', () => {
|
||||
// Case: 23:00 vs 01:00 the next day
|
||||
// Technically only 2 hours difference (0 full days), but 1 calendar day difference.
|
||||
const d1 = new DateUtils('2025-01-01T23:00:00.000Z');
|
||||
const d2 = new DateUtils('2025-01-02T01:00:00.000Z');
|
||||
|
||||
expect(d2.diff(d1, 'day')).toBe(0); // Standard diff (less than 24h)
|
||||
expect(d2.diffCalendarDay(d1)).toBe(1); // Calendar diff
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timezone Utilities (Indonesian & Offset)', () => {
|
||||
it('should return custom mapping for Indonesia (WIB)', () => {
|
||||
DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
const date = new DateUtils();
|
||||
expect(date.timezoneAbbr).toBe('WIB');
|
||||
});
|
||||
|
||||
it('should return custom mapping for Indonesia (WITA)', () => {
|
||||
DateUtils.setGlobalConfig('Asia/Makassar');
|
||||
const date = new DateUtils();
|
||||
expect(date.timezoneAbbr).toBe('WITA');
|
||||
});
|
||||
|
||||
it('should return custom mapping for Indonesia (WIT)', () => {
|
||||
DateUtils.setGlobalConfig('Asia/Jayapura');
|
||||
const date = new DateUtils();
|
||||
expect(date.timezoneAbbr).toBe('WIT');
|
||||
});
|
||||
|
||||
it('should fallback to standard abbr for other zones', () => {
|
||||
DateUtils.setGlobalConfig('UTC');
|
||||
const date = new DateUtils();
|
||||
expect(date.timezoneAbbr).toBe('UTC');
|
||||
});
|
||||
|
||||
// NEW FEATURE TEST: timezoneWithOffset
|
||||
it('should return combined timezone abbreviation and offset', () => {
|
||||
const isoInput = '2025-01-01T12:00:00Z'; // Input in UTC
|
||||
|
||||
// Case 1: Jakarta
|
||||
DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
const dateJkt = new DateUtils(isoInput);
|
||||
// Jakarta = UTC+7
|
||||
expect(dateJkt.timezoneWithOffset).toBe('WIB (+07:00)');
|
||||
|
||||
// Case 2: Makassar
|
||||
DateUtils.setGlobalConfig('Asia/Makassar');
|
||||
const dateMks = new DateUtils(isoInput);
|
||||
// Makassar = UTC+8
|
||||
expect(dateMks.timezoneWithOffset).toBe('WITA (+08:00)');
|
||||
|
||||
// Case 3: UTC
|
||||
DateUtils.setGlobalConfig('UTC');
|
||||
const dateUtc = new DateUtils(isoInput);
|
||||
expect(dateUtc.timezoneWithOffset).toBe('UTC (+00:00)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Epoch & Helpers', () => {
|
||||
it('should return correct epoch values', () => {
|
||||
const input = 1704067200000; // 2024-01-01 00:00:00 UTC
|
||||
const date = new DateUtils(input);
|
||||
|
||||
expect(date.timestamp).toBe(input);
|
||||
expect(date.epochMillis).toBe(input);
|
||||
expect(date.epochSeconds).toBe(1704067200);
|
||||
});
|
||||
|
||||
it('should return supported timezones list', () => {
|
||||
const timezones = DateUtils.getSupportedTimezones();
|
||||
expect(Array.isArray(timezones)).toBe(true);
|
||||
expect(timezones).toContain('Asia/Jakarta');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* ------------------------------------------------------------
|
||||
* DateUtils
|
||||
* ------------------------------------------------------------
|
||||
* Centralized date and time utility built on top of Day.js.
|
||||
*
|
||||
* Features:
|
||||
* - Global timezone normalization
|
||||
* - Safe cloning with timezone consistency
|
||||
* - Fluent and immutable API
|
||||
* - ISO 8601 compliant output
|
||||
* - Explicit Indonesian timezone abbreviation support
|
||||
*
|
||||
* ⚠️ Day.js plugins MUST be initialized before using DateUtils.
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import dayjs, { Dayjs, OpUnitType, ManipulateType } from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Day.js Bootstrap
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(advancedFormat);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Accepted input formats for DateUtils.
|
||||
*/
|
||||
export type DateInput = string | number | Date | Dayjs | null | undefined;
|
||||
|
||||
/**
|
||||
* Supported time units for manipulation and comparison.
|
||||
*/
|
||||
export type TimeUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second';
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Constants
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Explicit mapping for Indonesian timezone abbreviations.
|
||||
* This avoids ambiguity and ensures consistent output.
|
||||
*/
|
||||
const INDONESIA_TZ_MAP: Record<string, string> = {
|
||||
'Asia/Jakarta': 'WIB',
|
||||
'Asia/Pontianak': 'WIB',
|
||||
'Asia/Bangkok': 'WIB',
|
||||
'Asia/Makassar': 'WITA',
|
||||
'Asia/Ujung_Pandang': 'WITA',
|
||||
'Asia/Jayapura': 'WIT',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Interfaces
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Fluent date manipulation interface.
|
||||
* All methods are immutable and return a new instance.
|
||||
*/
|
||||
interface IDateUtils {
|
||||
format(format?: string): string;
|
||||
add(value: number, unit: TimeUnit): IDateUtils;
|
||||
subtract(value: number, unit: TimeUnit): IDateUtils;
|
||||
isBefore(date: DateInput | DateUtils): boolean;
|
||||
isAfter(date: DateInput | DateUtils): boolean;
|
||||
isSame(date: DateInput | DateUtils, unit?: TimeUnit): boolean;
|
||||
diff(date: DateInput | DateUtils, unit: TimeUnit, precise?: boolean): number;
|
||||
diffCalendarDay(date: DateInput | DateUtils): number;
|
||||
startOf(unit: TimeUnit): IDateUtils;
|
||||
endOf(unit: TimeUnit): IDateUtils;
|
||||
toISOString(): string;
|
||||
toDate(): Date;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* DateUtils
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Application-wide date abstraction.
|
||||
*
|
||||
* All instances are automatically normalized
|
||||
* to a single global timezone.
|
||||
*/
|
||||
export class DateUtils implements IDateUtils {
|
||||
private readonly _date: Dayjs;
|
||||
|
||||
/**
|
||||
* Global default timezone.
|
||||
* Used by all DateUtils instances.
|
||||
*/
|
||||
private static _defaultTimezone: string = dayjs.tz.guess();
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Global Configuration
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set the global timezone for the application.
|
||||
*
|
||||
* @example
|
||||
* DateUtils.setGlobalConfig('Asia/Jakarta');
|
||||
*/
|
||||
static setGlobalConfig(timezone: string): void {
|
||||
try {
|
||||
dayjs().tz(timezone);
|
||||
DateUtils._defaultTimezone = timezone;
|
||||
} catch {
|
||||
console.error(`[DateUtils] Invalid timezone "${timezone}". Using previous value.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active global timezone.
|
||||
*/
|
||||
static getGlobalTimezone(): string {
|
||||
return DateUtils._defaultTimezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DateUtils instance representing the current moment.
|
||||
*/
|
||||
static now(): DateUtils {
|
||||
return new DateUtils();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Constructor
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
constructor(date?: DateInput | DateUtils) {
|
||||
if (date instanceof DateUtils) {
|
||||
this._date = date.getRaw().tz(DateUtils._defaultTimezone);
|
||||
} else {
|
||||
this._date = dayjs(date).tz(DateUtils._defaultTimezone);
|
||||
}
|
||||
|
||||
if (!this._date.isValid()) {
|
||||
console.warn('[DateUtils] Invalid date input. Falling back to now().');
|
||||
this._date = dayjs().tz(DateUtils._defaultTimezone);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Internal Utilities
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Access the internal Day.js instance.
|
||||
* Intended for advanced usage only.
|
||||
*/
|
||||
getRaw(): Dayjs {
|
||||
return this._date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input into Day.js using the global timezone.
|
||||
*/
|
||||
private toDayjs(date: DateInput | DateUtils): Dayjs {
|
||||
return date instanceof DateUtils ? date.getRaw() : dayjs(date).tz(DateUtils._defaultTimezone);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Formatting & Conversion
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
format(format: string = 'YYYY-MM-DD'): string {
|
||||
return this._date.format(format);
|
||||
}
|
||||
|
||||
toISOString(): string {
|
||||
// Always returns UTC (ISO 8601 compliant)
|
||||
return this._date.toISOString();
|
||||
}
|
||||
|
||||
toDate(): Date {
|
||||
return this._date.toDate();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Manipulation
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
add(value: number, unit: TimeUnit): DateUtils {
|
||||
return new DateUtils(this._date.clone().add(value, unit as ManipulateType));
|
||||
}
|
||||
|
||||
subtract(value: number, unit: TimeUnit): DateUtils {
|
||||
return new DateUtils(this._date.clone().subtract(value, unit as ManipulateType));
|
||||
}
|
||||
|
||||
startOf(unit: TimeUnit): DateUtils {
|
||||
return new DateUtils(this._date.clone().startOf(unit as OpUnitType));
|
||||
}
|
||||
|
||||
endOf(unit: TimeUnit): DateUtils {
|
||||
return new DateUtils(this._date.clone().endOf(unit as OpUnitType));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Comparison
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
isBefore(date: DateInput | DateUtils): boolean {
|
||||
return this._date.isBefore(this.toDayjs(date));
|
||||
}
|
||||
|
||||
isAfter(date: DateInput | DateUtils): boolean {
|
||||
return this._date.isAfter(this.toDayjs(date));
|
||||
}
|
||||
|
||||
isSame(date: DateInput | DateUtils, unit?: TimeUnit): boolean {
|
||||
return this._date.isSame(this.toDayjs(date), unit as OpUnitType);
|
||||
}
|
||||
|
||||
diff(date: DateInput | DateUtils, unit: TimeUnit, precise: boolean = false): number {
|
||||
return this._date.diff(this.toDayjs(date), unit as OpUnitType, precise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-day difference ignoring time components.
|
||||
*/
|
||||
diffCalendarDay(date: DateInput | DateUtils): number {
|
||||
const target = this.toDayjs(date);
|
||||
return this._date.startOf('day').diff(target.startOf('day'), 'day');
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Epoch Helpers
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
get timestamp(): number {
|
||||
return this._date.valueOf();
|
||||
}
|
||||
|
||||
get epochMillis(): number {
|
||||
return this._date.valueOf();
|
||||
}
|
||||
|
||||
get epochSeconds(): number {
|
||||
return this._date.unix();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Timezone Utilities
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve supported IANA timezones from the runtime environment.
|
||||
*/
|
||||
static getSupportedTimezones(): string[] {
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone');
|
||||
} catch {
|
||||
console.warn('[DateUtils] Failed to retrieve timezones via Intl.');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'UTC',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Makassar',
|
||||
'Asia/Jayapura',
|
||||
'Asia/Singapore',
|
||||
'Asia/Tokyo',
|
||||
'Australia/Sydney',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'America/New_York',
|
||||
'America/Los_Angeles',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable timezone abbreviation.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Indonesian mapping (WIB / WITA / WIT)
|
||||
* 2. Day.js dynamic abbreviation (DST-safe)
|
||||
*/
|
||||
get timezoneAbbr(): string {
|
||||
const tz = DateUtils._defaultTimezone;
|
||||
return INDONESIA_TZ_MAP[tz] ?? this._date.format('z');
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Timezone Utilities
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns timezone abbreviation combined with GMT offset.
|
||||
* Useful for UI labels.
|
||||
*
|
||||
* @example
|
||||
* "WIB (+07:00)"
|
||||
* "UTC (+00:00)"
|
||||
*/
|
||||
get timezoneWithOffset(): string {
|
||||
return `${this.timezoneAbbr} (${this._date.format('Z')})`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user