diff --git a/app/address/[address]/files/__tests__/page-client.test.tsx b/app/address/[address]/files/__tests__/page-client.test.tsx new file mode 100644 index 000000000..a41e500c2 --- /dev/null +++ b/app/address/[address]/files/__tests__/page-client.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; + +import MetaplexFilesPageClient from '../page-client'; + +// Mock the dependencies +vi.mock('@/app/components/common/LoadingCard', () => ({ + LoadingCard: () =>
Loading...
, +})); + +vi.mock('@components/account/ParsedAccountRenderer', () => ({ + ParsedAccountRenderer: ({ + address, + renderComponent: RenderComponentProp, + }: { + address: string; + renderComponent: React.ComponentType; + }) => { + // Mock account data for testing + const mockAccount = { + data: { + parsed: { + nftData: { + metadata: { + data: { + uri: 'https://example.com/metadata.json', + }, + }, + }, + parsed: { type: 'mint' }, + program: 'spl-token', + }, + }, + pubkey: { toString: () => address }, + }; + const mockOnNotFound = vi.fn(() => { + throw new Error('Not found'); + }); + + return ( +
+ +
+ ); + }, +})); + +vi.mock('@components/account/MetaplexFilesCard', () => ({ + MetaplexFilesCard: ({ account, onNotFound: _onNotFound }: { account: any; onNotFound: () => never }) => ( +
MetaplexFilesCard for {account?.pubkey?.toString()}
+ ), +})); + +describe('MetaplexFilesPageClient', () => { + it('renders ParsedAccountRenderer with correct address', () => { + const testAddress = 'DemoKeypair1111111111111111111111111111111111'; + const props = { + params: { + address: testAddress, + }, + }; + + render(); + + expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument(); + }); + + it('renders MetaplexFilesCard within Suspense boundary', async () => { + const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const props = { + params: { + address: testAddress, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument(); + }); + }); + + it('renders the correct component structure', () => { + const testAddress = 'DemoKeypair1111111111111111111111111111111111'; + const props = { + params: { + address: testAddress, + }, + }; + + render(); + + // Should render the parsed account renderer with the component structure + expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument(); + expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument(); + }); +}); diff --git a/app/address/[address]/files/__tests__/page.test.tsx b/app/address/[address]/files/__tests__/page.test.tsx new file mode 100644 index 000000000..56ec8d48c --- /dev/null +++ b/app/address/[address]/files/__tests__/page.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import MetaplexFilesPage from '../page'; + +// Mock the page-client component +vi.mock('../page-client', () => ({ + default: ({ params }: { params: { address: string } }) => ( +
Metaplex Files for address: {params.address}
+ ), +})); + +describe('MetaplexFilesPage', () => { + it('renders the page with correct props', () => { + const props = { + params: { + address: 'DemoKeypair1111111111111111111111111111111111', + }, + }; + + render(); + + expect(screen.getByTestId('metaplex-files-page-client')).toBeInTheDocument(); + expect( + screen.getByText('Metaplex Files for address: DemoKeypair1111111111111111111111111111111111') + ).toBeInTheDocument(); + }); + + it('passes address parameter correctly', () => { + const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const props = { + params: { + address: testAddress, + }, + }; + + render(); + + expect(screen.getByText(`Metaplex Files for address: ${testAddress}`)).toBeInTheDocument(); + }); +}); diff --git a/app/address/[address]/files/page-client.tsx b/app/address/[address]/files/page-client.tsx new file mode 100644 index 000000000..4a437abc6 --- /dev/null +++ b/app/address/[address]/files/page-client.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { MetaplexFilesCard } from '@components/account/MetaplexFilesCard'; +import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; +import React, { Suspense } from 'react'; + +import { LoadingCard } from '@/app/components/common/LoadingCard'; + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +function MetaplexFilesCardRenderer({ + account, + onNotFound, +}: React.ComponentProps['renderComponent']>) { + return ( + }> + {} + + ); +} + +export default function MetaplexFilesPageClient({ params: { address } }: Props) { + return ; +} diff --git a/app/address/[address]/files/page.tsx b/app/address/[address]/files/page.tsx new file mode 100644 index 000000000..ed0837360 --- /dev/null +++ b/app/address/[address]/files/page.tsx @@ -0,0 +1,11 @@ +import MetaplexFilesPageClient from './page-client'; + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +export default function MetaplexFilesPage(props: Props) { + return ; +} diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index cae7678c1..a8a9e3906 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -108,7 +108,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = { title: 'Instructions', }, ], - 'spl-token-2022:mint:metaplexNFT': [ + 'spl-token-2022:mint:metaplex': [ { path: 'metadata', slug: 'metadata', @@ -119,6 +119,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = { slug: 'attributes', title: 'Attributes', }, + { + path: 'files', + slug: 'files', + title: 'Files', + }, ], 'spl-token:mint': [ { @@ -132,7 +137,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = { title: 'Instructions', }, ], - 'spl-token:mint:metaplexNFT': [ + 'spl-token:mint:metaplex': [ { path: 'metadata', slug: 'metadata', @@ -143,6 +148,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = { slug: 'attributes', title: 'Attributes', }, + { + path: 'files', + slug: 'files', + title: 'Files', + }, ], stake: [ { @@ -380,6 +390,7 @@ export type MoreTabs = | 'rewards' | 'metadata' | 'attributes' + | 'files' | 'domains' | 'security' | 'idl' @@ -443,7 +454,7 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] { (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && (parsedData as TokenProgramData).nftData ) { - tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]); + tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplex`]); } // Compressed NFT tabs diff --git a/app/components/account/MetaplexFilesCard.tsx b/app/components/account/MetaplexFilesCard.tsx new file mode 100644 index 000000000..2518fc981 --- /dev/null +++ b/app/components/account/MetaplexFilesCard.tsx @@ -0,0 +1,103 @@ +import { ErrorCard } from '@components/common/ErrorCard'; +import { LoadingCard } from '@components/common/LoadingCard'; +import { Account, isTokenProgramData } from '@providers/accounts'; +import React from 'react'; + +import { getProxiedUri } from '@/app/features/metadata/utils'; +import { useCluster } from '@/app/providers/cluster'; +import { useCompressedNft } from '@/app/providers/compressed-nft'; + +interface File { + uri: string; + type: string; + cdn?: boolean; +} + +export function MetaplexFilesCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) { + const { url } = useCluster(); + const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url }); + + const parsedData = account?.data?.parsed; + if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) { + if (compressedNft && compressedNft.compression.compressed) { + return ; + } + return onNotFound(); + } + return ; +} + +function NormalMetaplexFilesCard({ metadataUri }: { metadataUri: string }) { + const [files, setFiles] = React.useState([]); + const [status, setStatus] = React.useState<'loading' | 'success' | 'error'>('loading'); + + React.useEffect(() => { + async function fetchMetadataFiles() { + try { + const response = await fetch(getProxiedUri(metadataUri)); + if (!response.ok) { + throw new Error('Failed to fetch metadata'); + } + + const metadata = await response.json(); + // Verify if the attributes value is an array + if (Array.isArray(metadata.properties.files)) { + // Filter files to keep objects matching schema + const filteredFiles = metadata.properties.files.filter((file: any) => { + return ( + typeof file === 'object' && typeof file.uri === 'string' && typeof file.type === 'string' + ); + }); + + setFiles(filteredFiles); + setStatus('success'); + } else { + throw new Error('Files is not an array'); + } + } catch (error) { + setStatus('error'); + } + } + fetchMetadataFiles(); + }, [metadataUri]); + + if (status === 'loading') { + return ; + } + + if (status === 'error') { + return ; + } + + const filesList: React.ReactNode[] = files.map(({ uri, type }) => { + return ( + + + + {uri} + + + {type} + + ); + }); + + return ( +
+
+

Files

+
+
+ + + + + + + + {filesList} +
File URIFile Type
+
+
+ ); +} diff --git a/app/components/account/__test__/MetaplexFilesCard.test.tsx b/app/components/account/__test__/MetaplexFilesCard.test.tsx new file mode 100644 index 000000000..d2f1b11c1 --- /dev/null +++ b/app/components/account/__test__/MetaplexFilesCard.test.tsx @@ -0,0 +1,383 @@ +import { PublicKey } from '@solana/web3.js'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ok } from 'assert'; +import { vi } from 'vitest'; + +import { MetaplexFilesCard } from '../MetaplexFilesCard'; + +// Mock the dependencies +vi.mock('@/app/providers/cluster', () => ({ + useCluster: () => ({ + url: 'https://api.mainnet-beta.solana.com', + }), +})); + +vi.mock('@/app/providers/compressed-nft', () => ({ + useCompressedNft: vi.fn(), +})); + +vi.mock('@/app/features/metadata/utils', () => ({ + getProxiedUri: (uri: string) => uri, +})); + +vi.mock('@components/common/ErrorCard', () => ({ + ErrorCard: ({ text }: { text: string }) =>
{text}
, +})); + +vi.mock('@components/common/LoadingCard', () => ({ + LoadingCard: () =>
Loading...
, +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Import the mocked function +const { useCompressedNft } = await import('@/app/providers/compressed-nft'); +const mockUseCompressedNft = vi.mocked(useCompressedNft); + +describe('MetaplexFilesCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseCompressedNft.mockReturnValue(null); + }); + + const createMockAccount = (overrides = {}) => + ({ + data: { + parsed: { + nftData: { + editionInfo: { edition: 'master', masterEdition: undefined }, + json: undefined, + metadata: { + collection: null, + data: { + creators: null, + name: 'Test NFT', + sellerFeeBasisPoints: 0, + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + }, + editionNonce: null, + isMutable: true, + key: 1, + mint: 'So11111111111111111111111111111111111111112', + primarySaleHappened: false, + tokenStandard: null, + updateAuthority: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + uses: null, + }, + }, + parsed: { type: 'mint' as const }, + program: 'spl-token' as const, + }, + }, + executable: false, + lamports: 0, + owner: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + pubkey: new PublicKey('So11111111111111111111111111111111111111112'), + ...overrides, + } as any); + + const mockOnNotFound = vi.fn(() => { + throw new Error('Not found'); + }); + + it('shows loading card initially', () => { + const mockAccount = createMockAccount(); + render(); + + expect(screen.getByTestId('loading-card')).toBeInTheDocument(); + }); + + it('renders files table when fetch is successful', async () => { + const mockFiles = [ + { type: 'image/png', uri: 'https://example.com/file1.png' }, + { type: 'image/jpeg', uri: 'https://example.com/file2.jpg' }, + ]; + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: mockFiles, + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + expect(screen.getByText('File URI')).toBeInTheDocument(); + expect(screen.getByText('File Type')).toBeInTheDocument(); + + // Check if files are rendered + expect(screen.getByText('https://example.com/file1.png')).toBeInTheDocument(); + expect(screen.getByText('image/png')).toBeInTheDocument(); + expect(screen.getByText('https://example.com/file2.jpg')).toBeInTheDocument(); + expect(screen.getByText('image/jpeg')).toBeInTheDocument(); + }); + + it('shows error card when fetch fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('error-card')).toBeInTheDocument(); + }); + + expect(screen.getByText('Failed to fetch files')).toBeInTheDocument(); + }); + + it('shows error when files property is not an array', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: 'not an array', + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('error-card')).toBeInTheDocument(); + }); + + expect(screen.getByText('Failed to fetch files')).toBeInTheDocument(); + }); + + it('filters out invalid file objects', async () => { + const mockFiles = [ + { type: 'image/png', uri: 'https://example.com/valid.png' }, + { type: null, uri: 'invalid' }, // invalid type + { type: 'image/jpeg', uri: null }, // invalid uri + 'not an object', // not an object + { type: 'image/jpeg', uri: 'https://example.com/valid2.jpg' }, + ]; + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: mockFiles, + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + // Should only show valid files + expect(screen.getByText('https://example.com/valid.png')).toBeInTheDocument(); + expect(screen.getByText('https://example.com/valid2.jpg')).toBeInTheDocument(); + + // Should not show invalid files + expect(screen.queryByText('invalid')).not.toBeInTheDocument(); + }); + + it('calls onNotFound when account data is invalid', () => { + const invalidAccount = createMockAccount({ + data: { + parsed: { + parsed: { type: 'account' }, + program: 'spl-token', + }, + }, + }); + + expect(() => { + render(); + }).toThrow('Not found'); + + expect(mockOnNotFound).toHaveBeenCalled(); + }); + + it('calls onNotFound when account is undefined', () => { + expect(() => { + render(); + }).toThrow('Not found'); + + expect(mockOnNotFound).toHaveBeenCalled(); + }); + + it('renders file links with correct href and attributes', async () => { + const mockFiles = [{ type: 'image/png', uri: 'https://example.com/file1.png' }]; + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: mockFiles, + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + const fileLink = screen.getByRole('link', { name: 'https://example.com/file1.png' }); + expect(fileLink).toHaveAttribute('href', 'https://example.com/file1.png'); + expect(fileLink).toHaveAttribute('target', '_blank'); + expect(fileLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('handles compressed NFT when regular NFT data is not available', () => { + mockUseCompressedNft.mockReturnValue({ + compression: { + asset_hash: 'hash3', + compressed: true, + creator_hash: 'hash2', + data_hash: 'hash1', + eligible: false, + leaf_id: 1, + seq: 1, + tree: 'tree1', + }, + content: { + $schema: 'schema', + files: [], + json_uri: 'https://compressed-nft.com/metadata.json', + links: {}, + metadata: { + attributes: [], + description: 'desc', + name: 'name', + symbol: 'symbol', + token_standard: 'standard', + }, + }, + } as any); + + const accountWithoutNftData = createMockAccount({ + data: { + parsed: { + parsed: { type: 'account' }, + program: 'spl-token', + }, + }, + }); + + render(); + + expect(screen.getByTestId('loading-card')).toBeInTheDocument(); + }); + + it('handles empty files array', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: [], + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + expect(screen.getByText('File URI')).toBeInTheDocument(); + expect(screen.getByText('File Type')).toBeInTheDocument(); + + // Should show table with no file rows (only header row) + const fileRows = screen.getAllByRole('row'); + expect(fileRows).toHaveLength(1); // Only header row + }); + + it('uses proxied URI for metadata fetch', async () => { + const mockFiles = [{ type: 'image/png', uri: 'https://example.com/file1.png' }]; + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: mockFiles, + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + // Verify that fetch was called with the metadata URI + expect(mockFetch).toHaveBeenCalledWith('https://example.com/metadata.json'); + }); + + it('handles missing properties in metadata', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + // Missing properties field + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('error-card')).toBeInTheDocument(); + }); + + expect(screen.getByText('Failed to fetch files')).toBeInTheDocument(); + }); + + it('creates unique keys for file rows', async () => { + const mockFiles = [ + { type: 'image/png', uri: 'https://example.com/file1.png' }, + { type: 'image/png', uri: 'https://example.com/file2.png' }, + { type: 'image/jpeg', uri: 'https://example.com/file1.png' }, // Same URI, different type + ]; + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + properties: { + files: mockFiles, + }, + }), + ok: true, + }); + + const mockAccount = createMockAccount(); + render(); + + await waitFor(() => { + expect(screen.getByText('Files')).toBeInTheDocument(); + }); + + // Should render all files with unique keys + const fileRows = screen.getAllByRole('row').slice(1); // Skip header row + expect(fileRows).toHaveLength(3); + }); +});