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.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
export class InvalidPhoneNumberError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid phone number');
|
||||||
|
this.name = 'InvalidPhoneNumberError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user