diff --git a/coverage-targets.js b/coverage-targets.js
index 4bccf1153..ada91d29a 100644
--- a/coverage-targets.js
+++ b/coverage-targets.js
@@ -6,10 +6,10 @@
// subset of files to check against these targets.
module.exports = {
global: {
- branches: 50,
- functions: 55,
lines: 62.25,
+ branches: 50.5,
statements: 61.5,
+ functions: 55,
},
transforms: {
branches: 100,
diff --git a/test/data/mock-state.json b/test/data/mock-state.json
index d509fded4..f68548098 100644
--- a/test/data/mock-state.json
+++ b/test/data/mock-state.json
@@ -22,7 +22,7 @@
"mostRecentOverviewPage": "/mostRecentOverviewPage"
},
"metamask": {
- "ipfsGateway": "",
+ "ipfsGateway": "dweb.link",
"dismissSeedBackUpReminder": false,
"usePhishDetect": true,
"useMultiAccountBalanceChecker": false,
@@ -259,6 +259,161 @@
"maxBaseFee": "75",
"priorityFee": "2"
},
+ "collectiblesDropdownState": {
+ "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc": {
+ "0x5": {
+ "0x495f947276749Ce646f68AC8c248420045cb7b5e": false
+ }
+ }
+ },
+ "allNftContracts": {
+ "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc": {
+ "1": [
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "1",
+ "name": "MUNK #1",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ }
+ ],
+ "137": [
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "1",
+ "name": "MUNK #1",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ }
+ ],
+ "11155111": [
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "1",
+ "name": "MUNK #1",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ }
+ ],
+ "5": [
+ {
+ "address": "0x495f947276749Ce646f68AC8c248420045cb7b5e",
+ "tokenId": "58076532811975507823669075598676816378162417803895263482849101575514658701313",
+ "name": "Punk #4",
+ "creator": {
+ "user": {
+ "username": null
+ },
+ "profile_img_url": null,
+ "address": "0x806627172af48bd5b0765d3449a7def80d6576ff",
+ "config": ""
+ },
+ "description": "Red Mohawk bam!",
+ "image": "https://lh3.googleusercontent.com/BdxvLseXcfl57BiuQcQYdJ64v-aI8din7WPk0Pgo3qQFhAUH-B6i-dCqqc_mCkRIzULmwzwecnohLhrcH8A9mpWIZqA7ygc52Sr81hE",
+ "standard": "ERC1155"
+ },
+ {
+ "address": "0x495f947276749Ce646f68AC8c248420045cb7b5e",
+ "tokenId": "58076532811975507823669075598676816378162417803895263482849101574415147073537",
+ "name": "Punk #3",
+ "creator": {
+ "user": {
+ "username": null
+ },
+ "profile_img_url": null,
+ "address": "0x806627172af48bd5b0765d3449a7def80d6576ff",
+ "config": ""
+ },
+ "description": "Clown PUNK!!!",
+ "image": "https://lh3.googleusercontent.com/H7VrxaalZv4PF1B8U7ADuc8AfuqTVyzmMEDQ5OXKlx0Tqu5XiwsKYj4j_pAF6wUJjLMQbSN_0n3fuj84lNyRhFW9hyrxqDfY1IiQEQ",
+ "standard": "ERC1155"
+ },
+ {
+ "address": "0x495f947276749Ce646f68AC8c248420045cb7b5e",
+ "tokenId": "58076532811975507823669075598676816378162417803895263482849101573315635445761",
+ "name": "Punk #2",
+ "creator": {
+ "user": {
+ "username": null
+ },
+ "profile_img_url": null,
+ "address": "0x806627172af48bd5b0765d3449a7def80d6576ff",
+ "config": ""
+ },
+ "description": "Got glasses and black hair!",
+ "image": "https://lh3.googleusercontent.com/CHNTSlKB_Gob-iwTq8jcag6XwBkTqBMLt_vEKeBv18Q4AoPFAEPceqK6mRzkad2s5djx6CT5zbGQwDy81WwtNzViK5dQbG60uAWv",
+ "standard": "ERC1155"
+ },
+ {
+ "address": "0x495f947276749Ce646f68AC8c248420045cb7b5e",
+ "tokenId": "58076532811975507823669075598676816378162417803895263482849101572216123817985",
+ "name": "Punk #1",
+ "creator": {
+ "user": {
+ "username": null
+ },
+ "profile_img_url": null,
+ "address": "0x806627172af48bd5b0765d3449a7def80d6576ff",
+ "config": ""
+ },
+ "image": "https://lh3.googleusercontent.com/4jfPi-nQNWCUXD5qVNVWX7LX2UufU_elEJcvICFlsTdcBXv70asnDEOlI8oKECZxlXq1wseeIXMwmP5tLyOUxMKk",
+ "standard": "ERC1155"
+ },
+ {
+ "address": "0x495f947276749Ce646f68AC8c248420045cb7b5e",
+ "tokenId": "58076532811975507823669075598676816378162417803895263482849101571116612190209",
+ "name": "Punk #4651",
+ "creator": {
+ "user": {
+ "username": null
+ },
+ "profile_img_url": null,
+ "address": "0x806627172af48bd5b0765d3449a7def80d6576ff",
+ "config": ""
+ },
+ "image": "https://lh3.googleusercontent.com/BdxvLseXcfl57BiuQcQYdJ64v-aI8din7WPk0Pgo3qQFhAUH-B6i-dCqqc_mCkRIzULmwzwecnohLhrcH8A9mpWIZqA7ygc52Sr81hE",
+ "standard": "ERC1155"
+ },
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "1",
+ "name": "MUNK #1",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ },
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "2",
+ "name": "MUNK #2",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ },
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "3",
+ "name": "MUNK #3",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ }
+ ],
+ "153": [
+ {
+ "address": "0xDc7382Eb0Bc9C352A4CbA23c909bDA01e0206414",
+ "tokenId": "1",
+ "name": "MUNK #1",
+ "description": null,
+ "image": "ipfs://QmTSZUNt8AKyDabkyXXXP4oHWDnaVXgNdXoJGEyaYzLbeL",
+ "standard": "ERC721"
+ }
+ ]
+ }
+ },
"tokenList": {
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {
"address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
diff --git a/ui/components/app/collectible-default-image/__snapshots__/collectible-default-image.test.js.snap b/ui/components/app/collectible-default-image/__snapshots__/collectible-default-image.test.js.snap
new file mode 100644
index 000000000..e126c04b7
--- /dev/null
+++ b/ui/components/app/collectible-default-image/__snapshots__/collectible-default-image.test.js.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Collectible Default Image should match snapshot with all provided props 1`] = `
+
+
+
+`;
+
+exports[`Collectible Default Image should match snapshot with missing image click handler 1`] = `
+
+
+
+ Collectible Name
+
+
+ #
+ 123
+
+
+
+`;
+
+exports[`Collectible Default Image should render with no props 1`] = `
+
+
+
+ [unknownCollection]
+
+
+ #
+
+
+
+`;
diff --git a/ui/components/app/collectible-default-image/collectible-default-image.js b/ui/components/app/collectible-default-image/collectible-default-image.js
index 45fe66aa5..f0105acd9 100644
--- a/ui/components/app/collectible-default-image/collectible-default-image.js
+++ b/ui/components/app/collectible-default-image/collectible-default-image.js
@@ -15,6 +15,7 @@ export default function CollectibleDefaultImage({
return (
{
+ it('should render with no props', () => {
+ const { container } = renderWithProvider();
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should match snapshot with all provided props', () => {
+ const props = {
+ name: 'Collectible Name',
+ tokenId: '123',
+ handleImageClick: jest.fn(),
+ };
+
+ const { container } = renderWithProvider(
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should match snapshot with missing image click handler', () => {
+ const props = {
+ name: 'Collectible Name',
+ tokenId: '123',
+ };
+
+ const { container } = renderWithProvider(
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should render collectible name', () => {
+ const props = {
+ name: 'Collectible Name',
+ };
+
+ const { queryByText } = renderWithProvider(
+ ,
+ );
+
+ const collectibleElement = queryByText(`${props.name} #`);
+
+ expect(collectibleElement).toBeInTheDocument();
+ });
+
+ it('should render collectible name and tokenId', () => {
+ const props = {
+ name: 'Collectible Name',
+ tokenId: '123',
+ };
+
+ const { queryByText } = renderWithProvider(
+ ,
+ );
+
+ const collectibleElement = queryByText(`${props.name} #${props.tokenId}`);
+
+ expect(collectibleElement).toBeInTheDocument();
+ });
+
+ it('should handle image click', () => {
+ const props = {
+ handleImageClick: jest.fn(),
+ };
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ const collectibleImageElement = queryByTestId('collectible-default-image');
+ fireEvent.click(collectibleImageElement);
+
+ expect(props.handleImageClick).toHaveBeenCalled();
+ });
+});
diff --git a/ui/components/app/collectible-details/__snapshots__/collectible-details.test.js.snap b/ui/components/app/collectible-details/__snapshots__/collectible-details.test.js.snap
new file mode 100644
index 000000000..562938ccf
--- /dev/null
+++ b/ui/components/app/collectible-details/__snapshots__/collectible-details.test.js.snap
@@ -0,0 +1,167 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Collectible Details should match minimal props and state snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MUNK #1
+
+
+ #
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+ Contract address
+
+
+
+
+ Disclaimer: MetaMask pulls the media file from the source url. This url sometimes is changed by the marketplace the NFT was minted on.
+
+
+
+
+`;
diff --git a/ui/components/app/collectible-details/collectible-details.js b/ui/components/app/collectible-details/collectible-details.js
index 7772be610..0329fcc01 100644
--- a/ui/components/app/collectible-details/collectible-details.js
+++ b/ui/components/app/collectible-details/collectible-details.js
@@ -150,6 +150,7 @@ export default function CollectibleDetails({ collectible }) {
onClick={onSend}
disabled={sendDisabled}
className="collectible-details__send-button"
+ data-testid="collectible-send-button"
>
{t('send')}
@@ -416,6 +417,7 @@ export default function CollectibleDetails({ collectible }) {
ariaLabel="copy"
color={IconColor.iconAlternative}
className="collectible-details__contract-copy-button"
+ data-testid="collectible-address-copy"
onClick={() => {
handleAddressCopy(address);
}}
diff --git a/ui/components/app/collectible-details/collectible-details.test.js b/ui/components/app/collectible-details/collectible-details.test.js
new file mode 100644
index 000000000..e389e2e6e
--- /dev/null
+++ b/ui/components/app/collectible-details/collectible-details.test.js
@@ -0,0 +1,286 @@
+import { fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import copyToClipboard from 'copy-to-clipboard';
+import { startNewDraftTransaction } from '../../../ducks/send';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import mockState from '../../../../test/data/mock-state.json';
+import { DEFAULT_ROUTE, SEND_ROUTE } from '../../../helpers/constants/routes';
+import { AssetType } from '../../../../shared/constants/transaction';
+import {
+ removeAndIgnoreNft,
+ setRemoveCollectibleMessage,
+} from '../../../store/actions';
+import CollectibleDetails from './collectible-details';
+
+jest.mock('copy-to-clipboard');
+
+const mockHistoryPush = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useLocation: jest.fn(() => ({ search: '' })),
+ useHistory: () => ({
+ push: mockHistoryPush,
+ }),
+}));
+
+jest.mock('../../../ducks/send/index.js', () => ({
+ ...jest.requireActual('../../../ducks/send/index.js'),
+ startNewDraftTransaction: jest
+ .fn()
+ .mockReturnValue(jest.fn().mockResolvedValue()),
+}));
+
+jest.mock('../../../store/actions.ts', () => ({
+ ...jest.requireActual('../../../store/actions.ts'),
+ checkAndUpdateSingleNftOwnershipStatus: jest.fn().mockReturnValue(jest.fn()),
+ removeAndIgnoreNft: jest.fn().mockReturnValue(jest.fn()),
+ setRemoveCollectibleMessage: jest.fn().mockReturnValue(jest.fn()),
+}));
+
+describe('Collectible Details', () => {
+ const mockStore = configureMockStore([thunk])(mockState);
+
+ const collectibles =
+ mockState.metamask.allNftContracts[mockState.metamask.selectedAddress][5];
+
+ const props = {
+ collectible: collectibles[5],
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should match minimal props and state snapshot', () => {
+ const { container } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it(`should route to '/' route when the back button is clicked`, () => {
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const backButton = queryByTestId('asset__back');
+
+ fireEvent.click(backButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith(DEFAULT_ROUTE);
+ });
+
+ it(`should call removeAndIgnoreNft with proper collectible details and route to '/' when removing collectible`, () => {
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const removeCollectibleButton = queryByTestId('collectible-item-remove');
+ fireEvent.click(removeCollectibleButton);
+
+ expect(removeAndIgnoreNft).toHaveBeenCalledWith(
+ collectibles[5].address,
+ collectibles[5].tokenId,
+ );
+ expect(setRemoveCollectibleMessage).toHaveBeenCalledWith('success');
+ expect(mockHistoryPush).toHaveBeenCalledWith(DEFAULT_ROUTE);
+ });
+
+ it('should copy collectible address', async () => {
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const copyAddressButton = queryByTestId('collectible-address-copy');
+ fireEvent.click(copyAddressButton);
+
+ expect(copyToClipboard).toHaveBeenCalledWith(collectibles[5].address);
+ });
+
+ it('should navigate to draft transaction send route with ERC721 data', async () => {
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const collectibleSendButton = queryByTestId('collectible-send-button');
+ fireEvent.click(collectibleSendButton);
+
+ await waitFor(() => {
+ expect(startNewDraftTransaction).toHaveBeenCalledWith({
+ type: AssetType.NFT,
+ details: collectibles[5],
+ });
+
+ expect(mockHistoryPush).toHaveBeenCalledWith(SEND_ROUTE);
+ });
+ });
+
+ it('should not render send button if isCurrentlyOwned is false', () => {
+ const sixthCollectibleProps = {
+ collectible: collectibles[6],
+ };
+ collectibles[6].isCurrentlyOwned = false;
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const collectibleSendButton = queryByTestId('collectible-send-button');
+ expect(collectibleSendButton).not.toBeInTheDocument();
+ });
+
+ describe(`Alternative Networks' OpenSea Links`, () => {
+ it('should open opeasea link with goeli testnet chainId', async () => {
+ global.platform = { openTab: jest.fn() };
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const openOpenSea = queryByTestId('collectible-options__view-on-opensea');
+ fireEvent.click(openOpenSea);
+
+ await waitFor(() => {
+ expect(global.platform.openTab).toHaveBeenCalledWith({
+ url: `https://testnets.opensea.io/assets/${collectibles[5].address}/${collectibles[5].tokenId}`,
+ });
+ });
+ });
+
+ it('should open tab to mainnet opensea url with collectible info', async () => {
+ global.platform = { openTab: jest.fn() };
+
+ const mainnetState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ provider: {
+ chainId: '0x1',
+ },
+ },
+ };
+ const mainnetMockStore = configureMockStore([thunk])(mainnetState);
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ mainnetMockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const openOpenSea = queryByTestId('collectible-options__view-on-opensea');
+ fireEvent.click(openOpenSea);
+
+ await waitFor(() => {
+ expect(global.platform.openTab).toHaveBeenCalledWith({
+ url: `https://opensea.io/assets/${collectibles[5].address}/${collectibles[5].tokenId}`,
+ });
+ });
+ });
+
+ it('should open tab to polygon opensea url with collectible info', async () => {
+ const polygonState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ provider: {
+ chainId: '0x89',
+ },
+ },
+ };
+ const polygonMockStore = configureMockStore([thunk])(polygonState);
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ polygonMockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const openOpenSea = queryByTestId('collectible-options__view-on-opensea');
+ fireEvent.click(openOpenSea);
+
+ await waitFor(() => {
+ expect(global.platform.openTab).toHaveBeenCalledWith({
+ url: `https://opensea.io/assets/matic/${collectibles[5].address}/${collectibles[5].tokenId}`,
+ });
+ });
+ });
+
+ it('should open tab to sepolia opensea url with collectible info', async () => {
+ const sepoliaState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ provider: {
+ chainId: '0xaa36a7',
+ },
+ },
+ };
+ const sepoliaMockStore = configureMockStore([thunk])(sepoliaState);
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ sepoliaMockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const openOpenSea = queryByTestId('collectible-options__view-on-opensea');
+ fireEvent.click(openOpenSea);
+
+ await waitFor(() => {
+ expect(global.platform.openTab).toHaveBeenCalledWith({
+ url: `https://testnets.opensea.io/assets/${collectibles[5].address}/${collectibles[5].tokenId}`,
+ });
+ });
+ });
+
+ it('should not render opensea redirect button', async () => {
+ const randomNetworkState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ provider: {
+ chainId: '0x99',
+ },
+ },
+ };
+ const randomNetworkMockStore = configureMockStore([thunk])(
+ randomNetworkState,
+ );
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ randomNetworkMockStore,
+ );
+
+ const openOptionMenuButton = queryByTestId('collectible-options__button');
+ fireEvent.click(openOptionMenuButton);
+
+ const openOpenSea = queryByTestId('collectible-options__view-on-opensea');
+ await waitFor(() => {
+ expect(openOpenSea).not.toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/ui/components/app/collectible-options/collectible-options.js b/ui/components/app/collectible-options/collectible-options.js
index 8fddada83..4c09d5aad 100644
--- a/ui/components/app/collectible-options/collectible-options.js
+++ b/ui/components/app/collectible-options/collectible-options.js
@@ -22,6 +22,7 @@ const CollectibleOptions = ({ onRemove, onViewOnOpensea }) => {
{collectibleOptionsOpen ? (