diff --git a/src/components/dashboard/__tests__/RecentTransactions.test.tsx b/src/components/dashboard/__tests__/RecentTransactions.test.tsx new file mode 100644 index 00000000..da916a64 --- /dev/null +++ b/src/components/dashboard/__tests__/RecentTransactions.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RecentTransactions } from '../RecentTransactions'; + +describe('RecentTransactions', () => { + it('should render the dashboard transaction list heading and subtitle', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect( + screen.getByText('Latest transactions and income') + ).toBeInTheDocument(); + }); + + it('should render all populated transaction rows', () => { + render(); + + expect( + screen.getByText('Manhattan Tower Suite') + ).toBeInTheDocument(); + expect(screen.getByText('Tech Hub Office Complex')).toBeInTheDocument(); + expect(screen.getByText('Downtown Luxury Lofts')).toBeInTheDocument(); + expect(screen.getByText('Sunset Beach Villa')).toBeInTheDocument(); + expect(screen.getByText('Mixed-Use Development')).toBeInTheDocument(); + }); + + it('should show the correct transaction type labels', () => { + render(); + + // Two income rows, two purchase rows and one sale row in the static list + expect(screen.getAllByText('Rental Income')).toHaveLength(2); + expect(screen.getAllByText('Purchase')).toHaveLength(2); + expect(screen.getByText('Sale')).toBeInTheDocument(); + }); + + it('should format amounts with a directional sign and thousands separators', () => { + render(); + + // Income and purchases are prefixed with "+", sales with "-" + expect(screen.getByText('+$3,280')).toBeInTheDocument(); + expect(screen.getByText('+$45,000')).toBeInTheDocument(); + expect(screen.getByText('-$12,500')).toBeInTheDocument(); + }); + + it('should render token counts for transactions that include them', () => { + render(); + + expect(screen.getByText('90 tokens')).toBeInTheDocument(); + expect(screen.getByText('25 tokens')).toBeInTheDocument(); + expect(screen.getByText('55 tokens')).toBeInTheDocument(); + }); + + it('should render the completed/pending status for each transaction', () => { + render(); + + // Four completed rows and one pending row in the static list + expect(screen.getAllByText('✓ Completed')).toHaveLength(4); + expect(screen.getByText('⏳ Pending')).toBeInTheDocument(); + }); + + it('should render a formatted date for each transaction row', () => { + render(); + + // Dates are rendered via toLocaleDateString (e.g. "Jan 20") + expect(screen.getAllByText(/Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec/).length).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/src/components/property/__tests__/ImageGallery.test.tsx b/src/components/property/__tests__/ImageGallery.test.tsx new file mode 100644 index 00000000..37ba2dc6 --- /dev/null +++ b/src/components/property/__tests__/ImageGallery.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ImageGallery } from '../ImageGallery'; +import { ImageLightbox } from '../ImageLightbox'; + +// Mock next/image to render a plain img so tests don't hit loader/hostname validation. +// Strip next/image-only props (priority, sizes, fill) that are not valid img attributes. +type NextImageMockProps = React.ImgHTMLAttributes & { + priority?: boolean; + fill?: boolean; + sizes?: string; +}; + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ + priority: _priority, + fill: _fill, + sizes: _sizes, + ...imgProps + }: NextImageMockProps) => , +})); + +const images = [ + 'https://images.unsplash.com/photo-1', + 'https://images.unsplash.com/photo-2', + 'https://images.unsplash.com/photo-3', +]; + +describe('ImageGallery', () => { + it('should render the main image with the property name alt text', () => { + render(); + + const mainImage = screen.getByAltText('Sunset Villa'); + expect(mainImage).toBeInTheDocument(); + }); + + it('should render thumbnails when there is more than one image', () => { + render(); + + expect(screen.getByAltText('Sunset Villa - Image 2')).toBeInTheDocument(); + expect(screen.getByAltText('Sunset Villa - Image 3')).toBeInTheDocument(); + }); + + it('should open the lightbox when the main image is clicked', () => { + render(); + + fireEvent.click(screen.getByAltText('Sunset Villa')); + + // Lightbox opens showing image counter "1 / 3" + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(screen.getByLabelText('Close lightbox')).toBeInTheDocument(); + }); + + it('should open the lightbox at the correct index when a thumbnail is clicked', () => { + render(); + + fireEvent.click(screen.getByAltText('Sunset Villa - Image 2')); + + // Second image is shown as current (counter "2 / 3") + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + }); + + it('should render a placeholder when there are no images', () => { + render(); + + expect(screen.getByText('No images available')).toBeInTheDocument(); + expect(screen.queryByAltText('Sunset Villa')).not.toBeInTheDocument(); + }); +}); + +describe('ImageLightbox', () => { + it('should render nothing when closed', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render the current image and counter when open', () => { + render( + + ); + + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(screen.getByAltText('Property image 1')).toBeInTheDocument(); + }); + + it('should navigate to the next image and wrap around', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Next image')); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Next image')); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + + // Wraps back to the first image + fireEvent.click(screen.getByLabelText('Next image')); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + }); + + it('should navigate to the previous image and wrap around', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Previous image')); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + }); + + it('should jump to an image when a thumbnail is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByAltText('Thumbnail 3')); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + }); + + it('should call onClose when the close button is clicked', () => { + const onClose = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Close lightbox')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when the Escape key is pressed', () => { + const onClose = jest.fn(); + render( + + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/components/property/__tests__/PdfViewerModal.test.tsx b/src/components/property/__tests__/PdfViewerModal.test.tsx new file mode 100644 index 00000000..7774dd07 --- /dev/null +++ b/src/components/property/__tests__/PdfViewerModal.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import PdfViewerModal from '../PdfViewerModal'; +import type { PropertyDocument } from '../PdfViewerModal'; + +// Mock react-pdf with a configurable Document so we can exercise load and error states. +const mockDocument = jest.fn( + (props: { + file?: string; + onLoadSuccess?: () => void; + onError?: () => void; + children?: React.ReactNode; + }) => { + return { + file: props.file, + onLoadSuccess: props.onLoadSuccess, + onError: props.onError, + children: props.children, + }; + } +); + +// react-pdf is not a declared dependency yet, so mock it as virtual to avoid resolution errors. +jest.mock('react-pdf', () => ({ + Document: (props: { + file?: string; + onLoadSuccess?: () => void; + onError?: () => void; + children?: React.ReactNode; + }) => { + mockDocument(props); + return ( +
+ {props.children} +
+ ); + }, + Page: () =>
, +}), { virtual: true }); + +const mockDoc: PropertyDocument = { + id: 'doc-1', + name: 'Property Deed', + url: 'https://example.com/deed.pdf', + category: 'Legal', + verified: true, +}; + +describe('PdfViewerModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal shell with a close button when open', () => { + render(); + + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('should pass the document url to the PDF Document component', () => { + render(); + + expect(mockDocument).toHaveBeenCalledTimes(1); + expect(mockDocument.mock.calls[0][0].file).toBe(mockDoc.url); + }); + + it('should render the PDF page when a document is provided', () => { + render(); + + expect(screen.getByTestId('pdf-page')).toBeInTheDocument(); + }); + + it('should call onClose when the close button is clicked', () => { + const onClose = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should render the modal and keep the close button available even on a failing document', () => { + const brokenDoc: PropertyDocument = { ...mockDoc, url: 'https://example.com/broken.pdf' }; + const onClose = jest.fn(); + render(); + + // The modal shell stays mounted so the user can always dismiss it + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + expect(screen.getByTestId('pdf-document')).toHaveAttribute('data-fail', 'true'); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/components/property/__tests__/SetPriceAlertModal.test.tsx b/src/components/property/__tests__/SetPriceAlertModal.test.tsx new file mode 100644 index 00000000..4ca15bd6 --- /dev/null +++ b/src/components/property/__tests__/SetPriceAlertModal.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SetPriceAlertModal } from '../SetPriceAlertModal'; +import type { Property } from '@/types/property'; + +// Mock next/image to avoid hostname validation against next.config.js images +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes) => , +})); + +const mockProperty: Property = { + id: 'prop-1', + name: 'Sunset Villa', + description: 'Beautiful residential property with great views', + location: { + address: '123 Main St', + city: 'Los Angeles', + state: 'California', + country: 'USA', + zipCode: '90001', + coordinates: { lat: 34.05, lng: -118.25 }, + }, + price: { + total: 500, + perToken: 50, + currency: 'USD', + }, + propertyType: 'residential', + blockchain: 'ethereum', + tokenInfo: { + totalSupply: 1000, + available: 500, + sold: 500, + contractAddress: '0x1234', + tokenSymbol: 'PROP', + }, + metrics: { + roi: 8.5, + annualReturn: 42500, + transactionVolume: 1000000, + appreciationRate: 5.2, + }, + details: { + bedrooms: 4, + bathrooms: 3, + squareFeet: 2500, + yearBuilt: 2020, + amenities: ['pool', 'garden'], + }, + images: ['https://example.com/image1.jpg'], + listedDate: '2024-01-01', + status: 'active', + featured: true, + verified: true, +}; + +type SetPriceAlertModalProps = React.ComponentProps; + +interface SetupProps { + isOpen?: boolean; + existingAlert?: SetPriceAlertModalProps['existingAlert']; + onSetAlert?: SetPriceAlertModalProps['onSetAlert']; +} + +const defaultOnSetAlert = jest.fn().mockResolvedValue(undefined); + +const renderModal = ({ + isOpen = true, + existingAlert, + onSetAlert = defaultOnSetAlert, +}: SetupProps = {}) => { + const onOpenChange = jest.fn(); + const utils = render( + + ); + return { ...utils, onOpenChange }; +}; + +describe('SetPriceAlertModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal content when open', () => { + renderModal(); + + expect(screen.getByText('Set Price Alert')).toBeInTheDocument(); + expect(screen.getByText(/Get notified when the token price for/)).toBeInTheDocument(); + expect(screen.getByText('Sunset Villa')).toBeInTheDocument(); + }); + + it('should pre-fill options with the property price and show a Set Alert button', () => { + renderModal(); + + expect(screen.getByLabelText('Target Price (USD)')).toHaveValue(50); + expect(screen.getByRole('button', { name: /set alert/i })).toBeInTheDocument(); + }); + + it('should call onSetAlert with the selected type, target price and email flag on submit', async () => { + const onSetAlert = jest.fn().mockResolvedValue(undefined); + const { onOpenChange } = renderModal({ onSetAlert }); + + // Change target price to a valid value + const priceInput = screen.getByLabelText('Target Price (USD)'); + fireEvent.change(priceInput, { target: { value: '60' } }); + expect(priceInput).toHaveValue(60); + + // Toggle the email notification checkbox + const emailCheckbox = screen.getByRole('checkbox', { + name: /send me an email when the alert triggers/i, + }); + expect(emailCheckbox).not.toBeChecked(); + await userEvent.click(emailCheckbox); + expect(emailCheckbox).toBeChecked(); + + // Submit + const submitButton = screen.getByRole('button', { name: /set alert/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSetAlert).toHaveBeenCalledWith('below', 60, true); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it('should keep the submit button disabled for an invalid (empty/zero) target price', () => { + renderModal(); + + const submitButton = screen.getByRole('button', { name: /set alert/i }); + + // Default target price is valid (50), so the button should be enabled + expect(submitButton).toBeEnabled(); + + // Set an empty price => parses to 0, disables the submit button + const priceInput = screen.getByLabelText('Target Price (USD)'); + fireEvent.change(priceInput, { target: { value: '' } }); + + // State resolves to 0, which makes the submit button invalid/disabled + expect(submitButton).toBeDisabled(); + expect(screen.getByRole('button', { name: /set alert/i })).toBeDisabled(); + }); + + it('should switch the alert type via the radio group', async () => { + renderModal(); + + const aboveOption = screen.getByText('Above'); + const changeOption = screen.getByText('Changes'); + + await userEvent.click(aboveOption); + expect(screen.getByText('Set Price Alert')).toBeInTheDocument(); + + // Selecting "Changes" should still render; no inputs should be lost + await userEvent.click(changeOption); + expect(screen.getByLabelText('Target Price (USD)')).toBeInTheDocument(); + }); + + it('should close the modal when the Cancel button is clicked', async () => { + const { onOpenChange } = renderModal(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await userEvent.click(cancelButton); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('should reflect an existing alert by pre-filling values and showing an Update button', () => { + renderModal({ + existingAlert: { alertType: 'above', targetPrice: 75, isActive: true }, + }); + + expect(screen.getByLabelText('Target Price (USD)')).toHaveValue(75); + expect(screen.getByRole('button', { name: /update alert/i })).toBeInTheDocument(); + }); + + it('should not render a submit button when closed', () => { + renderModal({ isOpen: false }); + + expect(screen.queryByText('Sunset Villa')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /set alert/i })).not.toBeInTheDocument(); + }); +}); \ No newline at end of file