diff --git a/__tests__/components/6529Gradient/GradientPage.test.tsx b/__tests__/components/6529Gradient/GradientPage.test.tsx index 77b86bf6b6..64eaa1e5ba 100644 --- a/__tests__/components/6529Gradient/GradientPage.test.tsx +++ b/__tests__/components/6529Gradient/GradientPage.test.tsx @@ -1,6 +1,7 @@ import { mockGradientCollection } from "@/__tests__/fixtures/gradientFixtures"; import GradientPageComponent from "@/components/6529Gradient/GradientPage"; import { AuthContext } from "@/components/auth/Auth"; +import { SeizeConnectProvider } from "@/components/auth/SeizeConnectContext"; import { CookieConsentProvider } from "@/components/cookies/CookieConsentContext"; import { GRADIENT_CONTRACT } from "@/constants"; import { TitleProvider } from "@/contexts/TitleContext"; @@ -58,6 +59,20 @@ jest.mock("@/components/latest-activity/LatestActivityRow", () => ({ })); jest.mock("@/hooks/useCapacitor", () => () => ({ isIos: false })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isAuthenticated: false, + seizeConnect: jest.fn(), + seizeAcceptConnection: jest.fn(), + address: undefined, + hasInitializationError: false, + initializationError: null, + })), + SeizeConnectProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + const routerReplace = jest.fn(); (useRouter as jest.Mock).mockReturnValue({ replace: routerReplace, @@ -89,7 +104,9 @@ function renderPage(wallet: string = "0x1") { - + + + diff --git a/__tests__/components/NftNavigation.test.tsx b/__tests__/components/NftNavigation.test.tsx index 5ae70e8676..6b175c3c9f 100644 --- a/__tests__/components/NftNavigation.test.tsx +++ b/__tests__/components/NftNavigation.test.tsx @@ -1,13 +1,27 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import NftNavigation from '@/components/nft-navigation/NftNavigation'; -import { enterArtFullScreen, fullScreenSupported } from '@/helpers/Helpers'; +import NftNavigation from "@/components/nft-navigation/NftNavigation"; +import { enterArtFullScreen, fullScreenSupported } from "@/helpers/Helpers"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReadonlyURLSearchParams } from "next/navigation"; -jest.mock('next/link', () => ({ __esModule: true, default: ({ href, children, className, ...props }: any) => {children} })); -jest.mock('@fortawesome/react-fontawesome', () => ({ FontAwesomeIcon: (props: any) => })); +const makeParams = (query: string = "") => + new URLSearchParams(query) as unknown as ReadonlyURLSearchParams; -jest.mock('@/helpers/Helpers', () => ({ +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children, className, ...props }: any) => ( + + {children} + + ), +})); +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: (props: any) => ( + + ), +})); + +jest.mock("@/helpers/Helpers", () => ({ enterArtFullScreen: jest.fn(), fullScreenSupported: jest.fn(), })); @@ -15,18 +29,26 @@ jest.mock('@/helpers/Helpers', () => ({ const enterArtFullScreenMock = enterArtFullScreen as jest.Mock; const fullScreenSupportedMock = fullScreenSupported as jest.Mock; -describe('NftNavigation', () => { +describe("NftNavigation", () => { beforeEach(() => jest.clearAllMocks()); - it('disables previous link when at first item', () => { + it("disables previous link when at first item", () => { fullScreenSupportedMock.mockReturnValue(false); - render(); - const links = screen.getAllByRole('link'); + render( + + ); + const links = screen.getAllByRole("link"); expect(links[0].className).toMatch(/tw-pointer-events-none/); expect(links[1].className).not.toMatch(/tw-pointer-events-none/); }); - it('shows fullscreen icon and triggers fullscreen', async () => { + it("shows fullscreen icon and triggers fullscreen", async () => { fullScreenSupportedMock.mockReturnValue(true); render( { startIndex={1} endIndex={3} fullscreenElementId="art-1" + params={makeParams()} /> ); - const icons = screen.getAllByTestId('icon'); + const icons = screen.getAllByTestId("icon"); expect(icons).toHaveLength(3); await userEvent.click(icons[2]); - expect(enterArtFullScreenMock).toHaveBeenCalledWith('art-1'); + expect(enterArtFullScreenMock).toHaveBeenCalledWith("art-1"); + }); + + it("preserves query params in navigation links", () => { + fullScreenSupportedMock.mockReturnValue(false); + const params = makeParams("foo=bar&mode=light"); + render( + + ); + const links = screen.getAllByRole("link"); + expect(links.length).toBeGreaterThan(0); + for (const a of links) { + expect(a.getAttribute("href") || "").toContain("foo=bar"); + } }); }); diff --git a/__tests__/components/common/TabToggleWithOverflow.test.tsx b/__tests__/components/common/TabToggleWithOverflow.test.tsx index e579da33dc..f38bdc037c 100644 --- a/__tests__/components/common/TabToggleWithOverflow.test.tsx +++ b/__tests__/components/common/TabToggleWithOverflow.test.tsx @@ -1,17 +1,29 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { TabToggleWithOverflow } from '@/components/common/TabToggleWithOverflow'; +import { TabToggleWithOverflow } from "@/components/common/TabToggleWithOverflow"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -describe('TabToggleWithOverflow', () => { +describe("TabToggleWithOverflow", () => { const options = [ - { key: 'a', label: 'A' }, - { key: 'b', label: 'B' }, - { key: 'c', label: 'C' }, - { key: 'd', label: 'D' }, + { key: "a", label: "A" }, + { key: "b", label: "B" }, + { key: "c", label: "C" }, + { key: "d", label: "D" }, ]; - it('shows overflow items and handles selection', async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const ensureButtonFocused = async (button: HTMLElement) => { + await act(async () => { + button.focus(); + await delay(50); + if (document.activeElement !== button) { + button.focus(); + } + }); + }; + + it("shows overflow items and handles selection", async () => { const onSelect = jest.fn(); const user = userEvent.setup(); render( @@ -24,22 +36,24 @@ describe('TabToggleWithOverflow', () => { ); // visible tabs - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText("A")).toBeInTheDocument(); + expect(screen.getByText("B")).toBeInTheDocument(); // open overflow dropdown - await user.click(screen.getByRole('button', { name: 'More tabs' })); - const optionC = screen.getByRole('menuitem', { name: 'C' }); + await user.click(screen.getByRole("button", { name: "More tabs" })); + const optionC = screen.getByRole("menuitem", { name: "C" }); expect(optionC).toBeInTheDocument(); await user.click(optionC); - expect(onSelect).toHaveBeenCalledWith('c'); + expect(onSelect).toHaveBeenCalledWith("c"); await waitFor(() => - expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument() + expect( + screen.queryByRole("menuitem", { name: "C" }) + ).not.toBeInTheDocument() ); }); - it('shows active label when active tab is in overflow', () => { + it("shows active label when active tab is in overflow", () => { render( { ); // Button label should show active label - expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.getByText("D")).toBeInTheDocument(); }); - it('applies ARIA roles to visible tabs', () => { + it("applies ARIA roles to visible tabs", () => { render( { /> ); - expect(screen.getByRole('tablist')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'A' })).toHaveAttribute( - 'aria-selected', - 'true' + expect(screen.getByRole("tablist")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "A" })).toHaveAttribute( + "aria-selected", + "true" ); - expect(screen.getByRole('tab', { name: 'B' })).toHaveAttribute( - 'aria-selected', - 'false' + expect(screen.getByRole("tab", { name: "B" })).toHaveAttribute( + "aria-selected", + "false" ); }); - it('toggles the overflow menu with keyboard interactions', async () => { + it("toggles the overflow menu with keyboard interactions", async () => { const user = userEvent.setup(); render( { /> ); - const moreButton = screen.getByRole('button', { name: 'More tabs' }); - expect(moreButton).toHaveAttribute('aria-expanded', 'false'); + const moreButton = screen.getByRole("button", { name: "More tabs" }); + expect(moreButton).toHaveAttribute("aria-expanded", "false"); - await user.tab(); // focus first tab - await user.tab(); // focus second tab - moreButton.focus(); - expect(moreButton).toHaveFocus(); - await user.keyboard('{Enter}'); + await waitFor(() => { + const activeTab = screen.getByRole("tab", { name: "A" }); + expect(activeTab).toHaveFocus(); + }); + + await ensureButtonFocused(moreButton); + + await waitFor(() => expect(moreButton).toHaveFocus(), { timeout: 2000 }); + + await user.keyboard("{Enter}"); await waitFor(() => - expect(moreButton).toHaveAttribute('aria-expanded', 'true') + expect(moreButton).toHaveAttribute("aria-expanded", "true") ); - const optionC = await screen.findByRole('menuitem', { name: 'C' }); + const optionC = await screen.findByRole("menuitem", { name: "C" }); expect(optionC).toBeInTheDocument(); - await user.keyboard('{Escape}'); + await user.keyboard("{Escape}"); await waitFor(() => - expect(moreButton).toHaveAttribute('aria-expanded', 'false') + expect(moreButton).toHaveAttribute("aria-expanded", "false") ); await waitFor(() => { - expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: "C" }) + ).not.toBeInTheDocument(); }); - await user.keyboard(' '); + await ensureButtonFocused(moreButton); + + await waitFor(() => expect(moreButton).toHaveFocus(), { timeout: 2000 }); + await user.keyboard(" "); await waitFor(() => - expect(moreButton).toHaveAttribute('aria-expanded', 'true') + expect(moreButton).toHaveAttribute("aria-expanded", "true") ); - await screen.findByRole('menuitem', { name: 'C' }); + await screen.findByRole("menuitem", { name: "C" }); }); - it('indicates overflow active state via data attribute when opened', async () => { + it("indicates overflow active state via data attribute when opened", async () => { const user = userEvent.setup(); render( { /> ); - const moreButton = screen.getByRole('button', { name: 'More tabs' }); + const moreButton = screen.getByRole("button", { name: "More tabs" }); await user.click(moreButton); - expect(screen.getByRole('menuitem', { name: 'D' })).toHaveAttribute( - 'data-active', - 'true' + expect(screen.getByRole("menuitem", { name: "D" })).toHaveAttribute( + "data-active", + "true" ); - expect(screen.getByRole('menuitem', { name: 'C' })).toHaveAttribute( - 'data-active', - 'false' + expect(screen.getByRole("menuitem", { name: "C" })).toHaveAttribute( + "data-active", + "false" ); }); }); diff --git a/__tests__/components/memelab/MemeLabPage.test.tsx b/__tests__/components/memelab/MemeLabPage.test.tsx index 6ca76dcada..8388b4144d 100644 --- a/__tests__/components/memelab/MemeLabPage.test.tsx +++ b/__tests__/components/memelab/MemeLabPage.test.tsx @@ -1,8 +1,11 @@ +import { SeizeConnectProvider } from "@/components/auth/SeizeConnectContext"; import MemeLabPageComponent from "@/components/memelab/MemeLabPage"; import { MEME_FOCUS } from "@/components/the-memes/MemeShared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, render, screen, waitFor } from "@testing-library/react"; import React from "react"; +import { createConfig, http, WagmiProvider } from "wagmi"; +import { mainnet } from "wagmi/chains"; // Mock TitleContext jest.mock("@/contexts/TitleContext", () => ({ @@ -113,6 +116,20 @@ jest.mock("@/components/pagination/Pagination", () => ({ default: () =>
, })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isAuthenticated: false, + seizeConnect: jest.fn(), + seizeAcceptConnection: jest.fn(), + address: undefined, + hasInitializationError: false, + initializationError: null, + })), + SeizeConnectProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + // Import mocks const mockUseRouter = jest.fn(); const mockUseSearchParams = jest.fn(); @@ -146,7 +163,7 @@ beforeEach(() => { mockUseAuth.mockReturnValue({ connectedProfile: { - wallets: ["0xabc"], + wallets: [{ wallet: "0xabc" }], }, }); @@ -361,8 +378,21 @@ describe("MemeLabPageComponent", () => { setupMockApiCalls(1); + const mockWagmiConfig = createConfig({ + chains: [mainnet], + transports: { + [mainnet.id]: http(), + }, + }); + await act(async () => { - renderWithQueryClient(); + renderWithQueryClient( + + + + + + ); }); await waitFor( diff --git a/__tests__/components/nextGen/collections/nextgenToken/NextGenTokenPage.test.tsx b/__tests__/components/nextGen/collections/nextgenToken/NextGenTokenPage.test.tsx index 6c579c486f..61bc0e5dce 100644 --- a/__tests__/components/nextGen/collections/nextgenToken/NextGenTokenPage.test.tsx +++ b/__tests__/components/nextGen/collections/nextgenToken/NextGenTokenPage.test.tsx @@ -1,56 +1,26 @@ -// Mock all dependencies before importing anything const mockIsNullAddress = jest.fn(() => false); -jest.mock("@/helpers/Helpers", () => ({ - isNullAddress: mockIsNullAddress, -})); - -jest.mock("@/enums", () => ({ - NextgenCollectionView: { - ABOUT: "About", - PROVENANCE: "Provenance", - DISPLAY_CENTER: "Display Center", - RARITY: "Rarity", - OVERVIEW: "Overview", - TOP_TRAIT_SETS: "Trait Sets", - }, - ProfileActivityLogType: { - RATING_EDIT: "RATING_EDIT", - HANDLE_EDIT: "HANDLE_EDIT", - CLASSIFICATION_EDIT: "CLASSIFICATION_EDIT", - SOCIALS_EDIT: "SOCIALS_EDIT", - NFT_ACCOUNTS_EDIT: "NFT_ACCOUNTS_EDIT", - CONTACTS_EDIT: "CONTACTS_EDIT", - SOCIAL_VERIFICATION_POST_EDIT: "SOCIAL_VERIFICATION_POST_EDIT", - BANNER_1_EDIT: "BANNER_1_EDIT", - BANNER_2_EDIT: "BANNER_2_EDIT", - PFP_EDIT: "PFP_EDIT", - PROFILE_ARCHIVED: "PROFILE_ARCHIVED", - GENERAL_CIC_STATEMENT_EDIT: "GENERAL_CIC_STATEMENT_EDIT", - PROXY_CREATED: "PROXY_CREATED", - PROXY_ACTION_CREATED: "PROXY_ACTION_CREATED", - PROXY_ACTION_STATE_CHANGED: "PROXY_ACTION_STATE_CHANGED", - PROXY_ACTION_CHANGED: "PROXY_ACTION_CHANGED", - DROP_COMMENT: "DROP_COMMENT", - DROP_RATING_EDIT: "DROP_RATING_EDIT", - DROP_CREATED: "DROP_CREATED", - PROXY_DROP_RATING_EDIT: "PROXY_DROP_RATING_EDIT", - }, -})); - -// Mock entities that cause circular dependencies -jest.mock("@/entities/IProfile", () => ({ - PROFILE_ACTIVITY_TYPE_TO_TEXT: {}, -})); +jest.mock("@/helpers/Helpers", () => { + const actual = jest.requireActual("@/helpers/Helpers"); + return { + ...actual, + isNullAddress: mockIsNullAddress, + }; +}); // Mock user components that cause dependency issues jest.mock("@/components/user/utils/UserCICAndLevel", () => ({ UserCICAndLevel: () =>
, })); -jest.mock("@/components/user/utils/raters-table/ProfileRatersTableItem", () => ({ - ProfileRatersTableItem: () =>
, -})); +jest.mock( + "@/components/user/utils/raters-table/ProfileRatersTableItem", + () => ({ + ProfileRatersTableItem: () => ( +
+ ), + }) +); jest.mock("next/navigation", () => { return { @@ -69,7 +39,20 @@ jest.mock("next/navigation", () => { }; }); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isAuthenticated: false, + seizeConnect: jest.fn(), + seizeAcceptConnection: jest.fn(), + address: undefined, + hasInitializationError: false, + initializationError: null, + })), + SeizeConnectProvider: ({ children }: { children: any }) => <>{children}, +})); + import NextGenTokenPage from "@/components/nextGen/collections/nextgenToken/NextGenToken"; +import { NextgenCollectionView } from "@/enums"; import { render, screen } from "@testing-library/react"; jest.mock("react-bootstrap", () => { @@ -115,11 +98,16 @@ jest.mock( }) ); // Mock the printViewButton function from collectionParts -jest.mock("@/components/nextGen/collections/collectionParts/NextGenCollection", () => ({ - printViewButton: (cur: any, v: any, setView: any) => ( - - ), -})); +jest.mock( + "@/components/nextGen/collections/collectionParts/NextGenCollection", + () => ({ + printViewButton: (cur: any, v: any, setView: any) => ( + + ), + }) +); jest.mock("@fortawesome/react-fontawesome", () => ({ FontAwesomeIcon: (props: any) => ( @@ -138,7 +126,6 @@ jest.mock("react-tooltip", () => ({ ), })); - const baseProps = { collection: { id: 1, name: "COL" } as any, token: { @@ -150,7 +137,7 @@ const baseProps = { } as any, traits: [] as any[], tokenCount: 2, - view: "About" as any, // Updated to match actual enum values + view: NextgenCollectionView.ABOUT, setView: jest.fn(), }; @@ -169,7 +156,9 @@ describe("NextGenTokenPage", () => { renderComponent(); expect(screen.getByTestId("view-button-About")).toBeInTheDocument(); expect(screen.getByTestId("view-button-Provenance")).toBeInTheDocument(); - expect(screen.getByTestId("view-button-Display Center")).toBeInTheDocument(); + expect( + screen.getByTestId("view-button-Display Center") + ).toBeInTheDocument(); expect(screen.getByTestId("view-button-Rarity")).toBeInTheDocument(); }); @@ -186,7 +175,7 @@ describe("NextGenTokenPage", () => { describe("view switching", () => { it("renders About view components by default", () => { - renderComponent({ view: "About" }); + renderComponent({ view: NextgenCollectionView.ABOUT }); expect(screen.getByTestId("about")).toBeInTheDocument(); expect(screen.getByTestId("traits")).toBeInTheDocument(); expect(screen.queryByTestId("provenance")).not.toBeInTheDocument(); @@ -195,7 +184,7 @@ describe("NextGenTokenPage", () => { }); it("renders Provenance view when selected", () => { - renderComponent({ view: "Provenance" }); + renderComponent({ view: NextgenCollectionView.PROVENANCE }); expect(screen.getByTestId("provenance")).toBeInTheDocument(); expect(screen.queryByTestId("about")).not.toBeInTheDocument(); expect(screen.queryByTestId("traits")).not.toBeInTheDocument(); @@ -204,7 +193,7 @@ describe("NextGenTokenPage", () => { }); it("renders Display Center view when selected", () => { - renderComponent({ view: "Display Center" }); + renderComponent({ view: NextgenCollectionView.DISPLAY_CENTER }); expect(screen.getByTestId("render")).toBeInTheDocument(); expect(screen.queryByTestId("about")).not.toBeInTheDocument(); expect(screen.queryByTestId("traits")).not.toBeInTheDocument(); @@ -213,7 +202,7 @@ describe("NextGenTokenPage", () => { }); it("renders Rarity view when selected", () => { - renderComponent({ view: "Rarity" }); + renderComponent({ view: NextgenCollectionView.RARITY }); expect(screen.getByTestId("rarity")).toBeInTheDocument(); expect(screen.queryByTestId("about")).not.toBeInTheDocument(); expect(screen.queryByTestId("traits")).not.toBeInTheDocument(); @@ -233,7 +222,9 @@ describe("NextGenTokenPage", () => { }); it("enables previous button when not first token", () => { - renderComponent({ token: { ...baseProps.token, normalised_id: 1, id: 2 } }); + renderComponent({ + token: { ...baseProps.token, normalised_id: 1, id: 2 }, + }); const prev = screen.getByTestId("circle-chevron-left"); expect(prev.getAttribute("style")).toContain("color: rgb(255, 255, 255)"); expect(prev.getAttribute("style")).toContain("cursor: pointer"); @@ -244,9 +235,9 @@ describe("NextGenTokenPage", () => { }); it("disables next button on last token", () => { - renderComponent({ - token: { ...baseProps.token, normalised_id: 1 }, - tokenCount: 2 + renderComponent({ + token: { ...baseProps.token, normalised_id: 1 }, + tokenCount: 2, }); const next = screen.getByTestId("circle-chevron-right"); expect(next.getAttribute("style")).toContain("color: rgb(154, 154, 154)"); @@ -255,9 +246,9 @@ describe("NextGenTokenPage", () => { }); it("enables next button when not last token", () => { - renderComponent({ + renderComponent({ token: { ...baseProps.token, normalised_id: 0, id: 1 }, - tokenCount: 3 + tokenCount: 3, }); const next = screen.getByTestId("circle-chevron-right"); expect(next.getAttribute("style")).toContain("color: rgb(255, 255, 255)"); @@ -272,24 +263,33 @@ describe("NextGenTokenPage", () => { renderComponent({ token: { ...baseProps.token, burnt: true } }); const fireIcon = screen.getByTestId("fire"); expect(fireIcon).toBeInTheDocument(); - expect(fireIcon.getAttribute("style")).toContain("color: rgb(197, 29, 52)"); + expect(fireIcon.getAttribute("style")).toContain( + "color: rgb(197, 29, 52)" + ); expect(screen.getByTestId("tooltip-burnt-token-1")).toBeInTheDocument(); }); it("shows burnt icon when token owner is null address", () => { mockIsNullAddress.mockReturnValue(true); - - renderComponent({ token: { ...baseProps.token, owner: "0x0000000000000000000000000000000000000000" } }); + + renderComponent({ + token: { + ...baseProps.token, + owner: "0x0000000000000000000000000000000000000000", + }, + }); const fireIcon = screen.getByTestId("fire"); expect(fireIcon).toBeInTheDocument(); expect(screen.getByTestId("tooltip-burnt-token-1")).toBeInTheDocument(); - + // Reset mock for other tests mockIsNullAddress.mockReturnValue(false); }); it("does not show burnt icon for normal tokens", () => { - renderComponent({ token: { ...baseProps.token, burnt: false, owner: "0x123" } }); + renderComponent({ + token: { ...baseProps.token, burnt: false, owner: "0x123" }, + }); expect(screen.queryByTestId("fire")).not.toBeInTheDocument(); }); }); @@ -297,15 +297,18 @@ describe("NextGenTokenPage", () => { describe("props handling", () => { it("passes correct props to child components", () => { const mockSetView = jest.fn(); - const mockTraits = [{ trait: "Color", value: "Blue" }, { trait: "Collection Name", value: "Test" }]; - + const mockTraits = [ + { trait: "Color", value: "Blue" }, + { trait: "Collection Name", value: "Test" }, + ]; + renderComponent({ setView: mockSetView, traits: mockTraits, tokenCount: 5, - view: "About" + view: NextgenCollectionView.ABOUT, }); - + // About view should render with traits filtered (Collection Name should be filtered out) expect(screen.getByTestId("about")).toBeInTheDocument(); expect(screen.getByTestId("traits")).toBeInTheDocument(); @@ -314,7 +317,7 @@ describe("NextGenTokenPage", () => { it("calls setView when view buttons are clicked", () => { const mockSetView = jest.fn(); renderComponent({ setView: mockSetView }); - + const aboutButton = screen.getByTestId("view-button-About"); aboutButton.click(); expect(mockSetView).toHaveBeenCalledWith("About"); diff --git a/__tests__/components/nft-transfer/TransferModal.test.tsx b/__tests__/components/nft-transfer/TransferModal.test.tsx new file mode 100644 index 0000000000..0d32e0dfe1 --- /dev/null +++ b/__tests__/components/nft-transfer/TransferModal.test.tsx @@ -0,0 +1,690 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import TransferModal from "@/components/nft-transfer/TransferModal"; +import { ContractType } from "@/enums"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +jest.mock("@/components/nft-transfer/TransferState", () => ({ + useTransfer: jest.fn(), +})); +jest.mock("@/hooks/useIdentity", () => ({ + useIdentity: jest.fn(), +})); +jest.mock("@/components/nft-transfer/TransferModalPfp", () => { + const MockTransferModalPfp = () =>
; + MockTransferModalPfp.displayName = "MockTransferModalPfp"; + return MockTransferModalPfp; +}); +jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => { + const MockCircleLoader = () =>
; + MockCircleLoader.displayName = "MockCircleLoader"; + + return { + __esModule: true, + default: MockCircleLoader, + CircleLoaderSize: { + MEDIUM: "MEDIUM", + }, + }; +}); +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); +jest.mock("@/helpers/server.helpers", () => ({ + getUserProfile: jest.fn(), +})); +jest.mock("wagmi", () => ({ + useAccount: jest.fn(), + usePublicClient: jest.fn(), + useWalletClient: jest.fn(), +})); +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt = "", src, ...rest }: any) => { + const { fill, ...other } = rest; + return {alt}; + }, +})); + +const mockUseTransfer = require("@/components/nft-transfer/TransferState") + .useTransfer as jest.Mock; +const mockUseIdentity = require("@/hooks/useIdentity").useIdentity as jest.Mock; +const mockCommonApiFetch = require("@/services/api/common-api") + .commonApiFetch as jest.Mock; +const mockGetUserProfile = require("@/helpers/server.helpers") + .getUserProfile as jest.Mock; +const mockUseAccount = require("wagmi").useAccount as jest.Mock; +const mockUsePublicClient = require("wagmi").usePublicClient as jest.Mock; +const mockUseWalletClient = require("wagmi").useWalletClient as jest.Mock; + +describe("TransferModal", () => { + const selectedItems = new Map([ + [ + "MEMES:10", + { + key: "MEMES:10", + contract: "0x1155", + contractType: ContractType.ERC1155, + tokenId: 10, + qty: 2, + max: 5, + title: "Memes 10", + thumbUrl: "https://example.com/thumb-10.png", + label: "MEMES #10", + }, + ], + [ + "POSTER:7", + { + key: "POSTER:7", + contract: "0x721", + contractType: ContractType.ERC721, + tokenId: 7, + qty: 1, + max: 1, + title: "Poster 7", + thumbUrl: "https://example.com/thumb-7.png", + label: "POSTER #7", + }, + ], + ]); + + const identityResult = { + profile: { + wallets: [ + { + wallet: "0x1111111111111111111111111111111111111111", + display: "Recipient", + tdh: 1, + }, + { + wallet: "0x2222222222222222222222222222222222222222", + display: "Alt", + tdh: 2, + }, + ], + }, + isLoading: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseTransfer.mockReturnValue({ + selected: selectedItems, + totalQty: 3, + }); + + mockUseIdentity.mockImplementation( + ({ handleOrWallet }: { handleOrWallet: string }) => { + if (!handleOrWallet) { + return { profile: null, isLoading: false }; + } + return identityResult; + } + ); + + mockCommonApiFetch.mockResolvedValue([ + { + profile_id: "1", + handle: "recipient", + display: "Recipient", + wallet: "0x1111111111111111111111111111111111111111", + level: 10, + tdh: 100, + pfp: null, + }, + ]); + mockGetUserProfile.mockResolvedValue({ + id: "1", + handle: "recipient", + normalised_handle: "recipient", + primary_wallet: "0x1111111111111111111111111111111111111111", + pfp: null, + tdh: 100, + level: 10, + cic: 0, + }); + + mockUseAccount.mockReturnValue({ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + + mockUsePublicClient.mockReturnValue({ + simulateContract: jest.fn(), + waitForTransactionReceipt: jest.fn(), + readContract: jest.fn(), + chain: { + blockExplorers: { default: { url: "https://explorer" } }, + }, + }); + + mockUseWalletClient.mockReturnValue({ + data: { writeContract: jest.fn() }, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const openModal = (onClose = jest.fn()) => { + const queryClient = new QueryClient(); + render( + + + + ); + }; + + const selectRecipientFlow = async () => { + const user = userEvent.setup(); + openModal(); + + const input = screen.getByPlaceholderText(/search by handle/i); + await user.type(input, "rec"); + + await waitFor(() => expect(mockCommonApiFetch).toHaveBeenCalled()); + + const recipientBtn = await screen.findByRole("button", { + name: /recipient/i, + }); + await user.click(recipientBtn); + const walletBtn = await screen.findByRole("button", { + name: /0x1111111111111111111111111111111111111111/i, + }); + await user.click(walletBtn); + }; + + it("disables transfer confirmation until a wallet is selected", () => { + openModal(); + const transferButton = screen.getByRole("button", { name: /^transfer$/i }); + expect(transferButton).toBeDisabled(); + }); + + it("submits ERC1155 (batch) and ERC721 transfers successfully and shows completion UI", async () => { + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + if (!publicClient || !walletWrapper?.data) { + throw new Error("Expected wagmi clients to be initialised"); + } + + const simulateContract = publicClient.simulateContract as jest.Mock; + const readContract = publicClient.readContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + // groupByContractAndOriginator will call readContract for 1155 origin key + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000abc" + ); + + simulateContract + .mockResolvedValueOnce({ request: { type: "1155" } }) // 1155 batch + .mockResolvedValueOnce({ request: { type: "721" } }); // 721 single + + writeContract + .mockResolvedValueOnce("0xhash1155") + .mockResolvedValueOnce("0xhash721"); + + waitForReceipt + .mockResolvedValueOnce({ status: "success" }) + .mockResolvedValueOnce({ status: "success" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + await waitFor(() => { + expect(simulateContract.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + await waitFor(() => { + expect(writeContract.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + await screen.findByText(/all 2 transactions successful/i); + + await waitFor( + () => { + expect(waitForReceipt.mock.calls.length).toBeGreaterThanOrEqual(2); + }, + { timeout: 3000 } + ); + + // Each tx card should show "Successful" (exact match; exclude header text) + const successBadges = await screen.findAllByText(/^Successful$/i); + expect(successBadges.length).toBe(2); + }); + + it("handles a single ERC721 success and shows 'Transfer Successful'", async () => { + // Override selected to a single ERC721 + const singleSelected = new Map([ + [ + "POSTER:7", + { + key: "POSTER:7", + contract: "0x721", + contractType: ContractType.ERC721, + tokenId: 7, + qty: 1, + max: 1, + title: "Poster 7", + thumbUrl: "https://example.com/thumb-7.png", + label: "POSTER #7", + }, + ], + ]); + // Ensure the mocked selection stays consistent across all renders + mockUseTransfer.mockReset(); + mockUseTransfer.mockReturnValue({ + selected: singleSelected, + totalQty: 1, + }); + + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const simulateContract = publicClient.simulateContract as jest.Mock; + const readContract = publicClient.readContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + // 721 path does not require origin read, but keep it safe + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000abc" + ); + simulateContract.mockResolvedValueOnce({ request: { type: "721" } }); + writeContract.mockResolvedValueOnce("0xhash721"); + waitForReceipt.mockResolvedValueOnce({ status: "success" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + await screen.findByText(/transfer successful/i); + const successBadges = await screen.findAllByText(/^successful$/i); + expect(successBadges.length).toBe(1); + }); + + it("handles a single ERC721 failure and shows 'Transfer Failed'", async () => { + // Override selected to a single ERC721 + const singleSelected = new Map([ + [ + "POSTER:8", + { + key: "POSTER:8", + contract: "0x721", + contractType: ContractType.ERC721, + tokenId: 8, + qty: 1, + max: 1, + title: "Poster 8", + thumbUrl: "https://example.com/thumb-8.png", + label: "POSTER #8", + }, + ], + ]); + // Ensure the mocked selection stays consistent across all renders + mockUseTransfer.mockReset(); + mockUseTransfer.mockReturnValue({ + selected: singleSelected, + totalQty: 1, + }); + + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const simulateContract = publicClient.simulateContract as jest.Mock; + const readContract = publicClient.readContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000abc" + ); + simulateContract.mockResolvedValueOnce({ request: { type: "721" } }); + writeContract.mockResolvedValueOnce("0xhash721"); + // Force failure on receipt + waitForReceipt.mockResolvedValueOnce({ status: "error" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + await screen.findByText(/transfer failed/i); + const errorBadges = await screen.findAllByText(/error/i); + expect(errorBadges.length).toBeGreaterThan(0); + }); + + it("shows mixed results summary when one succeeds and one fails", async () => { + // Use default selected (1155 + 721) + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const simulateContract = publicClient.simulateContract as jest.Mock; + const readContract = publicClient.readContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000abc" + ); + + simulateContract + .mockResolvedValueOnce({ request: { type: "1155" } }) // 1155 batch + .mockResolvedValueOnce({ request: { type: "721" } }); // 721 single + + writeContract + .mockResolvedValueOnce("0xhash1155") + .mockResolvedValueOnce("0xhash721"); + + // Make first succeed, second fail + waitForReceipt + .mockResolvedValueOnce({ status: "success" }) + .mockResolvedValueOnce({ status: "error" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + await screen.findByText(/transfer complete: 1 successful, 1 failed/i); + + const successBadges = await screen.findAllByText(/^successful$/i); + expect(successBadges.length).toBe(1); + const errorBadges = await screen.findAllByText(/error/i); + expect(errorBadges.length).toBeGreaterThan(0); + }); + + it("shows a client not ready error when no public client is available", async () => { + // Make public client undefined for this test + mockUsePublicClient.mockReset(); + mockUsePublicClient.mockReturnValue(undefined); + + await selectRecipientFlow(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + expect( + await screen.findByText(/client not ready\. please reconnect\./i) + ).toBeInTheDocument(); + }); + + it("shows an error when the wallet client is unavailable", async () => { + mockUseWalletClient.mockReturnValue({ data: undefined }); + + await selectRecipientFlow(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + expect( + await screen.findByText(/wallet not ready\. please reconnect\./i) + ).toBeInTheDocument(); + + await waitFor(() => { + const anyErrorMatches = screen.queryAllByText( + /invalid destination wallet|wallet not ready|client not ready|error/i + ); + expect(anyErrorMatches.length).toBeGreaterThan(0); + }); + }); + + it("groups ERC1155 by extension origin and sorts tokenIds in the batch label", async () => { + const singleContract = "0x1155"; + + const selected = new Map([ + [ + "MEMES:5", + { + key: "MEMES:5", + contract: singleContract, + contractType: ContractType.ERC1155, + tokenId: 5, + qty: 0, // will clamp to 1 + max: 1, + title: "Memes 5", + thumbUrl: "https://example.com/thumb-5.png", + label: "MEMES #5", + }, + ], + [ + "MEMES:3", + { + key: "MEMES:3", + contract: singleContract, + contractType: ContractType.ERC1155, + tokenId: 3, + qty: 10, // will clamp to 2 via max + max: 2, + title: "Memes 3", + thumbUrl: "https://example.com/thumb-3.png", + label: "MEMES #3", + }, + ], + ]); + + mockUseTransfer.mockReset(); + mockUseTransfer.mockReturnValue({ selected, totalQty: 2 }); + + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const readContract = publicClient.readContract as jest.Mock; + const simulateContract = publicClient.simulateContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + // Same extension for both tokens -> 1 batch group. The address is lowercased in code + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000AbC" + ); + + simulateContract.mockResolvedValueOnce({ request: { type: "1155" } }); + writeContract.mockResolvedValueOnce("0xhash1155batch"); + waitForReceipt.mockResolvedValueOnce({ status: "success" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + // Batch label should be sorted ascending by tokenId and clamped quantities: #3(x2) then #5(x1) + await screen.findByText(/MEMES #3\(x2\) - #5\(x1\)/i); + + // Origin key should reflect the extension address (lowercased) + await screen.findByText( + /Originator: ext:0x0000000000000000000000000000000000000abc/i + ); + }); + + it("creates separate ERC1155 batches when origin extensions differ", async () => { + const singleContract = "0x1155"; + + const selected = new Map([ + [ + "MEMES:1", + { + key: "MEMES:1", + contract: singleContract, + contractType: ContractType.ERC1155, + tokenId: 1, + qty: 1, + max: 5, + title: "Memes 1", + thumbUrl: "https://example.com/thumb-1.png", + label: "MEMES #1", + }, + ], + [ + "MEMES:2", + { + key: "MEMES:2", + contract: singleContract, + contractType: ContractType.ERC1155, + tokenId: 2, + qty: 1, + max: 5, + title: "Memes 2", + thumbUrl: "https://example.com/thumb-2.png", + label: "MEMES #2", + }, + ], + ]); + + mockUseTransfer.mockReset(); + mockUseTransfer.mockReturnValue({ selected, totalQty: 2 }); + + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const readContract = publicClient.readContract as jest.Mock; + const simulateContract = publicClient.simulateContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + // Different extensions -> two separate groups/batches + readContract + .mockResolvedValueOnce("0x0000000000000000000000000000000000000abc") + .mockResolvedValueOnce("0x0000000000000000000000000000000000000def"); + + simulateContract + .mockResolvedValueOnce({ request: { type: "1155" } }) + .mockResolvedValueOnce({ request: { type: "1155" } }); + + writeContract + .mockResolvedValueOnce("0xhash1155a") + .mockResolvedValueOnce("0xhash1155b"); + + waitForReceipt + .mockResolvedValueOnce({ status: "success" }) + .mockResolvedValueOnce({ status: "success" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + // Two cards rendered — one per origin group + const cards = await screen.findAllByText(/Originator: ext:/i); + expect(cards.length).toBe(2); + }); + + it("creates individual entries for ERC721 and uses 'erc721' origin key", async () => { + const selected = new Map([ + [ + "POSTER:9", + { + key: "POSTER:9", + contract: "0x721", + contractType: ContractType.ERC721, + tokenId: 9, + qty: 1, + max: 1, + title: "Poster 9", + thumbUrl: "https://example.com/thumb-9.png", + label: "POSTER #9", + }, + ], + [ + "POSTER:7", + { + key: "POSTER:7", + contract: "0x721", + contractType: ContractType.ERC721, + tokenId: 7, + qty: 1, + max: 1, + title: "Poster 7", + thumbUrl: "https://example.com/thumb-7.png", + label: "POSTER #7", + }, + ], + ]); + + mockUseTransfer.mockReset(); + mockUseTransfer.mockReturnValue({ selected, totalQty: 2 }); + + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const simulateContract = publicClient.simulateContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + simulateContract + .mockResolvedValueOnce({ request: { type: "721" } }) + .mockResolvedValueOnce({ request: { type: "721" } }); + + writeContract + .mockResolvedValueOnce("0xhash721a") + .mockResolvedValueOnce("0xhash721b"); + + waitForReceipt + .mockResolvedValueOnce({ status: "success" }) + .mockResolvedValueOnce({ status: "success" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + // Two separate entries should render with labels for each token + await screen.findByText(/POSTER #7/i); + await screen.findByText(/POSTER #9/i); + + // And origin should be erc721 + const erc721Origins = await screen.findAllByText(/Originator: erc721/i); + expect(erc721Origins.length).toBe(2); + }); + + it("displays warning banner when transactions are pending", async () => { + await selectRecipientFlow(); + + const publicClient = mockUsePublicClient.mock.results.at(-1)?.value; + const walletWrapper = mockUseWalletClient.mock.results.at(-1)?.value; + + const simulateContract = publicClient.simulateContract as jest.Mock; + const readContract = publicClient.readContract as jest.Mock; + const writeContract = walletWrapper.data.writeContract as jest.Mock; + const waitForReceipt = publicClient.waitForTransactionReceipt as jest.Mock; + + readContract.mockResolvedValue( + "0x0000000000000000000000000000000000000abc" + ); + + simulateContract + .mockResolvedValueOnce({ request: { type: "1155" } }) + .mockResolvedValueOnce({ request: { type: "721" } }); + + writeContract + .mockResolvedValueOnce("0xhash1155") + .mockResolvedValueOnce("0xhash721"); + + waitForReceipt.mockImplementation(() => new Promise(() => {})); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /^transfer$/i })); + + await waitFor(() => { + expect( + screen.getByText( + /Double-check the recipient address and token details before/i + ) + ).toBeInTheDocument(); + }); + + expect( + screen.getByText( + /NFT transfers are irreversible once submitted on-chain/i + ) + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/nft-transfer/TransferModalPfp.test.tsx b/__tests__/components/nft-transfer/TransferModalPfp.test.tsx new file mode 100644 index 0000000000..8f3f8f4e20 --- /dev/null +++ b/__tests__/components/nft-transfer/TransferModalPfp.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, waitFor } from "@testing-library/react"; + +import TransferModalPfp from "@/components/nft-transfer/TransferModalPfp"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mockUseResolvedIpfsUrl = jest.fn(); +jest.mock("@/hooks/useResolvedIpfsUrl", () => ({ + useResolvedIpfsUrl: (...args: unknown[]) => mockUseResolvedIpfsUrl(...args), +})); + +const renderTransferModalPfp = (level: number, src: string | null) => { + const queryClient = new QueryClient(); + return render( + + + + ); +}; + +describe("TransferModalPfp", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders a level badge while the image is loading", () => { + mockUseResolvedIpfsUrl.mockReturnValue({ data: null }); + + renderTransferModalPfp(82, null); + + expect(screen.getByText("82")).toBeInTheDocument(); + }); + + it("loads and displays the resolved image", async () => { + mockUseResolvedIpfsUrl.mockReturnValue({ + data: "https://cdn.test/pfp.png", + }); + + renderTransferModalPfp(45, "ipfs://hash"); + + await waitFor(() => + expect(screen.getByAltText("Profile")).toBeInTheDocument() + ); + }); +}); diff --git a/__tests__/components/nft-transfer/TransferPanel.test.tsx b/__tests__/components/nft-transfer/TransferPanel.test.tsx new file mode 100644 index 0000000000..e323f6a969 --- /dev/null +++ b/__tests__/components/nft-transfer/TransferPanel.test.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import TransferPanel from "@/components/nft-transfer/TransferPanel"; + +jest.mock("@/components/nft-transfer/TransferState", () => ({ + useTransfer: jest.fn(), +})); +jest.mock("@/components/nft-transfer/TransferModal", () => ({ + __esModule: true, + default: jest.fn(() => null), +})); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ isConnected: true })), +})); +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt = "", src, ...rest }: any) => { + const { fill, ...other } = rest; + return {alt}; + }, +})); + +const mockUseTransfer = require("@/components/nft-transfer/TransferState") + .useTransfer as jest.Mock; +const mockTransferModal = require("@/components/nft-transfer/TransferModal") + .default as jest.Mock; + +describe("TransferPanel", () => { + beforeEach(() => { + jest.clearAllMocks(); + (globalThis.HTMLElement.prototype as any).scrollTo = jest.fn(); + }); + + it("renders nothing when transfer is disabled", () => { + mockUseTransfer.mockReturnValue({ + enabled: false, + selected: new Map(), + clear: jest.fn(), + setEnabled: jest.fn(), + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows empty state and disables continue button without items", () => { + const clear = jest.fn(); + const setEnabled = jest.fn(); + mockUseTransfer.mockReturnValue({ + enabled: true, + selected: new Map(), + count: 0, + totalQty: 0, + clear, + setEnabled, + }); + + render(); + + expect(screen.getByText(/select some nfts to transfer/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /continue/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(setEnabled).toHaveBeenCalledWith(false); + expect(clear).toHaveBeenCalled(); + }); + + it("lists selected items and clears them when requested", () => { + const clear = jest.fn(); + const setEnabled = jest.fn(); + const decQty = jest.fn(); + const incQty = jest.fn(); + const unselect = jest.fn(); + + const selected = new Map([ + [ + "MEMES:1", + { + key: "MEMES:1", + title: "Card 1", + thumbUrl: "https://example.com/thumb.png", + qty: 2, + max: 3, + }, + ], + ]); + + mockUseTransfer.mockReturnValue({ + enabled: true, + selected, + count: 1, + totalQty: 1, + clear, + setEnabled, + incQty, + decQty, + unselect, + }); + + render(); + + const expandButton = screen.getByLabelText("Expand panel"); + fireEvent.click(expandButton); + + expect(screen.getByText("Card 1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /remove/i })); + expect(unselect).toHaveBeenCalledWith("MEMES:1"); + + fireEvent.click(screen.getByRole("button", { name: /continue/i })); + expect(mockTransferModal).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/nft-transfer/TransferSingle.test.tsx b/__tests__/components/nft-transfer/TransferSingle.test.tsx new file mode 100644 index 0000000000..f5526f52ea --- /dev/null +++ b/__tests__/components/nft-transfer/TransferSingle.test.tsx @@ -0,0 +1,257 @@ +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +jest.mock("@/styles/Home.module.scss", () => ({ shadowBox: "shadowBox" })); + +jest.mock("@/enums", () => ({ + ContractType: { ERC721: "ERC721", ERC1155: "ERC1155" }, +})); + +jest.mock("@/entities/IProfile", () => ({ + COLLECTED_COLLECTION_TYPE_TO_CONTRACT: { + MEMES: "0xCONTRACT_MEMES", + NEXTGEN: "0xCONTRACT_NEXTGEN", + }, + COLLECTED_COLLECTION_TYPE_TO_CONTRACT_TYPE: { + MEMES: "ERC721", + NEXTGEN: "ERC1155", + }, + CollectedCollectionType: {} as any, +})); + +let mockSelected = new Map(); +const mockFns = { + select: jest.fn(), + unselect: jest.fn(), + incQty: jest.fn(), + decQty: jest.fn(), + setQty: jest.fn(), + toggleSelect: jest.fn(), + clear: jest.fn(), + setEnabled: jest.fn(), +}; + +jest.mock("@/components/nft-transfer/TransferState", () => { + return { + __esModule: true, + TransferProvider: ({ children }: any) => <>{children}, + useTransfer: () => ({ + selected: mockSelected, + select: mockFns.select, + unselect: mockFns.unselect, + incQty: mockFns.incQty, + decQty: mockFns.decQty, + setQty: mockFns.setQty, + toggleSelect: mockFns.toggleSelect, + clear: mockFns.clear, + setEnabled: mockFns.setEnabled, + totalQty: 1, + count: 1, + }), + buildTransferKey: ({ collection, tokenId }: any) => + `${collection}:${tokenId}`, + }; +}); + +jest.mock("@/components/nft-transfer/TransferModal", () => ({ + __esModule: true, + default: ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + ), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => { + const state = { isConnected: true, seizeConnectOpen: false }; + const seizeConnect = jest.fn(() => { + state.seizeConnectOpen = true; + }); + return { + __esModule: true, + useSeizeConnectContext: () => ({ + isConnected: state.isConnected, + seizeConnect, + seizeConnectOpen: state.seizeConnectOpen, + }), + __state: state, + __seizeConnect: seizeConnect, + }; +}); + +jest.mock("@/hooks/useDeviceInfo", () => ({ + __esModule: true, + default: jest.fn(() => ({ isMobileDevice: false })), +})); + +import TransferSingle from "@/components/nft-transfer/TransferSingle"; +import { ContractType } from "@/enums"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; + +const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction< + typeof useDeviceInfo +>; + +const resetAll = () => { + mockFns.select.mockReset(); + mockFns.unselect.mockReset(); + mockFns.incQty.mockReset(); + mockFns.decQty.mockReset(); + mockFns.setQty.mockReset(); + mockFns.toggleSelect.mockReset(); + mockFns.clear.mockReset(); + mockFns.setEnabled.mockReset(); + mockSelected = new Map(); + const connectMod = require("@/components/auth/SeizeConnectContext"); + connectMod.__state.isConnected = true; + connectMod.__state.seizeConnectOpen = false; + connectMod.__seizeConnect.mockReset(); + useDeviceInfoMock.mockReturnValue({ isMobileDevice: false } as any); +}; + +const baseProps = { + collectionType: "MEMES" as any, + contractType: ContractType.ERC721, + contract: "0xCONTRACT_MEMES", + tokenId: 1, + title: "The Memes #1", + max: 1, + thumbUrl: "https://example.com/img.jpg", +}; + +describe("TransferSingle", () => { + beforeEach(resetAll); + afterEach(cleanup); + + test("renders basic structure (ERC721) and button reads 'Transfer'", () => { + render(); + expect(screen.getByTestId("transfer-single")).toBeInTheDocument(); + expect(screen.getByTestId("transfer-single-submit")).toHaveTextContent( + "Transfer" + ); + }); + + test("returns null when rendered on mobile device", () => { + useDeviceInfoMock.mockReturnValue({ isMobileDevice: true } as any); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("calls select on mount and unselect on unmount", () => { + const { unmount } = render(); + const expectedKey = `${baseProps.collectionType}:${baseProps.tokenId}`; + expect(mockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + key: expectedKey, + contract: "0xCONTRACT_MEMES", + contractType: "ERC721", + }) + ); + unmount(); + expect(mockFns.unselect).toHaveBeenCalledWith(expectedKey); + }); + + test("hides +/- controls when max = 1", () => { + render(); + expect( + screen.queryByTestId("transfer-single-minus") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("transfer-single-plus") + ).not.toBeInTheDocument(); + }); + + test("renders +/- controls when max > 1 and handles bounds", () => { + const props = { ...baseProps, max: 5, contractType: ContractType.ERC1155 }; + const key = `${props.collectionType}:${props.tokenId}`; + mockSelected = new Map([[key, { qty: 1, max: 5 }]]); + render(); + const minus = screen.getByTestId("transfer-single-minus"); + const plus = screen.getByTestId("transfer-single-plus"); + expect(minus).toBeDisabled(); + expect(plus).toBeEnabled(); + cleanup(); + resetAll(); + mockSelected = new Map([[key, { qty: 5, max: 5 }]]); + render(); + const minus2 = screen.getByTestId("transfer-single-minus"); + const plus2 = screen.getByTestId("transfer-single-plus"); + expect(minus2).toBeEnabled(); + expect(plus2).toBeDisabled(); + }); + + test("clicking + and - triggers incQty and decQty", () => { + const props = { ...baseProps, max: 3, contractType: ContractType.ERC1155 }; + const key = `${props.collectionType}:${props.tokenId}`; + mockSelected = new Map([[key, { qty: 2, max: 3 }]]); + render(); + const minus = screen.getByTestId("transfer-single-minus"); + const plus = screen.getByTestId("transfer-single-plus"); + fireEvent.click(minus); + fireEvent.click(plus); + expect(mockFns.decQty).toHaveBeenCalledWith(key); + expect(mockFns.incQty).toHaveBeenCalledWith(key); + }); + + test("button label for ERC1155 reflects selected qty (copies)", () => { + const props = { ...baseProps, max: 10, contractType: ContractType.ERC1155 }; + const key = `${props.collectionType}:${props.tokenId}`; + mockSelected = new Map([[key, { qty: 3, max: 10 }]]); + render(); + expect(screen.getByTestId("transfer-single-submit")).toHaveTextContent( + "Transfer 3 copies" + ); + }); + + test("opens modal immediately when already connected", () => { + const connectMod = require("@/components/auth/SeizeConnectContext"); + connectMod.__state.isConnected = true; + render(); + const modal = screen.getByTestId("transfer-modal"); + expect(modal).toHaveAttribute("data-open", "false"); + fireEvent.click(screen.getByTestId("transfer-single-submit")); + expect(screen.getByTestId("transfer-modal")).toHaveAttribute( + "data-open", + "true" + ); + }); + + test("connect-first flow: click triggers seizeConnect, modal opens after connection", () => { + const connectMod = require("@/components/auth/SeizeConnectContext"); + connectMod.__state.isConnected = false; + connectMod.__state.seizeConnectOpen = false; + const { rerender } = render(); + const modal = screen.getByTestId("transfer-modal"); + expect(modal).toHaveAttribute("data-open", "false"); + fireEvent.click(screen.getByTestId("transfer-single-submit")); + expect(connectMod.__seizeConnect).toHaveBeenCalled(); + expect(screen.getByTestId("transfer-modal")).toHaveAttribute( + "data-open", + "false" + ); + connectMod.__state.isConnected = true; + connectMod.__state.seizeConnectOpen = false; + rerender(); + expect(screen.getByTestId("transfer-modal")).toHaveAttribute( + "data-open", + "true" + ); + }); + + test("rerender with new tokenId updates select key", () => { + const { rerender } = render(); + const firstKey = `${baseProps.collectionType}:${baseProps.tokenId}`; + expect(mockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ key: firstKey }) + ); + rerender(); + expect(mockFns.select).toHaveBeenLastCalledWith( + expect.objectContaining({ key: `${baseProps.collectionType}:99` }) + ); + }); +}); diff --git a/__tests__/components/nft-transfer/TransferState.test.tsx b/__tests__/components/nft-transfer/TransferState.test.tsx new file mode 100644 index 0000000000..3c27567ac0 --- /dev/null +++ b/__tests__/components/nft-transfer/TransferState.test.tsx @@ -0,0 +1,96 @@ +import { act, renderHook } from "@testing-library/react"; + +import { + buildTransferKey, + TransferProvider, + useTransfer, +} from "@/components/nft-transfer/TransferState"; +import { ContractType } from "@/enums"; + +function renderTransfer() { + return renderHook(() => useTransfer(), { wrapper: TransferProvider }); +} + +describe("TransferState", () => { + it("initialises with transfer disabled and no selections", () => { + const { result } = renderTransfer(); + + expect(result.current.enabled).toBe(false); + expect(result.current.count).toBe(0); + expect(result.current.totalQty).toBe(0); + expect(result.current.selected.size).toBe(0); + }); + + it("allows selecting, updating quantities and clearing items", () => { + const { result } = renderTransfer(); + + const key = buildTransferKey({ collection: "MEMES", tokenId: 1 }); + act(() => { + result.current.setEnabled(true); + result.current.select({ + key, + contract: "0xabc", + contractType: ContractType.ERC721, + tokenId: 1, + title: "Test", + thumbUrl: "thumb", + qty: 5, + max: 2, + }); + }); + + expect(result.current.enabled).toBe(true); + expect(result.current.count).toBe(1); + expect(result.current.totalQty).toBe(2); + + act(() => { + result.current.incQty(key); + }); + expect(result.current.selected.get(key)?.qty).toBe(2); + + act(() => { + result.current.decQty(key); + result.current.decQty(key); + }); + expect(result.current.selected.get(key)?.qty).toBe(1); + + act(() => { + result.current.clear(); + }); + expect(result.current.count).toBe(0); + expect(result.current.totalQty).toBe(0); + }); + + it("toggleSelect adds new item first and removes if toggled again", () => { + const { result } = renderTransfer(); + + const first = { + key: buildTransferKey({ collection: "A", tokenId: 1 }), + contract: "0x1", + contractType: ContractType.ERC721, + tokenId: 1, + }; + const second = { + key: buildTransferKey({ collection: "B", tokenId: 2 }), + contract: "0x2", + contractType: ContractType.ERC1155, + tokenId: 2, + max: 10, + }; + + act(() => { + result.current.toggleSelect(first); + result.current.toggleSelect(second); + }); + + const keys = Array.from(result.current.selected.keys()); + expect(keys[0]).toBe(second.key); + expect(keys).toContain(first.key); + + act(() => { + result.current.toggleSelect(second); + }); + expect(result.current.selected.has(second.key)).toBe(false); + expect(result.current.count).toBe(1); + }); +}); diff --git a/__tests__/components/nft-transfer/TransferToggle.test.tsx b/__tests__/components/nft-transfer/TransferToggle.test.tsx new file mode 100644 index 0000000000..b6c7b0e075 --- /dev/null +++ b/__tests__/components/nft-transfer/TransferToggle.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import TransferToggle from "@/components/nft-transfer/TransferToggle"; + +jest.mock("@/components/nft-transfer/TransferState", () => ({ + useTransfer: jest.fn(), +})); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + +const mockUseTransfer = require("@/components/nft-transfer/TransferState") + .useTransfer as jest.Mock; +const mockUseSeize = require("@/components/auth/SeizeConnectContext") + .useSeizeConnectContext as jest.Mock; + +describe("TransferToggle", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("requests connection before enabling transfer", () => { + const transferState = { + enabled: false, + setEnabled: jest.fn(), + clear: jest.fn(), + toggle: jest.fn(), + }; + const seizeState = { + isConnected: false, + seizeConnect: jest.fn(), + seizeConnectOpen: false, + }; + + mockUseTransfer.mockImplementation(() => transferState); + mockUseSeize.mockImplementation(() => seizeState); + + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /transfer/i })); + expect(seizeState.seizeConnect).toHaveBeenCalled(); + expect(transferState.setEnabled).not.toHaveBeenCalledWith(true); + + seizeState.isConnected = true; + rerender(); + + expect(transferState.setEnabled).toHaveBeenCalledWith(true); + }); + + it("disables transfer and clears selections when toggled off", () => { + const transferState = { + enabled: true, + setEnabled: jest.fn(), + clear: jest.fn(), + toggle: jest.fn(), + }; + + mockUseTransfer.mockImplementation(() => transferState); + mockUseSeize.mockImplementation(() => ({ + isConnected: true, + seizeConnect: jest.fn(), + seizeConnectOpen: false, + })); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /exit transfer/i })); + expect(transferState.setEnabled).toHaveBeenCalledWith(false); + expect(transferState.clear).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/the-memes/MemePageYourCards.test.tsx b/__tests__/components/the-memes/MemePageYourCards.test.tsx index ad98be94b0..ed2de01960 100644 --- a/__tests__/components/the-memes/MemePageYourCards.test.tsx +++ b/__tests__/components/the-memes/MemePageYourCards.test.tsx @@ -1,40 +1,78 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { MemePageYourCardsRightMenu, MemePageYourCardsSubMenu } from '@/components/the-memes/MemePageYourCards'; +import { SeizeConnectProvider } from "@/components/auth/SeizeConnectContext"; +import { + MemePageYourCardsRightMenu, + MemePageYourCardsSubMenu, +} from "@/components/the-memes/MemePageYourCards"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { createConfig, http, WagmiProvider } from "wagmi"; +import { mainnet } from "wagmi/chains"; + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isAuthenticated: false, + seizeConnect: jest.fn(), + seizeAcceptConnection: jest.fn(), + address: undefined, + hasInitializationError: false, + initializationError: null, + })), + SeizeConnectProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); const mockNFT = { id: 123, - name: 'Test Meme', - artist: 'Test Artist' + name: "Test Meme", + artist: "Test Artist", } as any; const mockConsolidatedTDH = { - wallets: ['0x123'], + wallets: ["0x123"], balance: 5, tdh: 1000, - dense_rank_balance: 10 + dense_rank_balance: 10, } as any; const mockTransactions = [ { - transaction_date: new Date('2023-01-01'), - from_address: '0x0000000000000000000000000000000000000000', - to_address: '0x456', + transaction_date: new Date("2023-01-01"), + from_address: "0x0000000000000000000000000000000000000000", + to_address: "0x456", value: 0, - token_count: 1 + token_count: 1, }, { - transaction_date: new Date('2023-01-02'), - from_address: '0x789', - to_address: '0x456', + transaction_date: new Date("2023-01-02"), + from_address: "0x789", + to_address: "0x456", value: 1.5, - token_count: 2 - } + token_count: 2, + }, ] as any[]; -describe('MemePageYourCardsRightMenu', () => { - describe('when show is false', () => { - it('should render empty fragment', () => { +const renderMemePageYourCardsWithProviders = (component: React.ReactNode) => { + const queryClient = new QueryClient(); + const mockWagmiConfig = createConfig({ + chains: [mainnet], + transports: { + [mainnet.id]: http(), + }, + }); + return render( + + + {component} + + + ); +}; + +describe("MemePageYourCardsRightMenu", () => { + describe("when show is false", () => { + it("should render empty fragment", () => { const { container } = render( { }); }); - describe('when show is true', () => { - describe('when no wallets connected', () => { - it('should display connect wallet message', () => { + describe("when show is true", () => { + describe("when no wallets connected", () => { + it("should display connect wallet message", () => { render( { myRank={undefined} /> ); - expect(screen.getByText('Connect your wallet to view your cards.')).toBeInTheDocument(); + expect( + screen.getByText("Connect your wallet to view your cards.") + ).toBeInTheDocument(); }); }); - describe('when wallets connected but no NFT balance', () => { - it('should display no ownership message', () => { + describe("when wallets connected but no NFT balance", () => { + it("should display no ownership message", () => { render( { myRank={undefined} /> ); - expect(screen.getByText("You don't own any editions of Card 123")).toBeInTheDocument(); + expect( + screen.getByText("You don't own any editions of Card 123") + ).toBeInTheDocument(); }); }); - describe('when user owns cards', () => { - it('should display cards count and overview', () => { - render( + describe("when user owns cards", () => { + it("should display cards count and overview", () => { + renderMemePageYourCardsWithProviders( { /> ); - expect(screen.getByText('x3')).toBeInTheDocument(); - expect(screen.getByText('Overview')).toBeInTheDocument(); - expect(screen.getByText('1,500')).toBeInTheDocument(); - expect(screen.getByText('#5')).toBeInTheDocument(); + expect(screen.getByText("x3")).toBeInTheDocument(); + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("1,500")).toBeInTheDocument(); + expect(screen.getByText("#5")).toBeInTheDocument(); }); - it('should display first acquisition date', () => { - render( + it("should display first acquisition date", () => { + renderMemePageYourCardsWithProviders( { expect(screen.getByText(/First acquired/)).toBeInTheDocument(); }); - it('should display no TDH message when no TDH data', () => { - render( + it("should display no TDH message when no TDH data", () => { + renderMemePageYourCardsWithProviders( { /> ); - expect(screen.getByText('No TDH accrued')).toBeInTheDocument(); + expect(screen.getByText("No TDH accrued")).toBeInTheDocument(); }); - it('should categorize airdropped cards', () => { - render( + it("should categorize airdropped cards", () => { + renderMemePageYourCardsWithProviders( { /> ); - expect(screen.getByText('1 card airdropped')).toBeInTheDocument(); + expect(screen.getByText("1 card airdropped")).toBeInTheDocument(); }); - it('should categorize bought cards', () => { - render( + it("should categorize bought cards", () => { + renderMemePageYourCardsWithProviders( { /> ); - expect(screen.getByText('2 cards bought for 1.5 ETH')).toBeInTheDocument(); + expect( + screen.getByText("2 cards bought for 1.5 ETH") + ).toBeInTheDocument(); }); }); }); }); -describe('MemePageYourCardsSubMenu', () => { - describe('when show is false', () => { - it('should render empty fragment', () => { +describe("MemePageYourCardsSubMenu", () => { + describe("when show is false", () => { + it("should render empty fragment", () => { const { container } = render( - + ); expect(container.firstChild).toBeNull(); }); }); - describe('when show is true', () => { - describe('when no transactions', () => { - it('should not display transaction history section', () => { - render( - - ); - expect(screen.queryByText('Your Transaction History')).not.toBeInTheDocument(); + describe("when show is true", () => { + describe("when no transactions", () => { + it("should not display transaction history section", () => { + render(); + expect( + screen.queryByText("Your Transaction History") + ).not.toBeInTheDocument(); }); }); - describe('when transactions exist', () => { - it('should display transaction history section', () => { - const mockTxsWithFullData = mockTransactions.map(tx => ({ + describe("when transactions exist", () => { + it("should display transaction history section", () => { + const mockTxsWithFullData = mockTransactions.map((tx) => ({ ...tx, gas: 21000, gas_price: 20000000000, gas_gwei: 20, gas_price_gwei: 20, - contract: '0x123', + contract: "0x123", token_id: 1, - transaction: '0xabc', + transaction: "0xabc", block: 12345678, created_at: new Date(), from_display: undefined, to_display: undefined, - royalties: 0 + royalties: 0, })); render( @@ -230,8 +268,10 @@ describe('MemePageYourCardsSubMenu', () => { transactions={mockTxsWithFullData} /> ); - expect(screen.getByText('Your Transaction History')).toBeInTheDocument(); + expect( + screen.getByText("Your Transaction History") + ).toBeInTheDocument(); }); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/components/user/collected/UserPageCollected.test.tsx b/__tests__/components/user/collected/UserPageCollected.test.tsx index 2ea34b7a81..a40ce27c3a 100644 --- a/__tests__/components/user/collected/UserPageCollected.test.tsx +++ b/__tests__/components/user/collected/UserPageCollected.test.tsx @@ -1,3 +1,4 @@ +import { TransferProvider } from "@/components/nft-transfer/TransferState"; import UserPageCollected from "@/components/user/collected/UserPageCollected"; import { CollectedCollectionType } from "@/entities/IProfile"; import { useQuery } from "@tanstack/react-query"; @@ -54,6 +55,14 @@ jest.mock( } ); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ address: "0x123" })), +})); + +const renderWithTransferProvider = (component: React.ReactNode) => { + return render({component}); +}; + describe("UserPageCollected", () => { const useRouterMock = useRouter as jest.Mock; const usePathnameMock = usePathname as jest.Mock; @@ -113,7 +122,7 @@ describe("UserPageCollected", () => { data: undefined, }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("loading")).toBeInTheDocument(); }); @@ -133,7 +142,7 @@ describe("UserPageCollected", () => { data: mockData, }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("filters")).toBeInTheDocument(); expect(screen.getByTestId("cards")).toBeInTheDocument(); @@ -149,7 +158,7 @@ describe("UserPageCollected", () => { return null; }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("filters")).toHaveAttribute( "data-collection", @@ -163,7 +172,7 @@ describe("UserPageCollected", () => { return null; }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("filters")).toBeInTheDocument(); }); @@ -174,7 +183,7 @@ describe("UserPageCollected", () => { return null; }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("filters")).toBeInTheDocument(); }); @@ -185,7 +194,7 @@ describe("UserPageCollected", () => { return null; }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("filters")).toBeInTheDocument(); }); @@ -207,12 +216,12 @@ describe("UserPageCollected", () => { data: mockData, }); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("cards")).toHaveAttribute("data-page", "2"); }); it("shows data row for collections that support it", () => { - render(); + renderWithTransferProvider(); expect(screen.getByTestId("cards")).toHaveAttribute( "data-show-data-row", "true" @@ -223,7 +232,7 @@ describe("UserPageCollected", () => { mockSearchParams.get.mockImplementation((k: string) => k === "collection" ? "memelab" : null ); - render(); + renderWithTransferProvider(); expect(screen.getByTestId("cards")).toHaveAttribute( "data-show-data-row", "false" @@ -243,7 +252,7 @@ describe("UserPageCollected", () => { data: mockData, }); - render(); + renderWithTransferProvider(); await waitFor(() => { expect(screen.getByTestId("cards")).toHaveAttribute( @@ -254,7 +263,7 @@ describe("UserPageCollected", () => { }); it("uses profile handle when no address filter provided", () => { - render(); + renderWithTransferProvider(); expect(useQueryMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -276,7 +285,7 @@ describe("UserPageCollected", () => { return null; }); - render(); + renderWithTransferProvider(); expect(useQueryMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/__tests__/components/user/collected/cards/UserPageCollectedCard.test.tsx b/__tests__/components/user/collected/cards/UserPageCollectedCard.test.tsx index b63b954e41..e5fcd0eb0b 100644 --- a/__tests__/components/user/collected/cards/UserPageCollectedCard.test.tsx +++ b/__tests__/components/user/collected/cards/UserPageCollectedCard.test.tsx @@ -1,31 +1,392 @@ -import { render, screen } from '@testing-library/react'; -import UserPageCollectedCard from '@/components/user/collected/cards/UserPageCollectedCard'; -import { CollectedCollectionType } from '@/entities/IProfile'; +import UserPageCollectedCard from "@/components/user/collected/cards/UserPageCollectedCard"; +import { CollectedCard, CollectedCollectionType } from "@/entities/IProfile"; +import { ContractType } from "@/enums"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -const memeCard = { +const memeCard: CollectedCard = { collection: CollectedCollectionType.MEMES, token_id: 1, - token_name: 'Card', - img: '/img.png', + token_name: "Card", + img: "/img.png", tdh: 2, rank: 3, seized_count: 1, + szn: null, }; -describe('UserPageCollectedCard', () => { - it('shows data row and seized count for memes', () => { - render(); - expect(screen.getByText('#1')).toBeInTheDocument(); - expect(screen.getByText('TDH')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('Rank')).toBeInTheDocument(); - expect(screen.getByText('3')).toBeInTheDocument(); - expect(screen.getByText('1x')).toBeInTheDocument(); - }); - - it('handles memelab collection', () => { - const card = { ...memeCard, collection: CollectedCollectionType.MEMELAB, seized_count: 0 }; - render(); - expect(screen.getAllByText('N/A').length).toBe(2); +describe("UserPageCollectedCard", () => { + it("shows data row and seized count for memes", () => { + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("TDH")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("Rank")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect( + screen.getByText((content, element) => { + const text = element?.textContent?.replaceAll(" ", "").trim() || ""; + return /^1\s*x$/.test(text); + }) + ).toBeInTheDocument(); + }); + + it("handles memelab collection", () => { + const card: CollectedCard = { + ...memeCard, + collection: CollectedCollectionType.MEMELAB, + seized_count: 0, + szn: null, + }; + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={0} + /> + ); + expect(screen.getAllByText("N/A").length).toBe(2); + }); + + it("calls onToggle when selection button is clicked in select mode", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + + render( + {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const selectButton = screen.getByLabelText("Select"); + await user.click(selectButton); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle when card is clicked in select mode", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + + render( + {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const card = screen.getByRole("button", { + name: "Select NFT for transfer", + }); + await user.click(card); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle when Enter key is pressed on card in select mode", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + + render( + {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const card = screen.getByRole("button", { + name: "Select NFT for transfer", + }); + card.focus(); + await user.keyboard("{Enter}"); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle when Space key is pressed on card in select mode", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + + render( + {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const card = screen.getByRole("button", { + name: "Select NFT for transfer", + }); + card.focus(); + await user.keyboard(" "); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onIncQty when increment button is clicked for ERC1155", async () => { + const user = userEvent.setup(); + const mockOnIncQty = jest.fn(); + + render( + {}} + onIncQty={mockOnIncQty} + onDecQty={() => {}} + copiesMax={5} + qtySelected={1} + /> + ); + + const incButton = screen.getByLabelText("Increase quantity"); + await user.click(incButton); + + expect(mockOnIncQty).toHaveBeenCalledTimes(1); + }); + + it("calls onDecQty when decrement button is clicked for ERC1155", async () => { + const user = userEvent.setup(); + const mockOnDecQty = jest.fn(); + + render( + {}} + onIncQty={() => {}} + onDecQty={mockOnDecQty} + copiesMax={5} + qtySelected={2} + /> + ); + + const decButton = screen.getByLabelText("Decrease quantity"); + await user.click(decButton); + + expect(mockOnDecQty).toHaveBeenCalledTimes(1); + }); + + it("disables increment button when qtySelected equals copiesMax", () => { + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={3} + qtySelected={3} + /> + ); + + const incButton = screen.getByLabelText("Increase quantity"); + expect(incButton).toBeDisabled(); + }); + + it("calls onToggle when deselect button is clicked for ERC721", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + + render( + {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const deselectButton = screen.getByLabelText("Deselect"); + await user.click(deselectButton); + + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("shows 'Not owned by your connected wallet' when copiesMax is 0", () => { + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={0} + /> + ); + + expect( + screen.getByText("Not owned by your connected wallet") + ).toBeInTheDocument(); + }); + + it("shows loading state when isTransferLoading is true", () => { + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={0} + isTransferLoading={true} + /> + ); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + }); + + it("renders as Link when interactiveMode is not select", () => { + render( + {}} + onIncQty={() => {}} + onDecQty={() => {}} + copiesMax={1} + /> + ); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/the-memes/1"); + }); + + it("calls onToggle and onDecQty when decrementing from qtySelected 1 for ERC1155", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + const mockOnDecQty = jest.fn(); + + render( + {}} + onDecQty={mockOnDecQty} + copiesMax={5} + qtySelected={1} + /> + ); + + const decButton = screen.getByLabelText("Decrease quantity"); + await user.click(decButton); + + expect(mockOnDecQty).toHaveBeenCalledTimes(1); + expect(mockOnToggle).toHaveBeenCalledTimes(1); + }); + + it("does not call onToggle when decrementing from qtySelected > 1 for ERC1155", async () => { + const user = userEvent.setup(); + const mockOnToggle = jest.fn(); + const mockOnDecQty = jest.fn(); + + render( + {}} + onDecQty={mockOnDecQty} + copiesMax={5} + qtySelected={3} + /> + ); + + const decButton = screen.getByLabelText("Decrease quantity"); + await user.click(decButton); + + expect(mockOnDecQty).toHaveBeenCalledTimes(1); + expect(mockOnToggle).not.toHaveBeenCalled(); + }); + + it("does not call onIncQty when qtySelected equals copiesMax", async () => { + const user = userEvent.setup(); + const mockOnIncQty = jest.fn(); + + render( + {}} + onIncQty={mockOnIncQty} + onDecQty={() => {}} + copiesMax={3} + qtySelected={3} + /> + ); + + const incButton = screen.getByLabelText("Increase quantity"); + await user.click(incButton); + + expect(mockOnIncQty).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/components/user/collected/cards/UserPageCollectedCards.test.tsx b/__tests__/components/user/collected/cards/UserPageCollectedCards.test.tsx index 56a36bd2c1..bee406020f 100644 --- a/__tests__/components/user/collected/cards/UserPageCollectedCards.test.tsx +++ b/__tests__/components/user/collected/cards/UserPageCollectedCards.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from "@testing-library/react"; -import React from "react"; +import { TransferProvider } from "@/components/nft-transfer/TransferState"; import UserPageCollectedCards from "@/components/user/collected/cards/UserPageCollectedCards"; import { CollectedCollectionType, @@ -7,26 +6,44 @@ import { CollectionSort, } from "@/entities/IProfile"; import { SortDirection } from "@/entities/ISort"; +import { render, screen } from "@testing-library/react"; +import React from "react"; -jest.mock("@/components/user/collected/cards/UserPageCollectedCard", () => (props: any) => ( -
- {props.card.token_id} -
-)); - -const paginationProps: any = {}; -jest.mock("@/components/utils/table/paginator/CommonTablePagination", () => (props: any) => { - Object.assign(paginationProps, props); - return ( -
- Page {props.currentPage} of {props.totalPages} +jest.mock("@/components/user/collected/cards/UserPageCollectedCard", () => { + const MockedCard = (props: any) => ( +
+ {props.card.token_id}
); + MockedCard.displayName = "UserPageCollectedCard"; + return MockedCard; +}); + +const paginationProps: any = {}; +jest.mock("@/components/utils/table/paginator/CommonTablePagination", () => { + const MockedPagination = (props: any) => { + Object.assign(paginationProps, props); + return ( +
+ Page {props.currentPage} of {props.totalPages} +
+ ); + }; + MockedPagination.displayName = "CommonTablePagination"; + return MockedPagination; }); -jest.mock("@/components/user/collected/cards/UserPageCollectedCardsNoCards", () => (props: any) => ( -
{String(props.filters.collection)}
-)); +jest.mock( + "@/components/user/collected/cards/UserPageCollectedCardsNoCards", + () => { + const MockedNoCards = (props: any) => ( +
{String(props.filters.collection)}
+ ); + + MockedNoCards.displayName = "UserPageCollectedCardsNoCards"; + return MockedNoCards; + } +); const sampleCards = [ { @@ -63,10 +80,14 @@ const baseFilters = { sortDirection: SortDirection.ASC, } as any; +const renderWithProviders = (component: React.ReactNode) => { + return render({component}); +}; + describe("UserPageCollectedCards", () => { it("renders cards and pagination when cards exist", () => { const setPage = jest.fn(); - render( + renderWithProviders( { showDataRow={true} filters={{ ...baseFilters, collection: null }} setPage={setPage} - />, + /> ); expect(screen.getAllByTestId("card")).toHaveLength(2); - expect(screen.getAllByTestId("card")[0]).toHaveAttribute("data-show-data-row", "true"); + expect(screen.getAllByTestId("card")[0]).toHaveAttribute( + "data-show-data-row", + "true" + ); expect(screen.getByTestId("pagination")).toHaveTextContent("Page 2 of 3"); expect(paginationProps.setCurrentPage).toBe(setPage); expect(paginationProps.haveNextPage).toBe(true); }); it("omits pagination when only one page", () => { - render( + renderWithProviders( { showDataRow={false} filters={{ ...baseFilters, collection: null }} setPage={() => {}} - />, + /> ); expect(screen.queryByTestId("pagination")).toBeNull(); }); it("renders no-cards message when list empty", () => { - render( + renderWithProviders( { showDataRow={false} filters={{ ...baseFilters, collection: CollectedCollectionType.MEMES }} setPage={() => {}} - />, + /> ); expect(screen.getByTestId("no-cards")).toHaveTextContent("MEMES"); diff --git a/__tests__/components/user/collected/filters/UserPageCollectedFilters.test.tsx b/__tests__/components/user/collected/filters/UserPageCollectedFilters.test.tsx index c04a7d936c..03859bd55b 100644 --- a/__tests__/components/user/collected/filters/UserPageCollectedFilters.test.tsx +++ b/__tests__/components/user/collected/filters/UserPageCollectedFilters.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { RefObject } from 'react'; import UserPageCollectedFilters from '@/components/user/collected/filters/UserPageCollectedFilters'; import { CollectedCollectionType, CollectionSeized, CollectionSort } from '@/entities/IProfile'; @@ -133,7 +133,7 @@ describe('UserPageCollectedFilters', () => { setSortBy: jest.fn(), setSeized: jest.fn(), setSzn: jest.fn(), - scrollHorizontally: jest.fn(), + showTransfer: false, }; let mockContainerRef: RefObject; @@ -144,17 +144,10 @@ describe('UserPageCollectedFilters', () => { current: document.createElement('div') }; - // Setup mock getBoundingClientRect - Element.prototype.getBoundingClientRect = jest.fn(() => ({ - left: 0, - top: 0, - right: 100, - bottom: 50, - width: 100, - height: 50, - x: 0, - y: 0, - toJSON: jest.fn(), + globalThis.ResizeObserver = jest.fn().mockImplementation((callback) => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), })); }); @@ -248,24 +241,7 @@ describe('UserPageCollectedFilters', () => { }); it('shows scroll arrows when filters are not fully visible', async () => { - // Mock checkVisibility to return false (not visible) - const mockGetBoundingClientRect = jest.fn() - // First call - container getBoundingClientRect - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Second call - mostLeftFilter getBoundingClientRect - .mockReturnValueOnce({ left: -50, right: -10, width: 40, height: 30, top: 0, bottom: 30, x: -50, y: 0, toJSON: jest.fn() }) - // Third call - container getBoundingClientRect again - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Fourth call - mostRightFilter getBoundingClientRect - .mockReturnValueOnce({ left: 150, right: 200, width: 50, height: 30, top: 0, bottom: 30, x: 150, y: 0, toJSON: jest.fn() }) - // Fifth call - container getBoundingClientRect again - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Default return for any other calls - .mockReturnValue({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }); - - Element.prototype.getBoundingClientRect = mockGetBoundingClientRect; - - render( + const { container } = render( { /> ); + await waitFor(() => { + const scrollContainer = container.querySelector('[class*="tw-overflow-x-auto"]') as HTMLDivElement; + expect(scrollContainer).toBeTruthy(); + }); + + const scrollContainer = container.querySelector('[class*="tw-overflow-x-auto"]') as HTMLDivElement; + if (!scrollContainer) { + throw new Error('Scroll container not found'); + } + + await act(async () => { + Object.defineProperty(scrollContainer, 'scrollLeft', { + writable: true, + configurable: true, + value: 50, + }); + Object.defineProperty(scrollContainer, 'scrollWidth', { + writable: true, + configurable: true, + value: 300, + }); + Object.defineProperty(scrollContainer, 'clientWidth', { + writable: true, + configurable: true, + value: 100, + }); + + const scrollEvent = new Event('scroll', { bubbles: true }); + scrollContainer.dispatchEvent(scrollEvent); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await waitFor(() => { expect(screen.getByLabelText('Scroll filters left')).toBeInTheDocument(); expect(screen.getByLabelText('Scroll filters right')).toBeInTheDocument(); @@ -281,24 +290,8 @@ describe('UserPageCollectedFilters', () => { }); it('calls scrollHorizontally when scroll arrows are clicked', async () => { - // Mock to show arrows - const mockGetBoundingClientRect = jest.fn() - // First call - container getBoundingClientRect - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Second call - mostLeftFilter getBoundingClientRect - .mockReturnValueOnce({ left: -50, right: -10, width: 40, height: 30, top: 0, bottom: 30, x: -50, y: 0, toJSON: jest.fn() }) - // Third call - container getBoundingClientRect again - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Fourth call - mostRightFilter getBoundingClientRect - .mockReturnValueOnce({ left: 150, right: 200, width: 50, height: 30, top: 0, bottom: 30, x: 150, y: 0, toJSON: jest.fn() }) - // Fifth call - container getBoundingClientRect again - .mockReturnValueOnce({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }) - // Default return for any other calls - .mockReturnValue({ left: 0, right: 100, width: 100, height: 30, top: 0, bottom: 30, x: 0, y: 0, toJSON: jest.fn() }); - - Element.prototype.getBoundingClientRect = mockGetBoundingClientRect; - - render( + const scrollBySpy = jest.fn(); + const { container } = render( { ); await waitFor(() => { - const leftArrow = screen.getByLabelText('Scroll filters left'); - const rightArrow = screen.getByLabelText('Scroll filters right'); - - fireEvent.click(leftArrow); - expect(mockSetters.scrollHorizontally).toHaveBeenCalledWith('left'); + const scrollContainer = container.querySelector('[class*="tw-overflow-x-auto"]') as HTMLDivElement; + expect(scrollContainer).toBeTruthy(); + }); + + const scrollContainer = container.querySelector('[class*="tw-overflow-x-auto"]') as HTMLDivElement; + if (!scrollContainer) { + throw new Error('Scroll container not found'); + } + + scrollContainer.scrollBy = scrollBySpy; + + await act(async () => { + Object.defineProperty(scrollContainer, 'scrollLeft', { + writable: true, + configurable: true, + value: 50, + }); + Object.defineProperty(scrollContainer, 'scrollWidth', { + writable: true, + configurable: true, + value: 300, + }); + Object.defineProperty(scrollContainer, 'clientWidth', { + writable: true, + configurable: true, + value: 100, + }); + + const scrollEvent = new Event('scroll', { bubbles: true }); + scrollContainer.dispatchEvent(scrollEvent); - fireEvent.click(rightArrow); - expect(mockSetters.scrollHorizontally).toHaveBeenCalledWith('right'); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + await waitFor(() => { + expect(screen.getByLabelText('Scroll filters left')).toBeInTheDocument(); + expect(screen.getByLabelText('Scroll filters right')).toBeInTheDocument(); }); + + const leftArrow = screen.getByLabelText('Scroll filters left'); + const rightArrow = screen.getByLabelText('Scroll filters right'); + + fireEvent.click(leftArrow); + expect(scrollBySpy).toHaveBeenCalledWith({ left: -150, behavior: 'smooth' }); + + fireEvent.click(rightArrow); + expect(scrollBySpy).toHaveBeenCalledWith({ left: 150, behavior: 'smooth' }); }); - it('sets up event listeners on mount and cleans up on unmount', () => { - const addEventListenerSpy = jest.spyOn(mockContainerRef.current!, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(mockContainerRef.current!, 'removeEventListener'); + it('sets up event listeners on mount and cleans up on unmount', async () => { + const addEventListenerSpy = jest.spyOn(HTMLDivElement.prototype, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(HTMLDivElement.prototype, 'removeEventListener'); const windowAddEventListenerSpy = jest.spyOn(window, 'addEventListener'); const windowRemoveEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const { unmount } = render( + const { container, unmount } = render( { /> ); - expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); - expect(windowAddEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + await waitFor(() => { + const scrollContainer = container.querySelector('[class*="tw-overflow-x-auto"]') as HTMLDivElement; + expect(scrollContainer).toBeTruthy(); + }); + + await waitFor(() => { + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(windowAddEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + }); unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + windowAddEventListenerSpy.mockRestore(); + windowRemoveEventListenerSpy.mockRestore(); }); - it('handles mobile touch device detection correctly', () => { - // Mock matchMedia to return true for touch devices - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: query === '(pointer: coarse)', - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); - - render( - - ); - - // On touch devices, arrows should not be shown - expect(screen.queryByLabelText('Scroll filters left')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Scroll filters right')).not.toBeInTheDocument(); - }); }); \ No newline at end of file diff --git a/__tests__/components/utils/NewVersionToast.test.tsx b/__tests__/components/utils/NewVersionToast.test.tsx index 67b09a0d21..4d5d767f69 100644 --- a/__tests__/components/utils/NewVersionToast.test.tsx +++ b/__tests__/components/utils/NewVersionToast.test.tsx @@ -1,52 +1,54 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import NewVersionToast from "@/components/utils/NewVersionToast"; -import { useIsVersionStale } from "@/hooks/useIsVersionStale"; -import { useRouter } from "next/navigation"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { useIsVersionStale } from "@/hooks/useIsVersionStale"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; jest.mock("@/hooks/useIsVersionStale", () => ({ useIsVersionStale: jest.fn(), })); -jest.mock("next/navigation", () => ({ useRouter: jest.fn() })); jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, default: jest.fn(), })); const mockedUseIsVersionStale = useIsVersionStale as jest.Mock; -const mockedUseRouter = useRouter as jest.Mock; const mockedUseDeviceInfo = useDeviceInfo as jest.Mock; +const mockReload = jest.fn(); + describe("NewVersionToast", () => { beforeEach(() => { jest.clearAllMocks(); + Object.defineProperty(globalThis, "location", { + value: { + ...globalThis.location, + reload: mockReload, + }, + writable: true, + }); }); it("returns null when not stale", () => { mockedUseIsVersionStale.mockReturnValue(false); - mockedUseRouter.mockReturnValue({ refresh: jest.fn() }); mockedUseDeviceInfo.mockReturnValue({ isApp: false }); const { container } = render(); expect(container.firstChild).toBeNull(); }); it("renders toast and refreshes on click", async () => { - const refresh = jest.fn(); mockedUseIsVersionStale.mockReturnValue(true); - mockedUseRouter.mockReturnValue({ refresh }); mockedUseDeviceInfo.mockReturnValue({ isApp: true }); const { container } = render(); expect(screen.getByText(/new version/i)).toBeInTheDocument(); expect(container.firstChild).toHaveClass("tw-bottom-24"); await userEvent.click(screen.getByRole("button")); - expect(refresh).toHaveBeenCalled(); + expect(mockReload).toHaveBeenCalled(); }); it("uses bottom-6 class when not in app", () => { mockedUseIsVersionStale.mockReturnValue(true); - mockedUseRouter.mockReturnValue({ refresh: jest.fn() }); mockedUseDeviceInfo.mockReturnValue({ isApp: false }); const { container } = render(); diff --git a/__tests__/services/6529api.test.ts b/__tests__/services/6529api.test.ts index 223f4fab58..5245cb3b0c 100644 --- a/__tests__/services/6529api.test.ts +++ b/__tests__/services/6529api.test.ts @@ -29,14 +29,14 @@ describe('6529api service', () => { it('fetchAllPages concatenates pages', async () => { (getStagingAuth as jest.Mock).mockReturnValue(null); (global.fetch as jest.Mock) - .mockResolvedValueOnce({ status: 200, json: async () => ({ data: ['a'], next: '/next' }) }) + .mockResolvedValueOnce({ status: 200, json: async () => ({ data: ['a'], next: 'http://localhost/next' }) }) .mockResolvedValueOnce({ status: 200, json: async () => ({ data: ['b'] }) }); - const result = await fetchAllPages('/start'); + const result = await fetchAllPages('http://localhost/start'); expect(global.fetch).toHaveBeenCalledTimes(2); - expect(global.fetch).toHaveBeenNthCalledWith(1, '/start', { headers: {} }); - expect(global.fetch).toHaveBeenNthCalledWith(2, '/next', { headers: {} }); + expect(global.fetch).toHaveBeenNthCalledWith(1, 'http://localhost/start', { headers: {} }); + expect(global.fetch).toHaveBeenNthCalledWith(2, 'http://localhost/next', { headers: {} }); expect(result).toEqual(['a', 'b']); }); diff --git a/app/[user]/_lib/userTabPageFactory.tsx b/app/[user]/_lib/userTabPageFactory.tsx index 5a3662c847..4ab83f5ac8 100644 --- a/app/[user]/_lib/userTabPageFactory.tsx +++ b/app/[user]/_lib/userTabPageFactory.tsx @@ -1,3 +1,4 @@ +import { TransferProvider } from "@/components/nft-transfer/TransferState"; import { getAppMetadata } from "@/components/providers/metadata"; import UserPageLayout from "@/components/user/layout/UserPageLayout"; import type { ApiIdentity } from "@/generated/models/ObjectSerializer"; @@ -16,6 +17,7 @@ type FactoryArgs = { subroute: string; metaLabel: string; Tab: (props: Readonly) => React.JSX.Element; + enableTransfer?: boolean; }; type UserRouteParams = { user: string }; @@ -29,20 +31,17 @@ const normalizeSearchParams = ( } if (params instanceof URLSearchParams) { - return Array.from(params.entries()).reduce( - (acc, [key, value]) => { - const existing = acc[key]; - if (existing === undefined) { - acc[key] = value; - } else if (Array.isArray(existing)) { - acc[key] = [...existing, value]; - } else { - acc[key] = [existing, value]; - } - return acc; - }, - {} as UserSearchParams - ); + return Array.from(params.entries()).reduce((acc, [key, value]) => { + const existing = acc[key]; + if (existing === undefined) { + acc[key] = value; + } else if (Array.isArray(existing)) { + acc[key] = [...existing, value]; + } else { + acc[key] = [existing, value]; + } + return acc; + }, {} as UserSearchParams); } return Object.entries(params).reduce((acc, [key, value]) => { @@ -80,7 +79,12 @@ const isNotFoundError = (error: unknown): boolean => { return !!message && message.toLowerCase().includes("not found"); }; -export function createUserTabPage({ subroute, metaLabel, Tab }: FactoryArgs) { +export function createUserTabPage({ + subroute, + metaLabel, + Tab, + enableTransfer, +}: FactoryArgs) { async function Page({ params, searchParams, @@ -95,11 +99,8 @@ export function createUserTabPage({ subroute, metaLabel, Tab }: FactoryArgs) { const user = resolvedParams.user; const normalizedUser = user.toLowerCase(); - const resolvedSearchParams = searchParams - ? await searchParams - : undefined; - const query: UserSearchParams = - normalizeSearchParams(resolvedSearchParams); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const query: UserSearchParams = normalizeSearchParams(resolvedSearchParams); const headers = await getAppCommonHeaders(); const profile: ApiIdentity = await getUserProfile({ user: normalizedUser, @@ -121,11 +122,17 @@ export function createUserTabPage({ subroute, metaLabel, Tab }: FactoryArgs) { redirect(needsRedirect.redirect.destination); } - return ( + const TabComponent = ( ); + + if (enableTransfer) { + return {TabComponent}; + } + + return TabComponent; } async function generateMetadata({ diff --git a/app/[user]/collected/page.tsx b/app/[user]/collected/page.tsx index a30bffd4fa..913f2d1743 100644 --- a/app/[user]/collected/page.tsx +++ b/app/[user]/collected/page.tsx @@ -5,6 +5,7 @@ const { Page, generateMetadata } = createUserTabPage({ subroute: "collected", metaLabel: "Collected", Tab: UserPageCollected, + enableTransfer: true, }); export default Page; diff --git a/app/network/tdh/historic-boosts/page.client.tsx b/app/network/tdh/historic-boosts/page.client.tsx index afb588d0a9..ea59527884 100644 --- a/app/network/tdh/historic-boosts/page.client.tsx +++ b/app/network/tdh/historic-boosts/page.client.tsx @@ -7,7 +7,7 @@ import { Col, Container, Row } from "react-bootstrap"; function DetailsCard(props: Readonly<{ title: string; children: ReactNode }>) { return ( -
+
{props.title}
diff --git a/app/network/tdh/page.client.tsx b/app/network/tdh/page.client.tsx index 509299c7fd..d897966ad9 100644 --- a/app/network/tdh/page.client.tsx +++ b/app/network/tdh/page.client.tsx @@ -52,7 +52,7 @@ export default function TDHMainPage() { {/* TDH 1.4 */}
+ className="tw-mt-10 tw-rounded-lg tw-bg-[#0c0c0d] tw-border-2 tw-border-solid tw-border-[#222] tw-p-6">

TDH 1.4 (October 10, 2025 — present)

Higher of Category A and Category B boosters, plus{" "} @@ -216,7 +216,7 @@ export default function TDHMainPage() { {/* Cross-links */}

-
+

Network Stats

Aggregate community activity, holdings, trading, and time-based @@ -226,7 +226,7 @@ export default function TDHMainPage() { View Network Stats

-
+

Levels

Our integrated progression that combines TDH with{" "} diff --git a/components/6529Gradient/6529Gradient.tsx b/components/6529Gradient/6529Gradient.tsx index 3169eca923..3124165437 100644 --- a/components/6529Gradient/6529Gradient.tsx +++ b/components/6529Gradient/6529Gradient.tsx @@ -54,24 +54,24 @@ export default function GradientsComponent() { const rawSort = searchParams.get("sort")?.toLowerCase() as Sort | undefined; const nextSort = rawSort === Sort.TDH ? Sort.TDH : Sort.ID; - const rawSortDir = searchParams - .get("sort_dir") - ?.toUpperCase() as SortDirection | undefined; + const rawSortDir = searchParams.get("sort_dir")?.toUpperCase() as + | SortDirection + | undefined; const nextSortDir = - rawSortDir === SortDirection.DESC ? SortDirection.DESC : SortDirection.ASC; + rawSortDir === SortDirection.DESC + ? SortDirection.DESC + : SortDirection.ASC; setSort((current) => (current === nextSort ? current : nextSort)); - setSortDir((current) => - current === nextSortDir ? current : nextSortDir - ); + setSortDir((current) => (current === nextSortDir ? current : nextSortDir)); }, [searchParams]); useEffect(() => { let isMounted = true; const url = `${publicEnv.API_ENDPOINT}/api/nfts/gradients?page_size=101`; - fetchAllPages(url) - .then((raw: GradientNFT[]) => { + fetchAllPages(url) + .then((raw) => { if (!isMounted) { return; } @@ -194,9 +194,7 @@ export default function GradientsComponent() { -

- 6529 Gradient -

+

6529 Gradient

diff --git a/components/6529Gradient/GradientPage.tsx b/components/6529Gradient/GradientPage.tsx index 975926015e..aa72343840 100644 --- a/components/6529Gradient/GradientPage.tsx +++ b/components/6529Gradient/GradientPage.tsx @@ -4,19 +4,24 @@ import styles from "./6529Gradient.module.scss"; import Address from "@/components/address/Address"; import { AuthContext } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; import LatestActivityRow from "@/components/latest-activity/LatestActivityRow"; import { NftPageStats } from "@/components/nft-attributes/NftStats"; import NFTImage from "@/components/nft-image/NFTImage"; import NFTMarketplaceLinks from "@/components/nft-marketplace-links/NFTMarketplaceLinks"; import NftNavigation from "@/components/nft-navigation/NftNavigation"; +import TransferSingle from "@/components/nft-transfer/TransferSingle"; import ArtistProfileHandle from "@/components/the-memes/ArtistProfileHandle"; +import YouOwnNftBadge from "@/components/you-own-nft-badge/YouOwnNftBadge"; import { publicEnv } from "@/config/env"; import { GRADIENT_CONTRACT } from "@/constants"; import { useSetTitle } from "@/contexts/TitleContext"; import { DBResponse } from "@/entities/IDBResponse"; import { NFT } from "@/entities/INFT"; +import { CollectedCollectionType } from "@/entities/IProfile"; import { Transaction } from "@/entities/ITransaction"; +import { ContractType } from "@/enums"; import { areEqualAddresses, numberWithCommas, @@ -26,7 +31,7 @@ import useCapacitor from "@/hooks/useCapacitor"; import { fetchUrl } from "@/services/6529api"; import { useContext, useEffect, useState } from "react"; import { Col, Container, Row, Table } from "react-bootstrap"; -import YouOwnNftBadge from "../you-own-nft-badge/YouOwnNftBadge"; + interface NftWithOwner extends NFT { owner: string; owner_display: string; @@ -35,6 +40,7 @@ interface NftWithOwner extends NFT { export default function GradientPageComponent({ id }: { readonly id: string }) { const capacitor = useCapacitor(); const { country } = useCookieConsent(); + const { address: connectedAddress } = useSeizeConnectContext(); const { connectedProfile } = useContext(AuthContext); const fullscreenElementId = "the-art-fullscreen-img"; @@ -42,6 +48,7 @@ export default function GradientPageComponent({ id }: { readonly id: string }) { const [nft, setNft] = useState(); const [isOwner, setIsOwner] = useState(false); + const [isConnectedAddressOwner, setIsConnectedAddressOwner] = useState(false); const [transactions, setTransactions] = useState([]); const [allNfts, setAllNfts] = useState([]); @@ -56,6 +63,10 @@ export default function GradientPageComponent({ id }: { readonly id: string }) { ); }, [nft, connectedProfile]); + useEffect(() => { + setIsConnectedAddressOwner(areEqualAddresses(connectedAddress, nft?.owner)); + }, [nft, connectedAddress]); + useEffect(() => { async function fetchNfts(url: string, mynfts: NftWithOwner[]) { return fetchUrl(url).then((response: DBResponse) => { @@ -117,28 +128,44 @@ export default function GradientPageComponent({ id }: { readonly id: string }) { )} {nft && ( - + - -

Owner

+ + + + +

Owner

+ +
+ + +

+
+

+ {isOwner && } + +
+
-
- - -

-
+ -

- {isOwner && } - + + )}
diff --git a/components/distribution-plan-tool/common/CircleLoader.tsx b/components/distribution-plan-tool/common/CircleLoader.tsx index 28a37689a8..adc20b58a7 100644 --- a/components/distribution-plan-tool/common/CircleLoader.tsx +++ b/components/distribution-plan-tool/common/CircleLoader.tsx @@ -26,17 +26,14 @@ export default function CircleLoader({ className={`${classes[size]} tw-flex-shrink-0 tw-inline tw-text-primary-400 tw-animate-spin`} viewBox="0 0 100 101" fill="none" - xmlns="http://www.w3.org/2000/svg" - > + xmlns="http://www.w3.org/2000/svg"> + fill="currentColor"> + fill="currentColor"> ); } diff --git a/components/distribution/Distribution.tsx b/components/distribution/Distribution.tsx index 46d66f2599..b760f0f8e3 100644 --- a/components/distribution/Distribution.tsx +++ b/components/distribution/Distribution.tsx @@ -90,10 +90,12 @@ export default function DistributionPage(props: Readonly) { if (nftId) { const distributionPhotosUrl = `${publicEnv.API_ENDPOINT}/api/distribution_photos/${props.contract}/${nftId}`; - fetchAllPages(distributionPhotosUrl).then((distributionPhotos: any[]) => { - setDistributionPhotos(distributionPhotos); - fetchDistribution(); - }); + fetchAllPages(distributionPhotosUrl).then( + (distributionPhotos) => { + setDistributionPhotos(distributionPhotos); + fetchDistribution(); + } + ); } }, [nftId]); @@ -290,8 +292,7 @@ export default function DistributionPage(props: Readonly) {

- {props.header} Card # - {nftId} Distribution + {props.header} Card #{nftId} Distribution

{printMintingLink()} diff --git a/components/latest-activity/fetchInitialActivityData.ts b/components/latest-activity/fetchInitialActivityData.ts index 2d0d0676c8..92b93d033b 100644 --- a/components/latest-activity/fetchInitialActivityData.ts +++ b/components/latest-activity/fetchInitialActivityData.ts @@ -33,9 +33,9 @@ export async function fetchInitialActivityData( ) as Promise, // Gradients data - fetchAllPages( + fetchAllPages( `${publicEnv.API_ENDPOINT}/api/nfts/gradients?&page_size=101` - ) as Promise, + ), // NextGen collections commonApiFetch<{ diff --git a/components/layout/WebLayout.tsx b/components/layout/WebLayout.tsx index d62de08ad0..e9b2e8d230 100644 --- a/components/layout/WebLayout.tsx +++ b/components/layout/WebLayout.tsx @@ -1,12 +1,12 @@ "use client"; -import React, { type ReactNode, useMemo } from "react"; import Image from "next/image"; -import WebSidebar from "./sidebar/WebSidebar"; -import { useSidebarController } from "../../hooks/useSidebarController"; +import React, { type ReactNode, useMemo } from "react"; import { SIDEBAR_WIDTHS } from "../../constants/sidebar"; +import { useSidebarController } from "../../hooks/useSidebarController"; import { SidebarProvider, useSidebarState } from "../../hooks/useSidebarState"; import ClientOnly from "../client-only/ClientOnly"; +import WebSidebar from "./sidebar/WebSidebar"; const DESKTOP_MAX_WIDTH = 1324; @@ -34,20 +34,19 @@ const WebLayoutContent = ({ children, isSmall = false }: WebLayoutProps) => { "--collapsed-width": SIDEBAR_WIDTHS.COLLAPSED, "--expanded-width": SIDEBAR_WIDTHS.EXPANDED, "--layout-max": `${DESKTOP_MAX_WIDTH}px`, - }) as React.CSSProperties, + } as React.CSSProperties), [sidebarWidth] ); return (
+ data-small={isSmall ? "true" : "false"}>
{ data-mobile={isMobile} data-narrow={isNarrow} data-offcanvas={isOffcanvasOpen} - data-right-open={isRightSidebarOpen} - > + data-right-open={isRightSidebarOpen}> {children}
@@ -88,11 +86,12 @@ const WebLayout = ({ children, isSmall = false }: WebLayoutProps) => ( height={326} className="tw-rounded-md tw-shadow-lg tw-max-w-[40vw] md:tw-max-w-[180px] tw-h-auto" /> -

Loading...

+

+ Loading... +

- } - > + }> {children} diff --git a/components/mapping-tools/ConsolidationMappingTool.tsx b/components/mapping-tools/ConsolidationMappingTool.tsx index c30674aca3..fb8b05b6ab 100644 --- a/components/mapping-tools/ConsolidationMappingTool.tsx +++ b/components/mapping-tools/ConsolidationMappingTool.tsx @@ -1,13 +1,13 @@ "use client"; import { publicEnv } from "@/config/env"; +import { Consolidation } from "@/entities/IDelegation"; +import { areEqualAddresses } from "@/helpers/Helpers"; +import { fetchAllPages } from "@/services/6529api"; import { faFileUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useEffect, useRef, useState } from "react"; import { Button, Col, Container, Form, Row } from "react-bootstrap"; -import { Consolidation } from "@/entities/IDelegation"; -import { areEqualAddresses } from "@/helpers/Helpers"; -import { fetchAllPages } from "@/services/6529api"; import styles from "./MappingTool.module.scss"; const csvParser = require("csv-parser"); @@ -100,7 +100,7 @@ export default function ConsolidationMappingTool() { useEffect(() => { async function fetchConsolidations(url: string) { - fetchAllPages(url).then((consolidations: Consolidation[]) => { + fetchAllPages(url).then((consolidations) => { setConsolidations(consolidations); const reader = new FileReader(); @@ -115,8 +115,8 @@ export default function ConsolidationMappingTool() { isFirstRow = false; } else { const address = row["_0"]; - const token_id = parseInt(row["_1"]); - const balance = parseInt(row["_2"]); + const token_id = Number.parseInt(row["_1"], 10); + const balance = Number.parseInt(row["_2"], 10); const contract = row["_3"]; const name = row["_4"]; results.push({ address, token_id, balance, contract, name }); diff --git a/components/mapping-tools/DelegationMappingTool.tsx b/components/mapping-tools/DelegationMappingTool.tsx index 745cf8525e..46fd002776 100644 --- a/components/mapping-tools/DelegationMappingTool.tsx +++ b/components/mapping-tools/DelegationMappingTool.tsx @@ -5,14 +5,14 @@ import { SUPPORTED_COLLECTIONS, } from "@/components/delegation/delegation-constants"; import { publicEnv } from "@/config/env"; -import { faFileUpload } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useEffect, useRef, useState } from "react"; -import { Button, Col, Container, Form, Row } from "react-bootstrap"; import { DELEGATION_ALL_ADDRESS, MEMES_CONTRACT } from "@/constants"; import { Delegation } from "@/entities/IDelegation"; import { areEqualAddresses } from "@/helpers/Helpers"; import { fetchAllPages } from "@/services/6529api"; +import { faFileUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useRef, useState } from "react"; +import { Button, Col, Container, Form, Row } from "react-bootstrap"; import styles from "./MappingTool.module.scss"; const csvParser = require("csv-parser"); @@ -83,7 +83,7 @@ export default function DelegationMappingTool() { useEffect(() => { async function fetchDelegations(url: string) { - fetchAllPages(url).then((delegations: Delegation[]) => { + fetchAllPages(url).then((delegations) => { setDelegations(delegations); const reader = new FileReader(); @@ -220,7 +220,7 @@ export default function DelegationMappingTool() { className={`${styles.formInput}`} value={useCase} onChange={(e) => { - const newCase = parseInt(e.target.value); + const newCase = Number.parseInt(e.target.value); setUseCase(newCase); }}>