From ded7f1d1927f9bdefe2ae71fb8b3f3634e5285d6 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:58:34 +0700 Subject: [PATCH] feat: implement DateService with timezone support and add EncryptionService with unit tests --- .vscode/settings.json | 3 +- apps/web/src/apps/modules/index.tsx | 3 +- packages/ui/src/components/date-example.tsx | 3 + .../src/date-service/date-service.test.ts | 227 ++++++++++++++++++ .../{index.ts => date-service.ts} | 26 +- .../src/encryption/encryption-service.test.ts | 123 ++++++++++ .../{index.ts => encryption-service.ts} | 22 +- packages/utils/src/example/index.ts | 3 - packages/utils/src/index.ts | 7 +- packages/utils/src/testing-example/add.ts | 1 - .../utils/src/testing-example/subtract.ts | 1 - .../utils-testing-example.test.ts | 11 - 12 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 packages/utils/src/date-service/date-service.test.ts rename packages/utils/src/date-service/{index.ts => date-service.ts} (91%) create mode 100644 packages/utils/src/encryption/encryption-service.test.ts rename packages/utils/src/encryption/{index.ts => encryption-service.ts} (68%) delete mode 100644 packages/utils/src/example/index.ts delete mode 100644 packages/utils/src/testing-example/add.ts delete mode 100644 packages/utils/src/testing-example/subtract.ts delete mode 100644 packages/utils/src/testing-example/utils-testing-example.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec..18aa648 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ { "mode": "auto" } - ] + ], + "cSpell.words": ["Ujung", "Pandang", "WITA"] } diff --git a/apps/web/src/apps/modules/index.tsx b/apps/web/src/apps/modules/index.tsx index 7c14b18..da0b2d7 100644 --- a/apps/web/src/apps/modules/index.tsx +++ b/apps/web/src/apps/modules/index.tsx @@ -1,9 +1,10 @@ +import { DateServiceComponent } from '@repo/ui'; import { Route, Routes } from 'react-router-dom'; export default function AppModule() { return ( - 1} /> + } /> ); } diff --git a/packages/ui/src/components/date-example.tsx b/packages/ui/src/components/date-example.tsx index 42f8fa9..f9a97a1 100644 --- a/packages/ui/src/components/date-example.tsx +++ b/packages/ui/src/components/date-example.tsx @@ -122,6 +122,9 @@ export const DateServiceComponent: React.FC = () => { ({currentTime.format('Z')}) +
+ {currentTime.timezoneWithOffset} +
{currentTime.format('dddd, DD MMMM YYYY')}
diff --git a/packages/utils/src/date-service/date-service.test.ts b/packages/utils/src/date-service/date-service.test.ts new file mode 100644 index 0000000..ba51ca6 --- /dev/null +++ b/packages/utils/src/date-service/date-service.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { DateService } from './date-service'; + +describe('DateService', () => { + // Setup standard environment before each test + beforeEach(() => { + // 1. Reset Global Timezone to UTC for deterministic results across environments + DateService.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 = DateService.now(); + expect(date).toBeInstanceOf(DateService); + 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 DateService(input); + expect(date.toISOString()).toBe(input); + }); + + it('should create a clone from another DateService instance', () => { + const original = new DateService('2025-01-01T00:00:00.000Z'); + const clone = new DateService(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 DateService('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', () => { + DateService.setGlobalConfig('Asia/Jakarta'); + expect(DateService.getGlobalTimezone()).toBe('Asia/Jakarta'); + }); + + it('should handle invalid timezone gracefully', () => { + const initialTz = DateService.getGlobalTimezone(); + DateService.setGlobalConfig('Mars/Alien_City'); + + expect(console.error).toHaveBeenCalled(); + expect(DateService.getGlobalTimezone()).toBe(initialTz); + }); + + it('should apply timezone to new instances', () => { + const utcString = '2025-01-01T00:00:00Z'; + + // Test UTC + DateService.setGlobalConfig('UTC'); + const dateUtc = new DateService(utcString); + expect(dateUtc.format('HH:mm')).toBe('00:00'); + + // Test Jakarta (UTC+7) + DateService.setGlobalConfig('Asia/Jakarta'); + const dateJkt = new DateService(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 DateService('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 DateService('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 DateService('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 DateService('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 DateService('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)', () => { + DateService.setGlobalConfig('Asia/Jakarta'); + // Input Jakarta local time 7 AM = 0 AM UTC + const date = new DateService('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 DateService('2025-01-01T00:00:00.000Z'); + const d2 = new DateService('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 DateService('2025-01-01T00:00:00.000Z'); + const d2 = new DateService('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 DateService('2025-01-01T23:00:00.000Z'); + const d2 = new DateService('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)', () => { + DateService.setGlobalConfig('Asia/Jakarta'); + const date = new DateService(); + expect(date.timezoneAbbr).toBe('WIB'); + }); + + it('should return custom mapping for Indonesia (WITA)', () => { + DateService.setGlobalConfig('Asia/Makassar'); + const date = new DateService(); + expect(date.timezoneAbbr).toBe('WITA'); + }); + + it('should return custom mapping for Indonesia (WIT)', () => { + DateService.setGlobalConfig('Asia/Jayapura'); + const date = new DateService(); + expect(date.timezoneAbbr).toBe('WIT'); + }); + + it('should fallback to standard abbr for other zones', () => { + DateService.setGlobalConfig('UTC'); + const date = new DateService(); + 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 + DateService.setGlobalConfig('Asia/Jakarta'); + const dateJkt = new DateService(isoInput); + // Jakarta = UTC+7 + expect(dateJkt.timezoneWithOffset).toBe('WIB (+07:00)'); + + // Case 2: Makassar + DateService.setGlobalConfig('Asia/Makassar'); + const dateMks = new DateService(isoInput); + // Makassar = UTC+8 + expect(dateMks.timezoneWithOffset).toBe('WITA (+08:00)'); + + // Case 3: UTC + DateService.setGlobalConfig('UTC'); + const dateUtc = new DateService(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 DateService(input); + + expect(date.timestamp).toBe(input); + expect(date.epochMillis).toBe(input); + expect(date.epochSeconds).toBe(1704067200); + }); + + it('should return supported timezones list', () => { + const timezones = DateService.getSupportedTimezones(); + expect(Array.isArray(timezones)).toBe(true); + expect(timezones).toContain('Asia/Jakarta'); + }); + }); +}); diff --git a/packages/utils/src/date-service/index.ts b/packages/utils/src/date-service/date-service.ts similarity index 91% rename from packages/utils/src/date-service/index.ts rename to packages/utils/src/date-service/date-service.ts index f385b3b..8ec8c58 100644 --- a/packages/utils/src/date-service/index.ts +++ b/packages/utils/src/date-service/date-service.ts @@ -192,19 +192,19 @@ export class DateService implements IDateManager { * ---------------------------------------------------------------- */ add(value: number, unit: TimeUnit): DateService { - return new DateService(this._date.add(value, unit as ManipulateType)); + return new DateService(this._date.clone().add(value, unit as ManipulateType)); } subtract(value: number, unit: TimeUnit): DateService { - return new DateService(this._date.subtract(value, unit as ManipulateType)); + return new DateService(this._date.clone().subtract(value, unit as ManipulateType)); } startOf(unit: TimeUnit): DateService { - return new DateService(this._date.startOf(unit as OpUnitType)); + return new DateService(this._date.clone().startOf(unit as OpUnitType)); } endOf(unit: TimeUnit): DateService { - return new DateService(this._date.endOf(unit as OpUnitType)); + return new DateService(this._date.clone().endOf(unit as OpUnitType)); } /* ---------------------------------------------------------------- @@ -293,4 +293,22 @@ export class DateService implements IDateManager { const tz = DateService._defaultTimezone; return INDONESIA_TZ_MAP[tz] ?? this._date.format('z'); } + + /* ---------------------------------------------------------------- + * Timezone Utilities + * ---------------------------------------------------------------- */ + + // ... (method getSupportedTimezones dan get timezoneAbbr yang sudah ada) + + /** + * 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')})`; + } } diff --git a/packages/utils/src/encryption/encryption-service.test.ts b/packages/utils/src/encryption/encryption-service.test.ts new file mode 100644 index 0000000..eeff07f --- /dev/null +++ b/packages/utils/src/encryption/encryption-service.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EncryptionService } from './encryption-service'; +import { AES } from 'crypto-js'; + +// 1. Mock the key module to ensure test consistency +vi.mock('./encryption-key', () => ({ + ENC_STORAGE_KEY: 'default-test-key-123', +})); + +describe('EncryptionService', () => { + const TEST_KEY = 'secret-key-xyz'; + let service: EncryptionService; + + beforeEach(() => { + // Reset the Singleton instance to ensure clean state for every test + (EncryptionService as any).instance = undefined; + service = new EncryptionService(TEST_KEY); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Constructor', () => { + it('should create an instance with the provided key', () => { + const instance = new EncryptionService('custom-key'); + expect(instance).toBeInstanceOf(EncryptionService); + }); + + it('should use the default ENC_STORAGE_KEY if no key is provided', () => { + const instance = new EncryptionService(); + // Verify functionality with default key + const data = 'test-default'; + const encrypted = instance.encrypt(data); + expect(instance.decrypt(encrypted)).toBe(data); + }); + + it('should throw an Error if the provided key is empty', () => { + expect(() => new EncryptionService('')).toThrow('[EncryptionService] Encryption key is missing/empty.'); + }); + }); + + describe('encrypt()', () => { + it('should return an encrypted string different from the plain text', () => { + const plainText = 'Hello World'; + const encrypted = service.encrypt(plainText); + + expect(encrypted).not.toBe(plainText); + expect(encrypted).not.toBe(''); + expect(typeof encrypted).toBe('string'); + }); + + it('should return an empty string if input is empty', () => { + expect(service.encrypt('')).toBe(''); + }); + }); + + describe('decrypt()', () => { + it('should decrypt data back to the original text correctly', () => { + const plainText = 'Secret Data 123'; + const encrypted = service.encrypt(plainText); + const decrypted = service.decrypt(encrypted); + + expect(decrypted).toBe(plainText); + }); + + it('should return an empty string if input is empty', () => { + expect(service.decrypt('')).toBe(''); + }); + + it('should return an empty string if decrypted with the WRONG key', () => { + const plainText = 'Sensitive Data'; + + // Encrypt with Service A (TEST_KEY) + const encrypted = service.encrypt(plainText); + + // Decrypt with Service B (WRONG_KEY) + const wrongService = new EncryptionService('wrong-key-999'); + const result = wrongService.decrypt(encrypted); + + expect(result).toBe(''); + }); + + it('should return an empty string if input is malformed (Graceful Fail)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const malformedData = 'invalid-base64-string-@@@'; + const result = service.decrypt(malformedData); + + expect(result).toBe(''); + // NOTE: crypto-js handles garbage input gracefully without throwing, + // so console.error is NOT called here. + }); + + it('should catch error and log it when decryption throws an exception (Critical Fail)', () => { + /** + * Spy on console.error to suppress and track error logging during the test. + * Mocked to prevent error messages from appearing in test output. + */ + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // FORCE an error by mocking AES.decrypt implementation + vi.spyOn(AES, 'decrypt').mockImplementationOnce(() => { + throw new Error('Simulated Crypto Failure'); + }); + + const result = service.decrypt('any-data'); + + expect(result).toBe(''); + // Now we expect the catch block to be executed + expect(consoleSpy).toHaveBeenCalledWith('[EncryptionService] Decryption failed:', expect.any(Error)); + }); + }); + + describe('getInstance() (Singleton)', () => { + it('should return the same instance reference', () => { + const instance1 = EncryptionService.getInstance(); + const instance2 = EncryptionService.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); +}); diff --git a/packages/utils/src/encryption/index.ts b/packages/utils/src/encryption/encryption-service.ts similarity index 68% rename from packages/utils/src/encryption/index.ts rename to packages/utils/src/encryption/encryption-service.ts index af420bd..32f798d 100644 --- a/packages/utils/src/encryption/index.ts +++ b/packages/utils/src/encryption/encryption-service.ts @@ -2,7 +2,7 @@ import { AES, enc } from 'crypto-js'; import { ENC_STORAGE_KEY } from './encryption-key'; /** - * Interface contract supaya method konsisten + * Interface defining encryption service methods. */ interface IEncryptionService { encrypt(data: string): string; @@ -13,7 +13,8 @@ export class EncryptionService implements IEncryptionService { private readonly _key: string; /** - * @param key (Optional) Jika tidak diisi, otomatis pakai ENC_STORAGE_KEY + * @param key - Encryption key used for encrypting and decrypting data. + * If not provided, defaults to ENC_STORAGE_KEY. */ constructor(key: string = ENC_STORAGE_KEY) { if (!key) { @@ -23,7 +24,8 @@ export class EncryptionService implements IEncryptionService { } /** - * Mengenkripsi string plain text. + * Encrypts the given data string. + * Returns an empty string '' if input data is empty. */ public encrypt(data: string): string { if (!data) { @@ -33,8 +35,8 @@ export class EncryptionService implements IEncryptionService { } /** - * Mendekripsi string terenkripsi. - * Mengembalikan string kosong '' jika gagal decrypt atau format salah. + * Decrypts the given encrypted data string. + * Returns an empty string '' if input is empty or decryption fails. */ public decrypt(encryptedData: string): string { if (!encryptedData) { @@ -45,7 +47,7 @@ export class EncryptionService implements IEncryptionService { const bytes = AES.decrypt(encryptedData, this._key); const originalText = bytes.toString(enc.Utf8); - // Validasi tambahan: jika hasil decrypt kosong, berarti key salah atau data corrupt + // If decryption yields an empty string, consider it a failure if (!originalText) { return ''; } @@ -57,8 +59,12 @@ export class EncryptionService implements IEncryptionService { } } - // --- Static Helper (Singleton Pattern sederhana) --- - // Supaya tidak perlu 'new EncryptionService()' berulang kali + /** + * Get the singleton instance of EncryptionService. + * Uses the default ENC_STORAGE_KEY. + * @return EncryptionService instance + */ + private static instance: EncryptionService; public static getInstance(): EncryptionService { diff --git a/packages/utils/src/example/index.ts b/packages/utils/src/example/index.ts deleted file mode 100644 index 6a3cfe8..0000000 --- a/packages/utils/src/example/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function utilsExample() { - return 'This is an example utility function.'; -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d485af2..73ff3f0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ -export * from './encryption'; -export * from './example'; -export * from './date-service'; +export * from './encryption/encryption-key'; +export * from './encryption/encryption-service'; + +export * from './date-service/date-service'; diff --git a/packages/utils/src/testing-example/add.ts b/packages/utils/src/testing-example/add.ts deleted file mode 100644 index bc81dd5..0000000 --- a/packages/utils/src/testing-example/add.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number) => a + b; diff --git a/packages/utils/src/testing-example/subtract.ts b/packages/utils/src/testing-example/subtract.ts deleted file mode 100644 index 03240ac..0000000 --- a/packages/utils/src/testing-example/subtract.ts +++ /dev/null @@ -1 +0,0 @@ -export const subtract = (a: number, b: number) => a - b; diff --git a/packages/utils/src/testing-example/utils-testing-example.test.ts b/packages/utils/src/testing-example/utils-testing-example.test.ts deleted file mode 100644 index 0f223f0..0000000 --- a/packages/utils/src/testing-example/utils-testing-example.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from 'vitest'; -import { add } from './add'; -import { subtract } from './subtract'; - -test('adds 1 + 2 to equal 3', () => { - expect(add(1, 2)).toBe(3); -}); - -test('subtracts 2 - 1 to equal 1', () => { - expect(subtract(2, 1)).toBe(1); -});