fix phone number bug

This commit is contained in:
Blake Kaufman
2026-06-21 09:01:10 -04:00
parent 40bec4a276
commit 7d94cbc6b3
2 changed files with 236 additions and 1 deletions
@@ -0,0 +1,230 @@
import getPhonePaymentAddress, {
getPhonePaymentCandidates,
isPhonePaymentNumber,
getPhonePaymentCountry,
canonicalizePhonePaymentAddress,
PROVIDER_COUNTRY_CURRENCY,
} from '../../../app/functions/sendBitcoin/getPhonePaymentAddress';
import getLNURLDetails from '../../../app/functions/lnurl/getLNURLDetails';
// getPhonePaymentAddress() probes the LNURL endpoint to disambiguate numbers
// that are valid in more than one supported country. Mock it so the suite is
// deterministic and never hits the network.
jest.mock('../../../app/functions/lnurl/getLNURLDetails', () => jest.fn());
const KE = '254717252303@bitcoin.co.ke';
const KE_OVERLAP = '254751234567@bitcoin.co.ke';
const ZM_OVERLAP = '0751234567@bitzed.xyz';
beforeEach(() => {
getLNURLDetails.mockReset();
});
describe('getPhonePaymentCandidates', () => {
it('maps a Kenyan national mobile number to only the Kenyan provider', () => {
// Regression: a leading-07 Kenyan mobile must not also validate as a
// Zambian/Philippine number under loose metadata.
expect(getPhonePaymentCandidates('0717252303')).toEqual([KE]);
});
it('maps the international (+254) form to the Kenyan provider', () => {
expect(getPhonePaymentCandidates('+254717252303')).toEqual([KE]);
});
it('maps the bare-country-code (254) form to the Kenyan provider', () => {
expect(getPhonePaymentCandidates('254717252303')).toEqual([KE]);
});
it('maps a Zambian national mobile number to only the Zambian provider', () => {
expect(getPhonePaymentCandidates('0977123456')).toEqual([
'0977123456@bitzed.xyz',
]);
});
it('maps a Philippine national mobile number to only the GCash provider', () => {
expect(getPhonePaymentCandidates('09171234567')).toEqual([
'639171234567@zapremit.com',
]);
});
it('returns both KE and ZM (KE first) for an overlapping 075 number', () => {
expect(getPhonePaymentCandidates('0751234567')).toEqual([
KE_OVERLAP,
ZM_OVERLAP,
]);
});
it('returns both KE and ZM (KE first) for an overlapping 077 number', () => {
expect(getPhonePaymentCandidates('0771234567')).toEqual([
'254771234567@bitcoin.co.ke',
'0771234567@bitzed.xyz',
]);
});
it('trims surrounding whitespace before parsing', () => {
expect(getPhonePaymentCandidates(' 0717252303 ')).toEqual([KE]);
});
it('returns [] for an empty string', () => {
expect(getPhonePaymentCandidates('')).toEqual([]);
});
it('returns [] for whitespace only', () => {
expect(getPhonePaymentCandidates(' ')).toEqual([]);
});
it('returns [] for null/undefined', () => {
expect(getPhonePaymentCandidates(null)).toEqual([]);
expect(getPhonePaymentCandidates(undefined)).toEqual([]);
});
it('returns [] for non-numeric text', () => {
expect(getPhonePaymentCandidates('hello world')).toEqual([]);
});
it('returns [] for a valid number from an unsupported country', () => {
// Valid US number, but the US is not a phone-payment provider.
expect(getPhonePaymentCandidates('+12025550123')).toEqual([]);
});
});
describe('isPhonePaymentNumber', () => {
it('is true for KE/ZM/PH numbers', () => {
expect(isPhonePaymentNumber('0717252303')).toBe(true);
expect(isPhonePaymentNumber('0977123456')).toBe(true);
expect(isPhonePaymentNumber('09171234567')).toBe(true);
});
it('is false for empty, garbage and unsupported numbers', () => {
expect(isPhonePaymentNumber('')).toBe(false);
expect(isPhonePaymentNumber('hello')).toBe(false);
expect(isPhonePaymentNumber('+12025550123')).toBe(false);
});
});
describe('getPhonePaymentCountry', () => {
it('maps each provider domain to its country', () => {
expect(getPhonePaymentCountry('254717252303@bitcoin.co.ke')).toBe('KE');
expect(getPhonePaymentCountry('0977123456@bitzed.xyz')).toBe('ZM');
expect(getPhonePaymentCountry('639171234567@zapremit.com')).toBe('PH');
});
it('matches the domain case-insensitively', () => {
expect(getPhonePaymentCountry('254717252303@BITCOIN.CO.KE')).toBe('KE');
});
it('returns null for a non-provider domain', () => {
expect(getPhonePaymentCountry('user@example.com')).toBe(null);
});
it('returns null for an address without an @', () => {
expect(getPhonePaymentCountry('nodomain')).toBe(null);
});
it('returns null for non-string input', () => {
expect(getPhonePaymentCountry(null)).toBe(null);
expect(getPhonePaymentCountry(12345)).toBe(null);
});
});
describe('getPhonePaymentCountry of candidates (gcash labelling regression)', () => {
it('does NOT classify a Kenyan number as a Philippine (GCash) number', () => {
// This mirrors the halfModalSendOptions `isGcashNumber` derivation.
const isGcash = getPhonePaymentCandidates('0717252303').some(
c => getPhonePaymentCountry(c) === 'PH',
);
expect(isGcash).toBe(false);
});
it('classifies a real Philippine number as a GCash number', () => {
const isGcash = getPhonePaymentCandidates('09171234567').some(
c => getPhonePaymentCountry(c) === 'PH',
);
expect(isGcash).toBe(true);
});
});
describe('canonicalizePhonePaymentAddress', () => {
it('canonicalizes a national Kenyan address to international form', () => {
expect(canonicalizePhonePaymentAddress('0717252303@bitcoin.co.ke')).toBe(
KE,
);
});
it('is idempotent on an already-canonical address', () => {
expect(canonicalizePhonePaymentAddress(KE)).toBe(KE);
});
it('keeps a Zambian address in its national provider form', () => {
expect(canonicalizePhonePaymentAddress('0977123456@bitzed.xyz')).toBe(
'0977123456@bitzed.xyz',
);
});
it('selects the candidate matching the address domain for overlapping numbers', () => {
// 0751234567 is valid for both KE and ZM; the bitzed.xyz domain must pin it
// to the Zambian candidate rather than the first (Kenyan) candidate.
expect(canonicalizePhonePaymentAddress(ZM_OVERLAP)).toBe(ZM_OVERLAP);
});
it('matches the provider domain case-insensitively', () => {
expect(canonicalizePhonePaymentAddress('0717252303@BITCOIN.CO.KE')).toBe(
KE,
);
});
it('returns null for a non-provider lightning address', () => {
expect(canonicalizePhonePaymentAddress('user@example.com')).toBe(null);
});
it('returns null for an address without an @', () => {
expect(canonicalizePhonePaymentAddress('someblitzname')).toBe(null);
});
it('returns null for non-string input', () => {
expect(canonicalizePhonePaymentAddress(null)).toBe(null);
});
});
describe('getPhonePaymentAddress (default, async resolution)', () => {
it('returns the only candidate without probing LNURL', async () => {
const result = await getPhonePaymentAddress('0717252303');
expect(result).toBe(KE);
expect(getLNURLDetails).not.toHaveBeenCalled();
});
it('returns null when there are no candidates', async () => {
const result = await getPhonePaymentAddress('hello');
expect(result).toBe(null);
expect(getLNURLDetails).not.toHaveBeenCalled();
});
it('returns the first candidate whose LNURL resolves to a payRequest', async () => {
getLNURLDetails.mockResolvedValueOnce({ tag: 'payRequest' });
const result = await getPhonePaymentAddress('0751234567');
expect(result).toBe(KE_OVERLAP);
expect(getLNURLDetails).toHaveBeenCalledWith(KE_OVERLAP);
});
it('falls back to the last candidate when an earlier probe fails', async () => {
getLNURLDetails.mockResolvedValueOnce(false);
const result = await getPhonePaymentAddress('0751234567');
expect(result).toBe(ZM_OVERLAP);
});
it('falls back to the last candidate when a probe is not a payRequest', async () => {
getLNURLDetails.mockResolvedValueOnce({ tag: 'withdrawRequest' });
const result = await getPhonePaymentAddress('0751234567');
expect(result).toBe(ZM_OVERLAP);
});
});
describe('PROVIDER_COUNTRY_CURRENCY', () => {
it('maps each provider country to its local fiat currency', () => {
expect(PROVIDER_COUNTRY_CURRENCY).toEqual({
KE: 'KES',
ZM: 'ZMW',
PH: 'PHP',
});
});
});
@@ -1,4 +1,9 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js';
// Use the mobile-aware metadata bundle: the default ("min") metadata is loose
// enough that a Kenyan mobile (07…) also validates as a Zambian/Philippine
// number, which produced spurious extra candidates (e.g. a KE number being
// labelled a GCash/PH number). The /mobile metadata's isValid() only accepts
// real mobile numbers, which these providers serve.
import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
import getLNURLDetails from '../lnurl/getLNURLDetails';
// country -> bitcoin payment provider; formatNumber emits the provider's