From 7f37985d22d303cb8f0d28b02ce92c1833785a64 Mon Sep 17 00:00:00 2001 From: shancheas Date: Thu, 20 Aug 2026 18:31:54 +0700 Subject: [PATCH] Add PhoneNumber value object with validation and unit tests - Implemented PhoneNumber class for creating and validating E.164 formatted phone numbers. - Added InvalidPhoneNumberError for handling invalid phone number inputs. - Created comprehensive unit tests covering various scenarios for phone number creation, validation, and equality checks. - Ensured immutability and proper serialization of the PhoneNumber object. --- .../invalid-phone-number.error.ts | 6 + .../phone-number/phone-number.spec.ts | 145 ++++++++++++++++++ .../phone-number/phone-number.ts | 54 +++++++ 3 files changed, 205 insertions(+) create mode 100644 src/common/value-objects/phone-number/invalid-phone-number.error.ts create mode 100644 src/common/value-objects/phone-number/phone-number.spec.ts create mode 100644 src/common/value-objects/phone-number/phone-number.ts diff --git a/src/common/value-objects/phone-number/invalid-phone-number.error.ts b/src/common/value-objects/phone-number/invalid-phone-number.error.ts new file mode 100644 index 0000000..bb61dc8 --- /dev/null +++ b/src/common/value-objects/phone-number/invalid-phone-number.error.ts @@ -0,0 +1,6 @@ +export class InvalidPhoneNumberError extends Error { + constructor() { + super('Invalid phone number'); + this.name = 'InvalidPhoneNumberError'; + } +} diff --git a/src/common/value-objects/phone-number/phone-number.spec.ts b/src/common/value-objects/phone-number/phone-number.spec.ts new file mode 100644 index 0000000..bab080b --- /dev/null +++ b/src/common/value-objects/phone-number/phone-number.spec.ts @@ -0,0 +1,145 @@ +import { InvalidPhoneNumberError } from './invalid-phone-number.error'; +import { PhoneNumber } from './phone-number'; + +describe('PhoneNumber', () => { + describe('create', () => { + it('creates a valid E.164 number and stores the canonical form', () => { + const phone = PhoneNumber.create('+6281234567890'); + + expect(phone.value).toBe('+6281234567890'); + }); + + it('trims surrounding whitespace', () => { + const phone = PhoneNumber.create(' +6281234567890 '); + + expect(phone.value).toBe('+6281234567890'); + }); + + it('normalizes formatted international numbers to E.164', () => { + const phone = PhoneNumber.create('+62 812 3456 7890'); + + expect(phone.value).toBe('+6281234567890'); + }); + + it('rejects numbers missing a leading +', () => { + expect(() => PhoneNumber.create('6281234567890')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects Indonesian national format', () => { + expect(() => PhoneNumber.create('081234567890')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects international prefix without +', () => { + expect(() => PhoneNumber.create('006281234567890')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects empty string', () => { + expect(() => PhoneNumber.create('')).toThrow(InvalidPhoneNumberError); + }); + + it('rejects whitespace-only input', () => { + expect(() => PhoneNumber.create(' ')).toThrow(InvalidPhoneNumberError); + }); + + it('rejects non-string input', () => { + expect(() => PhoneNumber.create(null as unknown as string)).toThrow( + InvalidPhoneNumberError, + ); + expect(() => PhoneNumber.create(123 as unknown as string)).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects non-digit junk', () => { + expect(() => PhoneNumber.create('+not-a-phone')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects invalid E.164-looking values', () => { + expect(() => PhoneNumber.create('+999')).toThrow(InvalidPhoneNumberError); + }); + + it('rejects trailing junk after a valid number', () => { + expect(() => PhoneNumber.create('+6281234567890abc')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('rejects numbers with extensions', () => { + expect(() => PhoneNumber.create('+6281234567890 ext. 123')).toThrow( + InvalidPhoneNumberError, + ); + expect(() => PhoneNumber.create('+62 812-3456-7890 x99')).toThrow( + InvalidPhoneNumberError, + ); + }); + + it('throws a generic message that does not echo the input', () => { + expect(() => PhoneNumber.create('081234567890')).toThrow( + 'Invalid phone number', + ); + }); + }); + + describe('equals', () => { + it('returns true for the same canonical value', () => { + const a = PhoneNumber.create('+6281234567890'); + const b = PhoneNumber.create('+62 812 3456 7890'); + + expect(a.equals(b)).toBe(true); + }); + + it('returns false for different numbers', () => { + const a = PhoneNumber.create('+6281234567890'); + const b = PhoneNumber.create('+14155552671'); + + expect(a.equals(b)).toBe(false); + }); + + it('returns false for null or undefined', () => { + const phone = PhoneNumber.create('+6281234567890'); + + expect(phone.equals(null as unknown as PhoneNumber)).toBe(false); + expect(phone.equals(undefined as unknown as PhoneNumber)).toBe(false); + }); + }); + + describe('serialization', () => { + it('toString returns E.164', () => { + const phone = PhoneNumber.create('+6281234567890'); + + expect(phone.toString()).toBe('+6281234567890'); + }); + + it('toJSON returns E.164', () => { + const phone = PhoneNumber.create('+6281234567890'); + + expect(phone.toJSON()).toBe('+6281234567890'); + expect(JSON.stringify({ phone })).toBe('{"phone":"+6281234567890"}'); + }); + }); + + describe('immutability', () => { + it('is frozen after creation', () => { + const phone = PhoneNumber.create('+6281234567890'); + + expect(Object.isFrozen(phone)).toBe(true); + }); + + it('cannot be constructed with new from outside', () => { + expect( + () => + new (PhoneNumber as unknown as new (value: string) => PhoneNumber)( + '+6281234567890', + ), + ).toThrow(); + }); + }); +}); diff --git a/src/common/value-objects/phone-number/phone-number.ts b/src/common/value-objects/phone-number/phone-number.ts new file mode 100644 index 0000000..7fbb12a --- /dev/null +++ b/src/common/value-objects/phone-number/phone-number.ts @@ -0,0 +1,54 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js'; +import { InvalidPhoneNumberError } from './invalid-phone-number.error'; + +const E164_COMPACT = /^\+[1-9]\d{1,14}$/; + +export class PhoneNumber { + private static readonly createToken = Symbol('PhoneNumber.create'); + + private constructor( + private readonly e164: string, + token: symbol, + ) { + if (token !== PhoneNumber.createToken) { + throw new TypeError( + 'PhoneNumber can only be created via PhoneNumber.create()', + ); + } + Object.freeze(this); + } + + static create(raw: string): PhoneNumber { + if (typeof raw !== 'string') { + throw new InvalidPhoneNumberError(); + } + + const compact = raw.trim().replace(/[\s().-]/g, ''); + if (!E164_COMPACT.test(compact)) { + throw new InvalidPhoneNumberError(); + } + + const parsed = parsePhoneNumberFromString(compact); + if (!parsed?.isValid() || parsed.ext) { + throw new InvalidPhoneNumberError(); + } + + return new PhoneNumber(parsed.number, PhoneNumber.createToken); + } + + get value(): string { + return this.e164; + } + + equals(other: PhoneNumber): boolean { + return other instanceof PhoneNumber && this.e164 === other.e164; + } + + toString(): string { + return this.e164; + } + + toJSON(): string { + return this.e164; + } +}