diff --git a/packages/utils/src/date-service/date-service.ts b/packages/utils/src/date-service/date-service.ts index 8ec8c58..fad0185 100644 --- a/packages/utils/src/date-service/date-service.ts +++ b/packages/utils/src/date-service/date-service.ts @@ -67,17 +67,17 @@ const INDONESIA_TZ_MAP: Record = { * Fluent date manipulation interface. * All methods are immutable and return a new instance. */ -interface IDateManager { +interface IDateService { format(format?: string): string; - add(value: number, unit: TimeUnit): IDateManager; - subtract(value: number, unit: TimeUnit): IDateManager; + add(value: number, unit: TimeUnit): IDateService; + subtract(value: number, unit: TimeUnit): IDateService; isBefore(date: DateInput | DateService): boolean; isAfter(date: DateInput | DateService): boolean; isSame(date: DateInput | DateService, unit?: TimeUnit): boolean; diff(date: DateInput | DateService, unit: TimeUnit, precise?: boolean): number; diffCalendarDay(date: DateInput | DateService): number; - startOf(unit: TimeUnit): IDateManager; - endOf(unit: TimeUnit): IDateManager; + startOf(unit: TimeUnit): IDateService; + endOf(unit: TimeUnit): IDateService; toISOString(): string; toDate(): Date; } @@ -92,7 +92,7 @@ interface IDateManager { * All instances are automatically normalized * to a single global timezone. */ -export class DateService implements IDateManager { +export class DateService implements IDateService { private readonly _date: Dayjs; /** @@ -298,8 +298,6 @@ export class DateService implements IDateManager { * Timezone Utilities * ---------------------------------------------------------------- */ - // ... (method getSupportedTimezones dan get timezoneAbbr yang sudah ada) - /** * Returns timezone abbreviation combined with GMT offset. * Useful for UI labels. diff --git a/packages/utils/src/encryption/encryption-key.ts b/packages/utils/src/encryption-service/encryption-key.ts similarity index 100% rename from packages/utils/src/encryption/encryption-key.ts rename to packages/utils/src/encryption-service/encryption-key.ts diff --git a/packages/utils/src/encryption/encryption-service.test.ts b/packages/utils/src/encryption-service/encryption-service.test.ts similarity index 96% rename from packages/utils/src/encryption/encryption-service.test.ts rename to packages/utils/src/encryption-service/encryption-service.test.ts index eeff07f..83b1b47 100644 --- a/packages/utils/src/encryption/encryption-service.test.ts +++ b/packages/utils/src/encryption-service/encryption-service.test.ts @@ -13,6 +13,7 @@ describe('EncryptionService', () => { beforeEach(() => { // Reset the Singleton instance to ensure clean state for every test + // eslint-disable-next-line @typescript-eslint/no-explicit-any (EncryptionService as any).instance = undefined; service = new EncryptionService(TEST_KEY); }); @@ -82,6 +83,7 @@ describe('EncryptionService', () => { }); it('should return an empty string if input is malformed (Graceful Fail)', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const malformedData = 'invalid-base64-string-@@@'; diff --git a/packages/utils/src/encryption/encryption-service.ts b/packages/utils/src/encryption-service/encryption-service.ts similarity index 100% rename from packages/utils/src/encryption/encryption-service.ts rename to packages/utils/src/encryption-service/encryption-service.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e3f7b33..070d0a9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ -export * from './encryption/encryption-key'; -export * from './encryption/encryption-service'; +export * from './encryption-service/encryption-key'; +export * from './encryption-service/encryption-service'; export * from './date-service/date-service'; export * from './currency-service/currency-service'; +export * from './string-service/string-service'; diff --git a/packages/utils/src/string-service/string-service.test.ts b/packages/utils/src/string-service/string-service.test.ts new file mode 100644 index 0000000..ae228c1 --- /dev/null +++ b/packages/utils/src/string-service/string-service.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { StringService } from './string-service'; + +describe('StringService', () => { + // ---------------------------------------------------------------- + // Instantiation & Static Methods + // ---------------------------------------------------------------- + describe('Initialization', () => { + it('should handle string input correctly', () => { + const svc = new StringService('Hello'); + expect(svc.value()).toBe('Hello'); + }); + + it('should handle number input by converting to string', () => { + const svc = new StringService(123); + expect(svc.value()).toBe('123'); + }); + + it('should handle null input gracefully (default to empty string)', () => { + const svc = new StringService(null); + expect(svc.value()).toBe(''); + }); + + it('should handle undefined input gracefully', () => { + const svc = new StringService(undefined); + expect(svc.value()).toBe(''); + }); + + it('should support static factory method .of()', () => { + const svc = StringService.of('Factory'); + expect(svc.value()).toBe('Factory'); + }); + }); + + describe('random()', () => { + it('should generate string with specified length', () => { + const random = StringService.random(15); + expect(random.value()).toHaveLength(15); + }); + + it('should generate alphanumeric characters only', () => { + const random = StringService.random(100); + expect(random.value()).toMatch(/^[A-Za-z0-9]+$/); + }); + }); + + // ---------------------------------------------------------------- + // Core Manipulations + // ---------------------------------------------------------------- + describe('Transformations', () => { + it('should convert to upperCase', () => { + expect(StringService.of('hello').upperCase().value()).toBe('HELLO'); + }); + + it('should convert to lowerCase', () => { + expect(StringService.of('HELLO').lowerCase().value()).toBe('hello'); + }); + + it('should trim whitespace', () => { + expect(StringService.of(' hello ').trim().value()).toBe('hello'); + }); + + it('should support method chaining (Immutable)', () => { + const original = StringService.of(' hello '); + const modified = original.trim().upperCase(); + + expect(original.value()).toBe(' hello '); // Original untouched + expect(modified.value()).toBe('HELLO'); // New instance modified + }); + }); + + describe('Capitalization', () => { + it('should capitalize first letter only', () => { + expect(StringService.of('hELLO world').capitalizeFirst().value()).toBe('Hello world'); + }); + + it('should capitalize each word', () => { + expect(StringService.of('hello world').capitalizeEachWord().value()).toBe('Hello World'); + }); + + it('should handle double spaces in capitalizeEachWord', () => { + // Test regex logic for splitting words + expect(StringService.of('hello world').capitalizeEachWord().value()).toBe('Hello World'); + }); + }); + + // ---------------------------------------------------------------- + // Utilities (Slug, Limit, Mask) + // ---------------------------------------------------------------- + describe('slugify()', () => { + it('should create valid slugs', () => { + expect(StringService.of('Hello World!').slugify().value()).toBe('hello-world'); + }); + + it('should handle complex characters', () => { + expect(StringService.of('C# & .NET Core').slugify().value()).toBe('c-net-core'); + }); + + it('should remove leading/trailing separators', () => { + expect(StringService.of('---Hello---').slugify().value()).toBe('hello'); + }); + }); + + describe('limit()', () => { + it('should truncate string if longer than max', () => { + expect(StringService.of('Hello World').limit(5).value()).toBe('Hello...'); + }); + + it('should not truncate if shorter than max', () => { + expect(StringService.of('Hi').limit(5).value()).toBe('Hi'); + }); + + it('should support custom suffix', () => { + expect(StringService.of('Hello World').limit(5, '!!!').value()).toBe('Hello!!!'); + }); + }); + + describe('mask()', () => { + it('should mask middle characters', () => { + // 0812 3456 7890 (Length 12) + // Start 4: 0812 + // End 3: 890 + // Middle masked: ***** + expect(StringService.of('081234567890').mask(4, 3).value()).toBe('0812*****890'); + }); + + it('should handle custom mask char', () => { + expect(StringService.of('123456').mask(2, 2, 'X').value()).toBe('12XX56'); + }); + + it('should return original if string is shorter than visible parts', () => { + expect(StringService.of('123').mask(5, 5).value()).toBe('123'); + }); + }); + + // ---------------------------------------------------------------- + // Inspection & Outputs + // ---------------------------------------------------------------- + describe('Inspection & Outputs', () => { + it('should detect empty strings correctly', () => { + expect(StringService.of('').isEmpty()).toBe(true); + expect(StringService.of(' ').isEmpty()).toBe(true); // Trim check + expect(StringService.of(null).isEmpty()).toBe(true); + expect(StringService.of('a').isEmpty()).toBe(false); + }); + + it('should return default value with orElse', () => { + expect(StringService.of(null).orElse('Default')).toBe('Default'); + expect(StringService.of('Valid').orElse('Default')).toBe('Valid'); + }); + + it('should support native string interpolation (toString)', () => { + const name = StringService.of('World'); + expect(`Hello ${name}`).toBe('Hello World'); + }); + + it('should support JSON serialization (toJSON)', () => { + const data = { + id: 1, + name: StringService.of('Product A'), + }; + + // JSON.stringify automatically calls .toJSON() + const json = JSON.stringify(data); + expect(json).toBe('{"id":1,"name":"Product A"}'); + }); + }); +}); diff --git a/packages/utils/src/string-service/string-service.ts b/packages/utils/src/string-service/string-service.ts new file mode 100644 index 0000000..223fc3a --- /dev/null +++ b/packages/utils/src/string-service/string-service.ts @@ -0,0 +1,275 @@ +/** + * ------------------------------------------------------------ + * StringService + * ------------------------------------------------------------ + * Centralized string manipulation utility. + * + * Features: + * - 🛡️ Null-safe (handles null/undefined gracefully) + * - ⛓️ Fluent and immutable API (chainable methods) + * - 🛠️ Common formatting utilities (Capitalization, Truncation) + * - 🎲 Generator utilities (Random string, Slugs) + * + * @example + * // Basic Chaining + * StringService.of(" hello world ") + * .trim() + * .capitalizeEachWord() + * .value(); // "Hello World" + * ------------------------------------------------------------ + */ + +/* ------------------------------------------------------------------ + * Types + * ------------------------------------------------------------------ */ + +export type StringInput = string | number | null | undefined; + +/* ------------------------------------------------------------------ + * Interfaces + * ------------------------------------------------------------------ */ + +interface IStringService { + // --- Output & Conversion --- + value(): string; + toString(): string; + toJSON(): string; + + // --- Manipulations (Chainable) --- + upperCase(): IStringService; + lowerCase(): IStringService; + capitalizeFirst(): IStringService; + capitalizeEachWord(): IStringService; + limit(maxLength: number, suffix?: string): IStringService; + trim(): IStringService; + slugify(): IStringService; + mask(visibleStart: number, visibleEnd: number, maskChar?: string): IStringService; + + // --- Inspection & Fallback --- + isEmpty(): boolean; + orElse(defaultValue: string): string; +} + +/* ------------------------------------------------------------------ + * StringService + * ------------------------------------------------------------------ */ + +export class StringService implements IStringService { + private readonly _value: string; + + /* ---------------------------------------------------------------- + * Constructor & Static Initializers + * ---------------------------------------------------------------- */ + + constructor(value?: StringInput) { + // Defensive coding: Convert null/undefined/number to string immediately + if (value === null || value === undefined) { + this._value = ''; + } else { + this._value = String(value); + } + } + + /** + * Static factory method for cleaner chaining. + * + * @param value - The input string, number, or null/undefined. + * @example + * StringService.of("hello").upperCase().value() + */ + static of(value?: StringInput): StringService { + return new StringService(value); + } + + /** + * Generates a random alphanumeric string. + * Useful for IDs, tokens, or temporary passwords. + * + * @param length - Length of the generated string (default: 10). + * @example + * StringService.random(8).value() // "aB9x2Z1m" + */ + static random(length: number = 10): StringService { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return new StringService(result); + } + + /* ---------------------------------------------------------------- + * Core Manipulations + * ---------------------------------------------------------------- */ + + /** + * Converts string to UPPERCASE. + * + * @example + * StringService.of("hello").upperCase().value() // "HELLO" + */ + upperCase(): StringService { + return new StringService(this._value.toUpperCase()); + } + + /** + * Converts string to lowercase. + * + * @example + * StringService.of("HELLO").lowerCase().value() // "hello" + */ + lowerCase(): StringService { + return new StringService(this._value.toLowerCase()); + } + + /** + * Capitalizes only the first letter of the entire string. + * Remainder is forced to lowercase (Sentence case). + * + * @example + * StringService.of("HELLO world").capitalizeFirst().value() // "Hello world" + */ + capitalizeFirst(): StringService { + if (!this._value) return this; + const lower = this._value.toLowerCase(); + return new StringService(lower.charAt(0).toUpperCase() + lower.slice(1)); + } + + /** + * Capitalizes the first letter of every word (Title Case). + * Automatically handles multiple spaces. + * + * @example + * StringService.of("hello world").capitalizeEachWord().value() // "Hello World" + * StringService.of("hello world").capitalizeEachWord().value() // "Hello World" + */ + capitalizeEachWord(): StringService { + if (!this._value) return this; + const transformed = this._value + .toLowerCase() + .split(/\s+/) // Split by any whitespace regex to handle double spaces + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + return new StringService(transformed); + } + + /** + * Truncates string to a specific length and adds a suffix. + * + * @param maxLength - The character limit. + * @param suffix - The string to append if truncated (default: "..."). + * @example + * StringService.of("Lorem Ipsum").limit(5).value() // "Lorem..." + * StringService.of("Lorem Ipsum").limit(5, "").value() // "Lorem" + */ + limit(maxLength: number, suffix: string = '...'): StringService { + if (this._value.length <= maxLength) return this; + return new StringService(this._value.substring(0, maxLength) + suffix); + } + + /* ---------------------------------------------------------------- + * Utility Extensions + * ---------------------------------------------------------------- */ + + /** + * Removes whitespace from both ends of the string. + * + * @example + * StringService.of(" data ").trim().value() // "data" + */ + trim(): StringService { + return new StringService(this._value.trim()); + } + + /** + * Converts string to URL-friendly slug. + * Removes special characters and replaces spaces with dashes. + * + * @example + * StringService.of("Hello World!").slugify().value() // "hello-world" + * StringService.of("C# & .NET").slugify().value() // "c-net" + */ + slugify(): StringService { + const slug = this._value + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove non-word chars + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with - + .replace(/^-+|-+$/g, ''); // Remove leading/trailing - + return new StringService(slug); + } + + /** + * Masks part of the string. Useful for PII (emails/phones). + * + * @param visibleStart - Number of characters to show at the start. + * @param visibleEnd - Number of characters to show at the end. + * @param maskChar - The character to use for masking (default: "*"). + * @example + * StringService.of("08123456789").mask(4, 3).value() // "0812****789" + */ + mask(visibleStart: number = 0, visibleEnd: number = 0, maskChar: string = '*'): StringService { + if (this._value.length <= visibleStart + visibleEnd) return this; + + const start = this._value.slice(0, visibleStart); + const end = this._value.slice(-visibleEnd); + const middle = maskChar.repeat(this._value.length - visibleStart - visibleEnd); + + return new StringService(start + middle + end); + } + + /* ---------------------------------------------------------------- + * Inspection & Output + * ---------------------------------------------------------------- */ + + /** + * Checks if the string is empty or contains only whitespace. + * + * @returns true if string is empty or whitespace-only. + * @example + * StringService.of("").isEmpty() // true + * StringService.of(" ").isEmpty() // true + */ + isEmpty(): boolean { + return this._value.trim().length === 0; + } + + /** + * Returns the raw string value. + */ + value(): string { + return this._value; + } + + /** + * Returns default value if current string is empty. + * + * @param defaultValue - The fallback string. + * @example + * StringService.of(null).orElse("N/A") // "N/A" + */ + orElse(defaultValue: string): string { + return this.isEmpty() ? defaultValue : this._value; + } + + /** + * Allows default JS string interpolation to work. + * + * @example + * const name = StringService.of("John"); + * console.log(`Hello ${name}`); // "Hello John" + */ + toString(): string { + return this._value; + } + + /** + * Allows JSON.stringify to serialize just the string, not the object wrapper. + * + * @example + * JSON.stringify({ name: StringService.of("John") }) // '{"name":"John"}' + */ + toJSON(): string { + return this._value; + } +}