mattermost-mobile-upstream/app/utils/url/links.test.ts
Joram Wilander 6ec7bef334
MM-70688 Autolink phone numbers in posts as tel: links (#10143)
* Add phone number matching helpers for tel: autolinks.

Detect E.164, grouped national numbers, and tel: URIs while skipping
bare digit runs that look like ticket IDs or dates.

Co-authored-by: joram <joram@mattermost.com>

* Autolink phone numbers in post markdown as tel: links.

Wrap recognized numbers in markdown link nodes after parsing so tapping
opens the device dialer. Skip code, existing links, and unsafe posts.

Co-authored-by: joram <joram@mattermost.com>

* Copy phone numbers from tel: markdown links on long-press.

Treat tel: like mailto: in link and channel-header sheets, and cover
opening tel: URLs through the existing openLink path.

Co-authored-by: joram <joram@mattermost.com>

* Reject truncated phone matches inside longer digit sequences.

Skip past hyphens and dots when checking match boundaries so overlong
numbers and numeric prefixes are not autolinked. Keep spaces and
parentheses as boundaries so adjacent numbers still match.

Also address CodeRabbit test nits: assert match counts, prefix
autolinkPhoneNumbers cases with should, and assert rendered tel: links.

Co-authored-by: joram <joram@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 14:32:17 -04:00

124 lines
5.2 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createIntl} from 'react-intl';
import {Alert} from 'react-native';
import * as DeepLinkUtils from '@utils/deep_link';
import * as Links from './links';
import * as UrlUtils from './index';
describe('onOpenLinkError', () => {
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should display an alert with the correct title and message', () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
// using jest.Mock as formatMessage has an implementation overload
// and we want to mock the implementation that returns a string
(intl.formatMessage as jest.Mock).
mockReturnValueOnce('Error').
mockReturnValueOnce('Unable to open the link.'); // Mock message
Links.onOpenLinkError(intl);
expect(intl.formatMessage).toHaveBeenCalledTimes(2);
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
});
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
});
expect(alertSpy).toHaveBeenCalledWith('Error', 'Unable to open the link.');
});
});
describe('openLink', () => {
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should call normalizeProtocol and return early if the URL is invalid', async () => {
const normalizeProtocolMock = jest.spyOn(UrlUtils, 'normalizeProtocol').mockReturnValue('');
await Links.openLink('', 'https://server-url.com', 'https://site-url.com', intl);
expect(UrlUtils.normalizeProtocol).toHaveBeenCalledWith('');
normalizeProtocolMock.mockRestore();
});
it('should handle deep link if matchDeepLink returns a match', async () => {
const normalizeProtocolMock = jest.spyOn(UrlUtils, 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue({url: 'https://example.com'});
const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: null});
await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(normalizeProtocolMock).toHaveBeenCalledWith('https://example.com');
expect(matchDeepLinkMock).toHaveBeenCalledWith('https://example.com', 'https://server-url.com', 'https://site-url.com');
expect(handleDeepLinkMock).toHaveBeenCalledWith({url: 'https://example.com'}, intl);
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
handleDeepLinkMock.mockRestore();
});
it('should call tryOpenURL with onOpenLinkError if handleDeepLink returns an error', async () => {
const normalizeProtocolMock = jest.spyOn(UrlUtils, 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(DeepLinkUtils, 'matchDeepLink').mockReturnValue({url: 'https://example.com', type: '_redirect'});
const handleDeepLinkMock = jest.spyOn(DeepLinkUtils, 'handleDeepLink').mockResolvedValue({error: true});
const tryOpenURLMock = jest.spyOn(UrlUtils, 'tryOpenURL').mockImplementation(() => {});
await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', expect.any(Function));
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
handleDeepLinkMock.mockRestore();
tryOpenURLMock.mockRestore();
});
it('should call tryOpenURL with onOpenLinkError if matchDeepLink does not return a match', async () => {
const normalizeProtocolMock = jest.spyOn(UrlUtils, 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(DeepLinkUtils, 'matchDeepLink').mockReturnValue(null);
const tryOpenURLMock = jest.spyOn(UrlUtils, 'tryOpenURL').mockImplementation(() => {});
await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', expect.any(Function));
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
tryOpenURLMock.mockRestore();
});
it('should open tel: links with tryOpenURL', async () => {
const tryOpenURLMock = jest.spyOn(UrlUtils, 'tryOpenURL').mockImplementation(() => {});
await Links.openLink('tel:+15551234567', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('tel:+15551234567', expect.any(Function));
tryOpenURLMock.mockRestore();
});
});