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`] = ` + +`; 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 ? ( setCollectibleOptionsOpen(false)} > {onViewOnOpensea ? ( @@ -38,7 +39,7 @@ const CollectibleOptions = ({ onRemove, onViewOnOpensea }) => { ) : null} { setCollectibleOptionsOpen(false); onRemove(); @@ -54,7 +55,7 @@ const CollectibleOptions = ({ onRemove, onViewOnOpensea }) => { CollectibleOptions.propTypes = { onRemove: PropTypes.func.isRequired, - onViewOnOpensea: PropTypes.func.isRequired, + onViewOnOpensea: PropTypes.func, }; export default CollectibleOptions; diff --git a/ui/components/app/collectible-options/collectible-options.test.js b/ui/components/app/collectible-options/collectible-options.test.js new file mode 100644 index 000000000..24b20490a --- /dev/null +++ b/ui/components/app/collectible-options/collectible-options.test.js @@ -0,0 +1,80 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import CollectibleOptions from './collectible-options'; + +describe('Collectible Options Component', () => { + const props = { + onRemove: jest.fn(), + onViewOnOpensea: jest.fn(), + }; + + it('should expand collectible options menu`', async () => { + const { queryByTestId } = renderWithProvider( + , + ); + + const openOptionMenuButton = queryByTestId('collectible-options__button'); + + expect(queryByTestId('collectible-item-remove')).not.toBeInTheDocument(); + + fireEvent.click(openOptionMenuButton); + + await waitFor(() => { + expect(queryByTestId('collectible-item-remove')).toBeInTheDocument(); + }); + }); + + it('should expand and close menu options when clicked`', async () => { + const { queryByTestId } = renderWithProvider( + , + ); + + const openOptionMenuButton = queryByTestId('collectible-options__button'); + + fireEvent.click(openOptionMenuButton); + + const closeOptionMenuButton = queryByTestId( + 'close-collectible-options-menu', + ); + + fireEvent.click(closeOptionMenuButton); + + expect(closeOptionMenuButton).not.toBeInTheDocument(); + }); + + it('should click onRemove handler and close option menu', () => { + const { queryByTestId } = renderWithProvider( + , + ); + + const openOptionMenuButton = queryByTestId('collectible-options__button'); + + fireEvent.click(openOptionMenuButton); + + const removeCollectibleButton = queryByTestId('collectible-item-remove'); + + fireEvent.click(removeCollectibleButton); + + expect(props.onRemove).toHaveBeenCalled(); + expect(removeCollectibleButton).not.toBeInTheDocument(); + }); + + it('should click onViewOnOpensea handler and close option menu', () => { + const { queryByTestId } = renderWithProvider( + , + ); + + const openOptionMenuButton = queryByTestId('collectible-options__button'); + const removeCollectibleButton = queryByTestId('collectible-item-remove'); + + fireEvent.click(openOptionMenuButton); + + const openOpenSea = queryByTestId('collectible-options__view-on-opensea'); + + fireEvent.click(openOpenSea); + + expect(props.onViewOnOpensea).toHaveBeenCalled(); + expect(removeCollectibleButton).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/collectibles-items/collectibles-items.js b/ui/components/app/collectibles-items/collectibles-items.js index 9c342b446..53e932240 100644 --- a/ui/components/app/collectibles-items/collectibles-items.js +++ b/ui/components/app/collectibles-items/collectibles-items.js @@ -134,10 +134,11 @@ export default function CollectiblesItems({ return (