Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions __tests__/unit/app/components/mobileapplink/MobileAppLink.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/* global jest */
/* global expect */
/* global describe */
/* global it */
/* global beforeEach */
/* global afterEach */

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import MobileAppLink, {
ANDROID_APP_URL,
APP_STORE_URL,
IOS_APP_URL,
IOS_UNIVERSAL_LINK_URL,
PLAY_STORE_URL,
getIosAppUrl,
} from '@app/components/mobileapplink/MobileAppLink';
import utils from '@app/core/utils';

const OPEN_APP_LABEL = 'Open the Tidepool Mobile app';
const STORE_BADGE_LABEL = 'Download Tidepool Mobile';

describe('MobileAppLink', () => {
let getMobilePlatform;
let trackMetric;

beforeEach(() => {
getMobilePlatform = jest.spyOn(utils, 'getMobilePlatform');
trackMetric = jest.fn();
// The iosLink override persists to localStorage, so tests must not leak state into each other
window.localStorage.clear();
});

afterEach(() => {
getMobilePlatform.mockRestore();
});

describe('on a desktop user agent', () => {
it('should render nothing, since the link cannot work there', () => {
getMobilePlatform.mockReturnValue(null);
const { container } = render(<MobileAppLink trackMetric={trackMetric} />);

expect(container).toBeEmptyDOMElement();
});
});

describe('on iOS', () => {
beforeEach(() => {
getMobilePlatform.mockReturnValue('ios');
});

it('should link to the app with the plain custom scheme', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: OPEN_APP_LABEL }))
.toHaveAttribute('href', IOS_APP_URL);
expect(IOS_APP_URL).toBe('org.tidepool.mobile://signup-complete');
});

it('should fall back to the App Store listing', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: STORE_BADGE_LABEL }))
.toHaveAttribute('href', APP_STORE_URL);
});

it('should track a metric when the app link is clicked', async () => {
render(<MobileAppLink trackMetric={trackMetric} />);
await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL }));

expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'ios' });
});
});

describe('getIosAppUrl', () => {
it('should default to the custom scheme', () => {
expect(getIosAppUrl('')).toBe(IOS_APP_URL);
});

it('should return the universal link when overridden via the query string', () => {
expect(getIosAppUrl('?iosLink=universal')).toBe(IOS_UNIVERSAL_LINK_URL);
});

it('should return the custom scheme when explicitly overridden', () => {
expect(getIosAppUrl('?iosLink=scheme')).toBe(IOS_APP_URL);
});

it('should ignore an unrecognised override', () => {
expect(getIosAppUrl('?iosLink=nonsense')).toBe(IOS_APP_URL);
});

it('should point at the current origin when testing the same-host case', () => {
expect(getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa1.development.tidepool.org'))
.toBe('https://qa1.development.tidepool.org/mobile-app');
});

it('should ignore linkHost when the scheme strategy is selected', () => {
expect(getIosAppUrl('?iosLink=scheme&linkHost=same', 'https://qa1.development.tidepool.org'))
.toBe(IOS_APP_URL);
});

// The value renders into an href, so an attacker-supplied host would repoint the button off-site.
it('should not honour an arbitrary host supplied in the query string', () => {
expect(getIosAppUrl('?iosLink=universal&linkHost=evil.example.com', 'https://qa1.development.tidepool.org'))
.toBe(IOS_UNIVERSAL_LINK_URL);
});

// Safari opens same-host universal links in the browser rather than the app, so pointing this
// at the host serving the page would silently defeat the whole mechanism.
it('should point the universal link at a dedicated host', () => {
expect(IOS_UNIVERSAL_LINK_URL).toMatch(/^https:\/\/[^/]+\/mobile-app$/);
});

// Editing query params on a phone keyboard is painful, so an override sticks across visits.
describe('override persistence', () => {
it('should keep applying an override on later visits without the query param', () => {
getIosAppUrl('?iosLink=universal');

expect(getIosAppUrl('')).toBe(IOS_UNIVERSAL_LINK_URL);
});

it('should persist the same-host variant, including the host choice', () => {
getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org');

expect(getIosAppUrl('', 'https://qa3.development.tidepool.org'))
.toBe('https://qa3.development.tidepool.org/mobile-app');
});

it('should drop a persisted linkHost when a later override omits it', () => {
getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org');
getIosAppUrl('?iosLink=universal', 'https://qa3.development.tidepool.org');

expect(getIosAppUrl('', 'https://qa3.development.tidepool.org')).toBe(IOS_UNIVERSAL_LINK_URL);
});

it('should return to the default after ?iosLink=reset', () => {
getIosAppUrl('?iosLink=universal');
getIosAppUrl('?iosLink=reset');

expect(getIosAppUrl('')).toBe(IOS_APP_URL);
});

it('should not persist an unrecognised override', () => {
getIosAppUrl('?iosLink=nonsense');

expect(window.localStorage.getItem('mobileAppLink.iosLink')).toBeNull();
});
});
});

describe('override indicator', () => {
beforeEach(() => {
getMobilePlatform.mockReturnValue('ios');
});

it('should not render while no override is active', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.queryByText(/Link override active/)).not.toBeInTheDocument();
});

it('should name the active override and how to clear it', () => {
getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org');
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByText('Link override active: universal (same host) — ?iosLink=reset clears it'))
.toBeInTheDocument();
});
});

describe('on Android', () => {
beforeEach(() => {
getMobilePlatform.mockReturnValue('android');
});

it('should link to the app with the intent:// scheme, so a missing app falls through to the Play Store', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: OPEN_APP_LABEL }))
.toHaveAttribute('href', ANDROID_APP_URL);
expect(ANDROID_APP_URL).toBe('intent://signup-complete#Intent;scheme=org.tidepool.mobile;package=io.tidepool.urchin;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dio.tidepool.urchin;end');
});

it('should fall back to the Play Store listing', () => {
render(<MobileAppLink trackMetric={trackMetric} />);

expect(screen.getByRole('link', { name: STORE_BADGE_LABEL }))
.toHaveAttribute('href', PLAY_STORE_URL);
});

// The visible target is a Button nested inside the anchor, so a real tap lands on the button
// and relies on the click bubbling up to trigger the link.
it('should trigger the app link when the button itself is tapped', async () => {
render(<MobileAppLink trackMetric={trackMetric} />);
await userEvent.click(screen.getByRole('button', { name: OPEN_APP_LABEL }));

expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'android' });
});

it('should track a metric when the app link is clicked', async () => {
render(<MobileAppLink trackMetric={trackMetric} />);
await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL }));

expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'android' });
});
});
});
61 changes: 61 additions & 0 deletions __tests__/unit/app/pages/mobileapp/MobileApp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* global jest */
/* global expect */
/* global describe */
/* global it */
/* global beforeEach */
/* global afterEach */

import React from 'react';
import { render, screen } from '@testing-library/react';

import MobileApp from '@app/pages/mobileapp/MobileApp';
import { APP_STORE_URL, PLAY_STORE_URL } from '@app/components/mobileapplink/MobileAppLink';
import utils from '@app/core/utils';

const BADGE_LABEL = 'Download Tidepool Mobile';

describe('MobileApp', () => {
let getMobilePlatform;

beforeEach(() => {
getMobilePlatform = jest.spyOn(utils, 'getMobilePlatform');
});

afterEach(() => {
getMobilePlatform.mockRestore();
});

it('should show only the App Store badge on iOS', () => {
getMobilePlatform.mockReturnValue('ios');
render(<MobileApp />);

const badges = screen.getAllByRole('link', { name: BADGE_LABEL });
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveAttribute('href', APP_STORE_URL);
});

it('should show only the Play Store badge on Android', () => {
getMobilePlatform.mockReturnValue('android');
render(<MobileApp />);

const badges = screen.getAllByRole('link', { name: BADGE_LABEL });
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveAttribute('href', PLAY_STORE_URL);
});

// Unlike the Welcome page button, this page must still render on desktop — it is a real
// destination that a universal link resolves to when the app isn't installed.
it('should show both badges on desktop rather than rendering nothing', () => {
getMobilePlatform.mockReturnValue(null);
render(<MobileApp />);

expect(screen.getAllByRole('link', { name: BADGE_LABEL })).toHaveLength(2);
});

it('should offer a manual scheme link on iOS as a retry path', () => {
getMobilePlatform.mockReturnValue('ios');
render(<MobileApp />);

expect(screen.getByText('Open the Tidepool Mobile app')).toBeInTheDocument();
});
});
Loading