shared: phone number normalize, format and reserved check

Task: M0-T2
This commit is contained in:
iamdoubz committed 2026-10-17 14:19:34 -05:00
1 parent aa99622a24
commit 0247137765
3 files changed
+104

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const APP_NAME = 'SelfPhoneNG';
export * from './phone.ts';
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { formatNumber, isReservedNumber, normalizeNumber } from './phone.ts';
describe('normalizeNumber', () => {
it.each([
['+1 (312) 555-1234', '13125551234'],
['+13125551234', '13125551234'],
['3125551234', '13125551234'],
['13125551234', '13125551234'],
['(312) 555-1234', '13125551234'],
['312.555.1234', '13125551234'],
['312-555-1234', '13125551234'],
[' 312 555 1234 ', '13125551234'],
['8005551234', '18005551234'],
['+44 20 7946 0958', '011442079460958'],
['+33 1 23 45 67 89', '01133123456789'],
['011442079460958', '011442079460958'],
['*97', '*97'],
['*72101', '*72101'],
['**101', '**101'],
['#', '#'],
['911', '911'],
['101', '101'],
['0', '0'],
['5551234', '5551234'],
['1234567890', '1234567890'], // 10 digits starting with 1 is not NANP: left as-is
])('%j → %s', (input, expected) => {
expect(normalizeNumber(input)).toBe(expected);
});
it.each([
'',
' ',
'abc',
'312-555-CALL',
'+',
'+1',
'+1312555123',
'+131255512345',
'+44abc',
'12+34',
'101); System(rm -rf /',
'१२३',
])('rejects %j', (input) => {
expect(() => normalizeNumber(input)).toThrow();
});
});
describe('formatNumber', () => {
it.each([
['13125551234', '(312) 555-1234'],
['18005551234', '(800) 555-1234'],
['101', '101'],
['011442079460958', '011442079460958'],
['*97', '*97'],
])('%s → %s', (input, expected) => {
expect(formatNumber(input)).toBe(expected);
});
});
describe('isReservedNumber', () => {
it.each(['211', '311', '411', '511', '611', '711', '811', '911', '933', '0123', '1000', '10000'])(
'%s is reserved',
(n) => {
expect(isReservedNumber(n)).toBe(true);
},
);
it.each(['100', '101', '499', '500', '700', '912', '011', '2000'])('%s is free', (n) => {
expect(isReservedNumber(n)).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The only place that normalizes phone numbers (spec 00 §8).
const RESERVED = new Set(['211', '311', '411', '511', '611', '711', '811', '911', '933']);
export function normalizeNumber(input: string, defaultCountry = '1'): string {
const n = input.replace(/[\s\-.()]/g, '');
if (n.startsWith('+')) {
const rest = n.slice(1);
if (!/^\d+$/.test(rest)) throw new Error(`Invalid phone number: ${input}`);
if (defaultCountry === '1' && rest.startsWith('1')) {
// TODO(spec): 00 §8 only defines +1 followed by 10 digits; other lengths are rejected.
if (rest.length !== 11) throw new Error(`Invalid phone number: ${input}`);
return rest;
}
return `011${rest}`;
}
if (/^[2-9]\d{9}$/.test(n)) return defaultCountry + n;
if (/^[0-9*#]+$/.test(n)) return n;
throw new Error(`Invalid phone number: ${input}`);
}
export function formatNumber(n: string): string {
const m = /^1(\d{3})(\d{3})(\d{4})$/.exec(n);
return m ? `(${m[1]}) ${m[2]}-${m[3]}` : n;
}
/** N11 service codes, 933, and anything starting with 0 or 1 followed by more than 2 digits. */
export function isReservedNumber(n: string): boolean {
return RESERVED.has(n) || /^[01]\d{3,}$/.test(n);
}