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