Skip to content

Commit

Permalink
chore(in-app-messaging): add unit testing for component util hooks (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
calebpollman authored Dec 1, 2021
1 parent 591c40a commit 0a86073
Show file tree
Hide file tree
Showing 22 changed files with 1,579 additions and 105 deletions.
10 changes: 10 additions & 0 deletions packages/aws-amplify-react-native/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ module.exports = {
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint', 'react-hooks', 'jest', 'prettier'],
overrides: [{
files: ['**/*.spec.*', '**/*.test.*'],
plugins: ['jest'],
rules: {
// turn the original rule off for test files
'@typescript-eslint/unbound-method': 'off',
'jest/unbound-method': 'error',
},
}],
rules: {
'@typescript-eslint/member-ordering': 'error',
'@typescript-eslint/no-extra-semi': 'error',
Expand All @@ -31,6 +40,7 @@ module.exports = {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '_', varsIgnorePattern: '_' }],
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/restrict-template-expressions': ['off'],
'@typescript-eslint/unbound-method': 'error',
'comma-dangle': ['error', 'only-multiline'],
'function-paren-newline': 'off',
'generator-star-spacing': 'off',
Expand Down
9 changes: 9 additions & 0 deletions packages/aws-amplify-react-native/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
module.exports = {
preset: 'react-native',
modulePathIgnorePatterns: ['<rootDir>/dist/'],
collectCoverageFrom: ['<rootDir>/src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/src/**/*{c,C}onstants.ts'],
coverageThreshold: {
global: {
branches: 16,
functions: 9,
lines: 16,
statements: 16,
},
},
};
4 changes: 3 additions & 1 deletion packages/aws-amplify-react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"description": "AWS Amplify is a JavaScript library for Frontend and mobile developers building cloud-enabled applications.",
"main": "dist/index.js",
"scripts": {
"test": "npm run lint && jest -w 1 --passWithNoTests --coverage --maxWorkers 2 --config jest.config.js",
"test": "npm run lint && jest -w 1 --coverage --maxWorkers 2 --config jest.config.js",
"test:verbose": "npm run test -- --verbose",
"build": "npm run lint && npm run clean && babel src --out-dir dist --extensions '.ts,.tsx,.js,.jsx' --copy-files",
"watch": "npm run build -- --watch",
"build:cjs:watch": "npm run watch",
Expand All @@ -20,6 +21,7 @@
"@babel/core": "7.15.5",
"@babel/preset-env": "7.15.6",
"@react-native-picker/picker": "^1.2",
"@testing-library/react-hooks": "^7.0.2",
"@types/react": "^16.13.1",
"@types/react-native": "^0.63.45",
"@types/react-test-renderer": "^17.0.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
* the License. A copy of the License is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/

import { Linking } from 'react-native';
import { ConsoleLogger as Logger } from '@aws-amplify/core';
import { InAppMessageAction } from '@aws-amplify/notifications';

import handleAction from '../handleAction';

jest.mock('react-native', () => ({
Linking: {
canOpenURL: jest.fn(),
openURL: jest.fn(),
},
}));

const logger = new Logger('TEST_LOGGER');

const deepLink = 'DEEP_LINK';
const link = 'LINK';
const url = 'https://docs.amplify.aws/';

const error = 'ERROR';

describe('handleAction', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each([deepLink, link])('handles a %s action as expected in the happy path', async (action) => {
(Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(true);

await handleAction(action as InAppMessageAction, url);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`);
expect(Linking.canOpenURL).toHaveBeenCalledWith(url);
expect(logger.info).toHaveBeenCalledWith(`Opening url: ${url}`);
expect(Linking.openURL).toHaveBeenCalledWith(url);
expect(logger.info).toHaveBeenCalledTimes(2);
});

it.each([deepLink, link])(
'logs a warning and early returns when a %s action is provided with a null url value',
async (action) => {
const invalidUrl = null;

await handleAction(action as InAppMessageAction, invalidUrl);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`);
expect(logger.warn).toHaveBeenCalledWith(`url must be of type string: ${invalidUrl}`);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(Linking.canOpenURL).not.toHaveBeenCalled();
}
);

it.each([deepLink, link])(
'logs a warning and early returns when a %s action is provided with an undefined url value',
async (action) => {
const invalidUrl = undefined;

await handleAction(action as InAppMessageAction, invalidUrl);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${action}`);
expect(logger.warn).toHaveBeenCalledWith(`url must be of type string: ${invalidUrl}`);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(Linking.canOpenURL).not.toHaveBeenCalled();
}
);

it('logs a warning when Linking.canOpenUrl returns false', async () => {
(Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(false);

await handleAction(link, url);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`);
expect(Linking.canOpenURL).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(`Unsupported url provided: ${url}`);
expect(Linking.openURL).not.toHaveBeenCalled();
});

it('logs an error when Linking.canOpenUrl fails', async () => {
(Linking.canOpenURL as jest.Mock).mockRejectedValueOnce(error);

await handleAction(link, url);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`);
expect(logger.error).toHaveBeenCalledWith(`Call to Linking.canOpenURL failed: ${error}`);
});

it('logs an error when Linking.openUrl fails', async () => {
(Linking.canOpenURL as jest.Mock).mockResolvedValueOnce(true);
(Linking.openURL as jest.Mock).mockRejectedValue(error);

await handleAction(link, url);

expect(logger.info).toHaveBeenCalledWith(`Handle action: ${link}`);
expect(logger.error).toHaveBeenCalledWith(`Call to Linking.openURL failed: ${error}`);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
* Copyright 2017-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
* the License. A copy of the License is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/

import { InAppMessage, InAppMessageInteractionEvent, Notifications } from '@aws-amplify/notifications';
import { ConsoleLogger as Logger } from '@aws-amplify/core';

import useInAppMessaging from '../../../../hooks/useInAppMessaging';
import BannerMessage from '../../../BannerMessage';
import CarouselMessage from '../../../CarouselMessage';
import FullScreenMessage from '../../../FullScreenMessage';
import ModalMessage from '../../../ModalMessage';
import { InAppMessageComponentBaseProps } from '../../../types';

import useMessage from '../useMessage';

jest.mock('@aws-amplify/notifications', () => ({
...jest.requireActual('@aws-amplify/notifications'),
Notifications: { InAppMessaging: { notifyMessageInteraction: jest.fn() } },
}));

jest.mock('../../../../hooks/useInAppMessaging', () => ({
__esModule: true,
default: jest.fn(),
}));

jest.useFakeTimers();

const logger = new Logger('TEST_LOGGER');

const mockUseInAppMessaging = useInAppMessaging as jest.Mock;
const mockClearInAppMessage = jest.fn();

const header = { content: 'header one' };
const baseInAppMessage: Partial<InAppMessage> = { id: 'test', content: [{ header }] };
const carouselInAppMessage: Partial<InAppMessage> = {
id: 'carousel',
content: [{ header }, { header: { content: 'header two' } }],
layout: 'CAROUSEL',
};

function CustomBannerMessage() {
return null;
}
function CustomCarouselMessage() {
return null;
}
function CustomFullScreenMessage() {
return null;
}
function CustomModalMessage() {
return null;
}

describe('useMessage', () => {
beforeEach(() => {
(logger.info as jest.Mock).mockClear();
});

// happy path test for banner and full screen layouts
it.each([
['BOTTOM_BANNER', BannerMessage, { position: 'bottom' }],
['FULL_SCREEN', FullScreenMessage, null],
['MIDDLE_BANNER', BannerMessage, { position: 'middle' }],
['TOP_BANNER', BannerMessage, { position: 'top' }],
['MODAL', ModalMessage, null],
])('returns the expected values of Component and props for a %s layout', (layout, layoutComponent, layoutProps) => {
mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: { ...baseInAppMessage, layout } });
const { Component, props } = useMessage();

expect(Component).toBe(layoutComponent);
expect(props).toEqual(
expect.objectContaining({
...layoutProps,
header,
layout,
onClose: expect.any(Function) as InAppMessageComponentBaseProps['onClose'],
onDisplay: expect.any(Function) as InAppMessageComponentBaseProps['onDisplay'],
style: undefined,
})
);
});

it('returns the expected values of Component and props for a CAROUSEL layout', () => {
mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: carouselInAppMessage });

const { Component, props } = useMessage();

expect(Component).toBe(CarouselMessage);
expect(props).toEqual(
expect.objectContaining({
data: [{ header }, { header: { content: 'header two' } }],
layout: 'CAROUSEL',
onClose: expect.any(Function) as InAppMessageComponentBaseProps['onClose'],
onDisplay: expect.any(Function) as InAppMessageComponentBaseProps['onDisplay'],
style: undefined,
})
);
});

it.each([
['BannerMessage', 'BOTTOM_BANNER', CustomBannerMessage],
['BannerMessage', 'MIDDLE_BANNER', CustomBannerMessage],
['BannerMessage', 'TOP_BANNER', CustomBannerMessage],
['CarouselMessage', 'CAROUSEL', CustomCarouselMessage],
['FullScreenMessage', 'FULL_SCREEN', CustomFullScreenMessage],
['ModalMessage', 'MODAL', CustomModalMessage],
])(
'returns a custom %s component for a %s layout in place of the default component when provided',
(componentKey, layout, CustomComponent) => {
mockUseInAppMessaging.mockReturnValueOnce({
components: { [componentKey]: CustomComponent },
inAppMessage: { layout },
});

const { Component } = useMessage();

expect(Component).toBe(CustomComponent);
}
);

it('returns null values for Component and props when inAppMessage is null', () => {
mockUseInAppMessaging.mockReturnValueOnce({ components: {}, inAppMessage: null });

const { Component, props } = useMessage();

expect(Component).toBeNull();
expect(props).toBeNull();
});

it('returns null values for Component and props when inAppMessage.layout is not supported', () => {
const layout = 'NOT_A_SUPPORTED_LAYOUT';
mockUseInAppMessaging.mockReturnValueOnce({
components: {},
inAppMessage: { layout },
});

const { Component, props } = useMessage();

expect(logger.info).toHaveBeenCalledWith(`Received unknown InAppMessage layout: ${layout}`);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(Component).toBeNull();
expect(props).toBeNull();
});

describe('event handling', () => {
const inAppMessage = {
content: [{ primaryButton: { action: 'CLOSE', title: 'primary' } }],
layout: 'TOP_BANNER',
};

beforeEach(() => {
mockUseInAppMessaging.mockReturnValueOnce({
clearInAppMessage: mockClearInAppMessage,
components: {},
inAppMessage,
});

mockClearInAppMessage.mockClear();
(Notifications.InAppMessaging.notifyMessageInteraction as jest.Mock).mockClear();
});

describe('onClose', () => {
it('calls the expected methods', () => {
const { props } = useMessage();

props.onClose();

expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1);
expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith(
inAppMessage,
InAppMessageInteractionEvent.MESSAGE_DISMISSED
);
expect(mockClearInAppMessage).toHaveBeenCalledTimes(1);
});
});

describe('onDisplay', () => {
it('calls the expected methods', () => {
const { props } = useMessage();

props.onDisplay();

expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1);
expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith(
inAppMessage,
InAppMessageInteractionEvent.MESSAGE_DISPLAYED
);
});
});

describe('onActionCallback', () => {
it('calls the expected methods via the onPress function of the primary button', () => {
const { props } = useMessage();

props.primaryButton.onPress();

jest.runAllTimers();

expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledTimes(1);
expect(Notifications.InAppMessaging.notifyMessageInteraction).toHaveBeenCalledWith(
inAppMessage,
InAppMessageInteractionEvent.MESSAGE_ACTION_TAKEN
);
expect(mockClearInAppMessage).toHaveBeenCalledTimes(1);
});
});
});
});
Loading

0 comments on commit 0a86073

Please sign in to comment.