From 03b4b9dfd517673ba762fa335bc87aae2306dc10 Mon Sep 17 00:00:00 2001
From: Charis Daniels
Date: Thu, 27 Aug 2026 12:13:51 +0000
Subject: [PATCH] test: add coverage for property, security, and KYC components
(#924, #925, #926, #927)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add Jest specs for DocumentSection and ShareButton (property document
rendering and share flows), TransactionSecuritySettings (security toggle
persistence), and the KYC components (step gating, rejection/retry states,
and audit log rendering).
Also repair the cacheManager test suite so it runs under `npm test`:
- add the missing `defineChain` export to the viem mock, which broke every
suite that transitively imports viem via chains.ts
- mock ethers' sha256/toUtf8Bytes in cacheManager.test.ts (ethers 6.16
returns Node Buffers that fail instanceof against the jsdom Uint8Array)
- fix the broken crypto.randomUUID polyfill in jest.setup.js so it emits a
valid UUID v4, and update a stale sync-queue id assertion
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
__mocks__/viem.js | 1 +
jest.setup.js | 14 +-
package-lock.json | 16 +
src/components/kyc/KycVerificationCenter.tsx | 1 +
.../kyc/__tests__/ComplianceAuditLog.test.tsx | 100 ++++++
.../kyc/__tests__/KycStatusBadge.test.tsx | 61 ++++
.../__tests__/KycVerificationCenter.test.tsx | 174 +++++++++
.../__tests__/DocumentSection.test.tsx | 119 ++++++
.../property/__tests__/ShareButton.test.tsx | 217 +++++++++++
.../TransactionSecuritySettings.test.tsx | 340 ++++++++++++++++++
src/lib/__tests__/cacheManager.test.ts | 24 +-
11 files changed, 1058 insertions(+), 9 deletions(-)
create mode 100644 src/components/kyc/__tests__/ComplianceAuditLog.test.tsx
create mode 100644 src/components/kyc/__tests__/KycStatusBadge.test.tsx
create mode 100644 src/components/kyc/__tests__/KycVerificationCenter.test.tsx
create mode 100644 src/components/property/__tests__/DocumentSection.test.tsx
create mode 100644 src/components/property/__tests__/ShareButton.test.tsx
create mode 100644 src/components/security/__tests__/TransactionSecuritySettings.test.tsx
diff --git a/__mocks__/viem.js b/__mocks__/viem.js
index c79247a1..d55bba2b 100644
--- a/__mocks__/viem.js
+++ b/__mocks__/viem.js
@@ -58,4 +58,5 @@ module.exports = {
formatEther: jest.fn((wei) => Number(wei) / 1e18),
parseEther: jest.fn((eth) => BigInt(Math.floor(Number(eth) * 1e18))),
parseUnits: jest.fn((val, decimals) => BigInt(Number(val) * Math.pow(10, decimals))),
+ defineChain: jest.fn((chain) => chain),
};
diff --git a/jest.setup.js b/jest.setup.js
index 3ea5e2ee..49a40b33 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -138,15 +138,13 @@ const sessionStorageMock = {
}
global.sessionStorage = sessionStorageMock
-// Polyfill crypto.randomUUID for jsdom (Node.js <19)
+// Polyfill crypto.randomUUID for jsdom (Node.js <19 / jsdom without randomUUID)
if (typeof globalThis.crypto !== 'undefined' && !globalThis.crypto.randomUUID) {
- let counter = BigInt(0);
globalThis.crypto.randomUUID = function randomUUID() {
- counter++;
- const hex = (counter + BigInt(Date.now()) * BigInt(100000)).toString(16);
- return hex.slice(0, 36).replace(
- /^(.{8})(.{4})(.{4})(.{4})(.{12})$/,
- '$1-$2-4$3-a$4-$5'
- );
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
}
}
diff --git a/package-lock.json b/package-lock.json
index 73944842..3e80170d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
"@tanstack/react-query-devtools": "^5.100.2",
"@tanstack/react-virtual": "^3.13.24",
"@testing-library/dom": "^10.4.1",
+ "@upstash/redis": "^1.38.0",
"@wagmi/connectors": "^7.1.2",
"@wagmi/core": "3.4.0",
"@walletconnect/web3-provider": "^1.8.0",
@@ -7718,6 +7719,15 @@
"win32"
]
},
+ "node_modules/@upstash/redis": {
+ "version": "1.38.3",
+ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.3.tgz",
+ "integrity": "sha512-vtS0BonQHU6kDSWvHTISh+LOuLIEk+jeMXebv20CDJ/aHGOG085SGa8OpI+I+Ow8WgPFhqH23XG8kQ2LXXRnag==",
+ "license": "MIT",
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
"node_modules/@vitest/browser": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz",
@@ -22544,6 +22554,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
diff --git a/src/components/kyc/KycVerificationCenter.tsx b/src/components/kyc/KycVerificationCenter.tsx
index eb367d43..ea1fc9a7 100644
--- a/src/components/kyc/KycVerificationCenter.tsx
+++ b/src/components/kyc/KycVerificationCenter.tsx
@@ -126,6 +126,7 @@ export function KycVerificationCenter() {
{
+ beforeEach(() => {
+ useKycStore.getState().resetKyc();
+ });
+
+ it('shows an empty state when there is no compliance activity', () => {
+ render(
);
+
+ expect(
+ screen.getByText('No compliance activity yet.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders a human-readable label for each audit entry', () => {
+ useKycStore.setState({
+ auditLog: [
+ {
+ id: 'log-1',
+ timestamp: '2026-08-01T10:00:00.000Z',
+ event: 'document_uploaded',
+ details: { name: 'passport.pdf' },
+ },
+ {
+ id: 'log-2',
+ timestamp: '2026-08-01T10:05:00.000Z',
+ event: 'verification_approved',
+ details: { provider: 'TrustLayer Mock' },
+ },
+ ],
+ });
+
+ render(
);
+
+ expect(screen.getByText('Document uploaded')).toBeInTheDocument();
+ expect(screen.getByText('Verification approved')).toBeInTheDocument();
+ expect(screen.queryByText('No compliance activity yet.')).not.toBeInTheDocument();
+ });
+
+ it('renders the event details for each entry', () => {
+ useKycStore.setState({
+ auditLog: [
+ {
+ id: 'log-1',
+ timestamp: '2026-08-01T10:00:00.000Z',
+ event: 'transaction_blocked',
+ details: { valueEth: 25, allowed: false },
+ },
+ ],
+ });
+
+ render(
);
+
+ expect(screen.getByText('Transaction blocked')).toBeInTheDocument();
+ expect(screen.getByText('valueEth: 25')).toBeInTheDocument();
+ expect(screen.getByText('allowed: false')).toBeInTheDocument();
+ });
+
+ it('covers all known event types with labels', () => {
+ const events: ComplianceLogEntry['event'][] = [
+ 'threshold_updated',
+ 'document_uploaded',
+ 'liveness_started',
+ 'liveness_passed',
+ 'liveness_failed',
+ 'verification_submitted',
+ 'verification_approved',
+ 'verification_rejected',
+ 'transaction_screened',
+ 'transaction_blocked',
+ ];
+
+ useKycStore.setState({
+ auditLog: events.map((event, index) => ({
+ id: `log-${index}`,
+ timestamp: `2026-08-01T10:0${index}:00.000Z`,
+ event,
+ details: {},
+ })),
+ });
+
+ render(
);
+
+ expect(screen.getByText('Threshold updated')).toBeInTheDocument();
+ expect(screen.getByText('Document uploaded')).toBeInTheDocument();
+ expect(screen.getByText('Liveness started')).toBeInTheDocument();
+ expect(screen.getByText('Liveness passed')).toBeInTheDocument();
+ expect(screen.getByText('Liveness failed')).toBeInTheDocument();
+ expect(screen.getByText('Verification submitted')).toBeInTheDocument();
+ expect(screen.getByText('Verification approved')).toBeInTheDocument();
+ expect(screen.getByText('Verification rejected')).toBeInTheDocument();
+ expect(screen.getByText('Transaction screened')).toBeInTheDocument();
+ expect(screen.getByText('Transaction blocked')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/kyc/__tests__/KycStatusBadge.test.tsx b/src/components/kyc/__tests__/KycStatusBadge.test.tsx
new file mode 100644
index 00000000..05f68e9f
--- /dev/null
+++ b/src/components/kyc/__tests__/KycStatusBadge.test.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { KycStatusBadge } from '../KycStatusBadge';
+
+describe('KycStatusBadge', () => {
+ it('renders the KYC required label for unverified status', () => {
+ render(
);
+
+ expect(screen.getByText('KYC required')).toBeInTheDocument();
+ });
+
+ it('renders the KYC pending label for pending status', () => {
+ render(
);
+
+ expect(screen.getByText('KYC pending')).toBeInTheDocument();
+ });
+
+ it('renders the KYC verified label for verified status', () => {
+ render(
);
+
+ expect(screen.getByText('KYC verified')).toBeInTheDocument();
+ });
+
+ it('renders the review needed label for rejected status', () => {
+ render(
);
+
+ expect(screen.getByText('KYC review needed')).toBeInTheDocument();
+ });
+
+ it('annotates the threshold in the title attribute', () => {
+ render(
);
+
+ expect(
+ screen.getByTitle('High-value transactions above 25 ETH require KYC review')
+ ).toBeInTheDocument();
+ });
+
+ it('uses a different title when already verified', () => {
+ render(
);
+
+ expect(
+ screen.getByTitle('High-value transactions above 25 ETH are allowed')
+ ).toBeInTheDocument();
+ });
+
+ it('renders the compact variant without the full label', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText('Verified')).toBeInTheDocument();
+ expect(screen.queryByText('KYC verified')).not.toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(screen.getByText('KYC')).toBeInTheDocument();
+ expect(screen.queryByText('KYC required')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/kyc/__tests__/KycVerificationCenter.test.tsx b/src/components/kyc/__tests__/KycVerificationCenter.test.tsx
new file mode 100644
index 00000000..cb1bfd70
--- /dev/null
+++ b/src/components/kyc/__tests__/KycVerificationCenter.test.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { KycVerificationCenter } from '../KycVerificationCenter';
+import { useKycStore } from '@/store/kycStore';
+import type { KycDocument } from '@/types/kyc';
+
+const makeFile = (name: string, type: string): File =>
+ new File(['content'], name, { type });
+
+describe('KycVerificationCenter', () => {
+ beforeEach(() => {
+ useKycStore.getState().resetKyc();
+ });
+
+ it('renders the provider and initial unverified status', () => {
+ render(
);
+
+ expect(
+ screen.getByText(/provider: trustlayer mock/i)
+ ).toBeInTheDocument();
+ expect(screen.getByText('unverified')).toBeInTheDocument();
+ expect(screen.getByText(/liveness: not_started/i)).toBeInTheDocument();
+ });
+
+ it('saves a new threshold through the store', () => {
+ render(
);
+
+ const input = screen.getByLabelText('KYC threshold (ETH)');
+ fireEvent.change(input, { target: { value: '25' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(useKycStore.getState().profile.thresholdEth).toBe(25);
+ expect(
+ useKycStore.getState().auditLog[0].event
+ ).toBe('threshold_updated');
+ });
+
+ it('records uploaded documents in the store', async () => {
+ render(
);
+
+ await act(async () => {
+ fireEvent.change(screen.getByTestId('kyc-file-input'), {
+ target: { files: [makeFile('passport.pdf', 'application/pdf')] },
+ });
+ });
+
+ const profile = useKycStore.getState().profile;
+ expect(profile.documents).toHaveLength(1);
+ expect(profile.documents[0].name).toBe('passport.pdf');
+ expect(profile.status).toBe('pending');
+ expect(
+ screen.getByText('1 document(s) uploaded')
+ ).toBeInTheDocument();
+ });
+
+ it('runs the liveness check and advances the step after it passes', async () => {
+ jest.useFakeTimers();
+ try {
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /run liveness check/i })
+ );
+
+ expect(useKycStore.getState().profile.livenessStatus).toBe('pending');
+
+ await act(async () => {
+ jest.advanceTimersByTime(1200);
+ });
+
+ expect(useKycStore.getState().profile.livenessStatus).toBe('passed');
+ expect(
+ screen.getByText('Liveness status: passed')
+ ).toBeInTheDocument();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('gates submission until documents and liveness are complete', async () => {
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /submit verification/i })
+ );
+
+ // Without documents or a passed liveness check the profile is rejected.
+ expect(useKycStore.getState().profile.status).toBe('rejected');
+ expect(
+ useKycStore.getState().profile.rejectedReason
+ ).toContain('Upload documents and pass liveness');
+ });
+
+ it('approves verification once all steps are complete', async () => {
+ const documents: KycDocument[] = [
+ {
+ id: 'doc-1',
+ name: 'passport.pdf',
+ type: 'application/pdf',
+ size: 1024,
+ uploadedAt: '2026-08-01T10:00:00.000Z',
+ },
+ ];
+ useKycStore.getState().addDocuments(documents);
+ useKycStore.getState().completeLivenessCheck(true);
+
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /submit verification/i })
+ );
+
+ await waitFor(() => {
+ expect(useKycStore.getState().profile.status).toBe('verified');
+ });
+
+ expect(
+ screen.getByRole('button', { name: /view compliance log/i })
+ ).toBeInTheDocument();
+ expect(
+ useKycStore.getState().auditLog.some(
+ (entry) => entry.event === 'verification_approved'
+ )
+ ).toBe(true);
+ });
+
+ it('shows a rejected state after a failed liveness attempt', () => {
+ useKycStore.getState().startLivenessCheck();
+ useKycStore.getState().completeLivenessCheck(false);
+
+ render(
);
+
+ expect(useKycStore.getState().profile.status).toBe('rejected');
+ expect(
+ screen.getByText('Liveness status: failed')
+ ).toBeInTheDocument();
+ });
+
+ it('resets the flow back to defaults', () => {
+ useKycStore.getState().addDocuments([
+ {
+ id: 'doc-1',
+ name: 'passport.pdf',
+ type: 'application/pdf',
+ size: 1024,
+ uploadedAt: '2026-08-01T10:00:00.000Z',
+ },
+ ]);
+
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
+
+ const profile = useKycStore.getState().profile;
+ expect(profile.documents).toHaveLength(0);
+ expect(profile.status).toBe('unverified');
+ });
+
+ it('updates the completion progress as steps are completed', async () => {
+ const { container } = render(
);
+
+ const indicator = container.querySelector('[data-slot="progress-indicator"]') as HTMLElement;
+ expect(indicator).toHaveStyle('transform: translateX(-100%)');
+
+ await act(async () => {
+ fireEvent.change(screen.getByTestId('kyc-file-input'), {
+ target: { files: [makeFile('passport.pdf', 'application/pdf')] },
+ });
+ });
+
+ // One of three steps complete → 33% filled.
+ expect(indicator.style.transform).toMatch(/translateX\(-66\.\d+%\)/);
+ });
+});
diff --git a/src/components/property/__tests__/DocumentSection.test.tsx b/src/components/property/__tests__/DocumentSection.test.tsx
new file mode 100644
index 00000000..52546f0d
--- /dev/null
+++ b/src/components/property/__tests__/DocumentSection.test.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import DocumentSection from '../DocumentSection';
+import type { PropertyDocument } from '../PdfViewerModal';
+
+// PdfViewerModal pulls in react-pdf, which is not installed, so we stub the
+// modal surface and assert on the props it receives.
+jest.mock('../PdfViewerModal', () => ({
+ __esModule: true,
+ default: ({
+ doc,
+ onClose,
+ }: {
+ doc: PropertyDocument;
+ onClose: () => void;
+ }) => (
+
+ Viewing: {doc.name}
+
+
+ ),
+}));
+
+const documents: PropertyDocument[] = [
+ {
+ id: 'doc-1',
+ name: 'Title Deed.pdf',
+ url: '/docs/title-deed.pdf',
+ category: 'Legal',
+ verified: true,
+ },
+ {
+ id: 'doc-2',
+ name: 'Inspection Report.pdf',
+ url: '/docs/inspection.pdf',
+ category: 'Inspection',
+ verified: false,
+ },
+];
+
+describe('DocumentSection', () => {
+ it('renders a heading per category that has documents', () => {
+ render(
);
+
+ expect(screen.getByRole('heading', { name: 'Legal' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: 'Inspection' })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render headings for categories without documents', () => {
+ render(
);
+
+ expect(
+ screen.queryByRole('heading', { name: 'Financial' })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: 'Photos' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders every document name under its category', () => {
+ render(
);
+
+ expect(screen.getByText('Title Deed.pdf')).toBeInTheDocument();
+ expect(screen.getByText('Inspection Report.pdf')).toBeInTheDocument();
+ });
+
+ it('shows the verification state for each document', () => {
+ render(
);
+
+ expect(screen.getByText('Verified')).toBeInTheDocument();
+ expect(screen.getByText('Pending Verification')).toBeInTheDocument();
+ });
+
+ it('renders a download link pointing at the document url', () => {
+ render(
);
+
+ const downloads = screen.getAllByRole('link', { name: 'Download' });
+ expect(downloads).toHaveLength(2);
+ expect(downloads[0]).toHaveAttribute('href', '/docs/title-deed.pdf');
+ expect(downloads[0]).toHaveAttribute('download');
+ expect(downloads[1]).toHaveAttribute('href', '/docs/inspection.pdf');
+ });
+
+ it('opens the pdf viewer modal with the selected document', () => {
+ render(
);
+
+ expect(
+ screen.queryByTestId('pdf-viewer-modal')
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'View' })[0]);
+
+ expect(screen.getByTestId('pdf-viewer-modal')).toBeInTheDocument();
+ expect(
+ screen.getByText('Viewing: Title Deed.pdf')
+ ).toBeInTheDocument();
+ });
+
+ it('closes the modal when the close action is triggered', () => {
+ render(
);
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'View' })[0]);
+ expect(screen.getByTestId('pdf-viewer-modal')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close modal' }));
+ expect(
+ screen.queryByTestId('pdf-viewer-modal')
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when there are no documents', () => {
+ const { container } = render(
);
+
+ expect(container.querySelector('section')).not.toBeInTheDocument();
+ expect(container.textContent).toBe('');
+ });
+});
diff --git a/src/components/property/__tests__/ShareButton.test.tsx b/src/components/property/__tests__/ShareButton.test.tsx
new file mode 100644
index 00000000..7673c15c
--- /dev/null
+++ b/src/components/property/__tests__/ShareButton.test.tsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { ShareButton } from '../ShareButton';
+import { toast } from 'sonner';
+
+jest.mock('sonner', () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ warning: jest.fn(),
+ },
+}));
+
+const property = {
+ id: 'prop-1',
+ name: 'Lakeside Villa',
+ location: { city: 'Austin', state: 'TX' },
+ price: { total: 2.5, perToken: 0.1 },
+ images: ['/images/lakeside.jpg'],
+ metrics: { roi: 12 },
+};
+
+const shareUrl = `${window.location.origin}/properties/prop-1`;
+
+describe('ShareButton', () => {
+ let openSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+ });
+
+ afterEach(() => {
+ openSpy.mockRestore();
+ // Remove native share support so it does not leak between tests.
+ delete (navigator as { share?: unknown }).share;
+ });
+
+ it('renders the share trigger button', () => {
+ render(
);
+
+ expect(
+ screen.getByRole('button', { name: /share/i })
+ ).toBeInTheDocument();
+ });
+
+ it('opens the share dialog when the trigger is clicked', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+
+ expect(
+ await screen.findByRole('heading', { name: 'Share Property' })
+ ).toBeInTheDocument();
+ });
+
+ it('shows a property preview inside the dialog', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ expect(screen.getByText('Lakeside Villa')).toBeInTheDocument();
+ expect(screen.getByText('Austin, TX')).toBeInTheDocument();
+ expect(screen.getByText('2.5 ETH')).toBeInTheDocument();
+ expect(screen.getByText('12% ROI')).toBeInTheDocument();
+ });
+
+ it('opens the twitter share intent when twitter is clicked', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /twitter/i }));
+
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('twitter.com/intent/tweet'),
+ '_blank',
+ expect.any(String)
+ );
+ expect(toast.success).toHaveBeenCalledWith(
+ 'Opening Twitter share dialog...'
+ );
+ });
+
+ it('opens the linkedin share intent when linkedin is clicked', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /linkedin/i }));
+
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('linkedin.com/sharing/share-offsite'),
+ '_blank',
+ expect.any(String)
+ );
+ });
+
+ it('opens a mailto link when email is clicked', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /email/i }));
+
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('mailto:')
+ );
+ });
+
+ it('copies the property link to the clipboard', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /copy link/i }));
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(shareUrl);
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ expect(toast.success).toHaveBeenCalledWith('Link copied to clipboard!');
+ });
+
+ it('falls back to copying the link when native share is unsupported', async () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ expect(
+ screen.queryByRole('button', { name: /native share/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows and invokes the native share option when supported', async () => {
+ const shareMock = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: shareMock,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ const nativeButton = screen.getByRole('button', { name: /native share/i });
+ expect(nativeButton).toBeInTheDocument();
+
+ fireEvent.click(nativeButton);
+
+ await waitFor(() => {
+ expect(shareMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Lakeside Villa',
+ url: shareUrl,
+ })
+ );
+ });
+ expect(toast.success).toHaveBeenCalledWith(
+ 'Property shared successfully!'
+ );
+ });
+
+ it('silently ignores a cancelled native share (AbortError)', async () => {
+ const shareMock = jest.fn().mockRejectedValue(
+ Object.assign(new Error('aborted'), { name: 'AbortError' })
+ );
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: shareMock,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /native share/i }));
+
+ await waitFor(() => {
+ expect(shareMock).toHaveBeenCalled();
+ });
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it('shows an error toast when the native share fails', async () => {
+ const shareMock = jest
+ .fn()
+ .mockRejectedValue(new Error('share unavailable'));
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: shareMock,
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: /share/i }));
+ await screen.findByRole('heading', { name: 'Share Property' });
+
+ fireEvent.click(screen.getByRole('button', { name: /native share/i }));
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith('Failed to share property');
+ });
+ });
+});
diff --git a/src/components/security/__tests__/TransactionSecuritySettings.test.tsx b/src/components/security/__tests__/TransactionSecuritySettings.test.tsx
new file mode 100644
index 00000000..c177c1c6
--- /dev/null
+++ b/src/components/security/__tests__/TransactionSecuritySettings.test.tsx
@@ -0,0 +1,340 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { TransactionSecuritySettings } from '../TransactionSecuritySettings';
+import { useTransactionSecurityStore } from '@/store/transactionSecurityStore';
+import { toast } from 'sonner';
+
+jest.mock('sonner', () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ warning: jest.fn(),
+ },
+}));
+
+jest.mock('@/store/transactionSecurityStore', () => ({
+ useTransactionSecurityStore: jest.fn(),
+}));
+
+jest.mock('qrcode', () => ({
+ toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,QUJD'),
+}));
+
+jest.mock('@/utils/security/transactionSecurity', () => ({
+ buildOtpAuthUri: jest.fn(
+ ({ issuer, accountName }: { issuer: string; accountName: string }) =>
+ `otpauth://totp/${issuer}:${accountName}?secret=SECRET`
+ ),
+ formatTrustedDeviceExpiry: jest.fn((timestamp: number) =>
+ new Date(timestamp).toLocaleDateString()
+ ),
+ getSecurityDeviceId: jest.fn(() => 'device-test-1'),
+ getSecurityDeviceLabel: jest.fn(() => 'Test Browser'),
+}));
+
+jest.mock('@/utils/security/totp', () => ({
+ normalizeTotpCode: jest.fn((code: string) => code.trim()),
+}));
+
+const defaultSettings = {
+ thresholdEth: 2,
+ twoFactorRequired: true,
+ totpEnabled: true,
+ hardwareWalletEnabled: true,
+ trustedDeviceBypass: true,
+ trustedDeviceDurationDays: 30,
+ totpIssuer: 'PropChain',
+ totpAccountLabel: 'Primary wallet',
+ totpSecret: null,
+};
+
+const storeState = {
+ settings: { ...defaultSettings },
+ trustedDevices: [] as { id: string; label: string; trustUntil: number }[],
+ lastVerifiedAt: null as number | null,
+ lastVerificationMethod: null as string | null,
+ updateSettings: jest.fn(),
+ enrollTotp: jest.fn(() => ({
+ secret: 'MOCK-SECRET',
+ otpauthUri: 'otpauth://totp/PropChain:Primary%20wallet?secret=MOCK-SECRET',
+ })),
+ verifyTotpCode: jest.fn().mockResolvedValue(true),
+ trustDevice: jest.fn(() => ({
+ id: 'device-test-1',
+ label: 'Test Browser',
+ createdAt: 1,
+ lastUsedAt: 1,
+ trustUntil: Date.now() + 30 * 24 * 60 * 60 * 1000,
+ })),
+ revokeTrustedDevice: jest.fn(),
+ clearTrustedDevices: jest.fn(),
+ setLastVerification: jest.fn(),
+ resetSecurity: jest.fn(),
+ getActiveTrustedDevice: jest.fn(() => null),
+};
+
+const mockedStore = useTransactionSecurityStore as jest.Mock;
+
+describe('TransactionSecuritySettings', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ storeState.settings = { ...defaultSettings };
+ storeState.trustedDevices = [];
+ storeState.lastVerifiedAt = null;
+ storeState.lastVerificationMethod = null;
+ storeState.getActiveTrustedDevice.mockReturnValue(null);
+ mockedStore.mockReturnValue(storeState);
+ });
+
+ it('renders the security section with the store settings reflected', () => {
+ render(
);
+
+ expect(screen.getByText('Transaction Security')).toBeInTheDocument();
+
+ const switches = screen.getAllByRole('switch');
+ expect(switches).toHaveLength(4);
+ expect(switches[0]).toHaveAttribute('data-state', 'checked');
+ expect(switches[1]).toHaveAttribute('data-state', 'checked');
+ expect(switches[2]).toHaveAttribute('data-state', 'checked');
+ expect(switches[3]).toHaveAttribute('data-state', 'checked');
+ });
+
+ it('persists the step-up verification toggle via the store', () => {
+ render(
);
+
+ fireEvent.click(screen.getAllByRole('switch')[0]);
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ twoFactorRequired: false,
+ });
+ });
+
+ it('persists the totp toggle via the store', () => {
+ render(
);
+
+ fireEvent.click(screen.getAllByRole('switch')[1]);
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ totpEnabled: false,
+ });
+ });
+
+ it('persists the hardware wallet toggle via the store', () => {
+ render(
);
+
+ fireEvent.click(screen.getAllByRole('switch')[2]);
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ hardwareWalletEnabled: false,
+ });
+ });
+
+ it('persists the trusted device bypass toggle via the store', () => {
+ render(
);
+
+ fireEvent.click(screen.getAllByRole('switch')[3]);
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ trustedDeviceBypass: false,
+ });
+ });
+
+ it('reflects the persisted enabled state of each toggle on render', () => {
+ storeState.settings = {
+ ...defaultSettings,
+ twoFactorRequired: false,
+ totpEnabled: false,
+ hardwareWalletEnabled: false,
+ trustedDeviceBypass: false,
+ };
+
+ render(
);
+
+ const switches = screen.getAllByRole('switch');
+ switches.forEach((sw) =>
+ expect(sw).toHaveAttribute('data-state', 'unchecked')
+ );
+
+ // Current policy summary mirrors the saved settings.
+ expect(screen.getByText('2FA off')).toBeInTheDocument();
+ expect(screen.queryByText('2FA on')).not.toBeInTheDocument();
+ });
+
+ it('shows the current policy summary from persisted settings', () => {
+ render(
);
+
+ expect(screen.getByText('2FA on')).toBeInTheDocument();
+ // Threshold appears both in the header badge and the policy summary.
+ expect(screen.getAllByText('2.00 ETH').length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText('Enabled')).toHaveLength(3); // TOTP, hardware wallet, trusted bypass
+ });
+
+ it('saves a new high-value threshold', () => {
+ render(
);
+
+ const input = screen.getByRole('spinbutton');
+ fireEvent.change(input, { target: { value: '5' } });
+ fireEvent.click(screen.getByRole('button', { name: /save threshold/i }));
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ thresholdEth: 5,
+ });
+ expect(toast.success).toHaveBeenCalledWith('Security threshold saved');
+ });
+
+ it('clamps out-of-range thresholds to the allowed bounds', () => {
+ render(
);
+
+ const input = screen.getByRole('spinbutton');
+ fireEvent.change(input, { target: { value: '99' } });
+ fireEvent.click(screen.getByRole('button', { name: /save threshold/i }));
+
+ expect(storeState.updateSettings).toHaveBeenCalledWith({
+ thresholdEth: 25,
+ });
+ });
+
+ it('keeps the trust-this-browser button disabled until a verification exists', () => {
+ storeState.lastVerifiedAt = null;
+ render(
);
+
+ const trustButton = screen.getByRole('button', {
+ name: /trust this browser/i,
+ });
+ expect(trustButton).toBeDisabled();
+
+ fireEvent.click(trustButton);
+ expect(storeState.trustDevice).not.toHaveBeenCalled();
+ });
+
+ it('trusts the current browser once a verification exists', () => {
+ storeState.lastVerifiedAt = Date.now();
+ storeState.lastVerificationMethod = 'totp';
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /trust this browser/i })
+ );
+
+ expect(storeState.trustDevice).toHaveBeenCalledWith(
+ 'device-test-1',
+ 'Test Browser'
+ );
+ expect(toast.success).toHaveBeenCalledWith(
+ expect.stringContaining('Trusted')
+ );
+ });
+
+ it('shows the last verification method banner', () => {
+ storeState.lastVerifiedAt = Date.now();
+ storeState.lastVerificationMethod = 'totp';
+ render(
);
+
+ expect(
+ screen.getByText(/last verified via totp/i)
+ ).toBeInTheDocument();
+ });
+
+ it('lists trusted devices and revokes them', () => {
+ storeState.trustedDevices = [
+ {
+ id: 'device-a',
+ label: 'Work laptop',
+ trustUntil: Date.now() + 1000,
+ },
+ ];
+ render(
);
+
+ expect(screen.getByText('Work laptop')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: '' }));
+
+ expect(storeState.revokeTrustedDevice).toHaveBeenCalledWith('device-a');
+ });
+
+ it('shows the active trusted device state', () => {
+ storeState.trustedDevices = [
+ {
+ id: 'device-test-1',
+ label: 'Test Browser',
+ trustUntil: Date.now() + 30 * 24 * 60 * 60 * 1000,
+ },
+ ];
+
+ render(
);
+
+ expect(screen.getByText('Trusted now')).toBeInTheDocument();
+ // Appears in the active-device card and again in the devices list.
+ expect(screen.getAllByText('Test Browser').length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('enrolls a totp secret and copies it to the clipboard', async () => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /set up authenticator/i })
+ );
+
+ expect(storeState.enrollTotp).toHaveBeenCalled();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('MOCK-SECRET');
+ expect(toast.success).toHaveBeenCalledWith('Authenticator setup started');
+ });
+
+ it('shows the qr code and secret after enrolling totp', async () => {
+ storeState.settings = {
+ ...defaultSettings,
+ totpSecret: 'MOCK-SECRET',
+ };
+
+ render(
);
+
+ expect(await screen.findByText('MOCK-SECRET')).toBeInTheDocument();
+ expect(
+ await screen.findByAltText('Authenticator QR code')
+ ).toBeInTheDocument();
+ });
+
+ it('disables the verify button until a 6-digit code is entered', () => {
+ storeState.settings = {
+ ...defaultSettings,
+ totpSecret: 'MOCK-SECRET',
+ };
+
+ render(
);
+
+ expect(
+ screen.getByRole('button', { name: /verify authenticator/i })
+ ).toBeDisabled();
+ });
+
+ it('copies the secret with the copy button', async () => {
+ storeState.settings = {
+ ...defaultSettings,
+ totpSecret: 'MOCK-SECRET',
+ };
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText } });
+
+ render(
);
+
+ await waitFor(() => expect(screen.getByText('MOCK-SECRET')).toBeInTheDocument());
+
+ const copyButtons = screen
+ .getAllByRole('button')
+ .filter((button) => button.querySelector('svg'));
+ fireEvent.click(copyButtons[copyButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('MOCK-SECRET');
+ });
+ expect(toast.success).toHaveBeenCalledWith(
+ 'Authenticator secret copied'
+ );
+ });
+});
diff --git a/src/lib/__tests__/cacheManager.test.ts b/src/lib/__tests__/cacheManager.test.ts
index 9b9318c7..1178d91f 100644
--- a/src/lib/__tests__/cacheManager.test.ts
+++ b/src/lib/__tests__/cacheManager.test.ts
@@ -59,6 +59,26 @@ function makeTrackableStorage() {
};
}
+/* -------------------------------------------------------------------------- */
+/* ethers mock */
+/* -------------------------------------------------------------------------- */
+/* ethers' sha256 hashes via node:crypto, which returns a Node Buffer. In the
+ * jsdom test environment Buffer is a different realm from the global
+ * Uint8Array, so ethers' internal instanceof check rejects the digest and
+ * sha256 always throws. Provide deterministic equivalents so the sync-queue
+ * dedupe logic can be exercised. */
+
+jest.mock('ethers', () => {
+ const actual = jest.requireActual('ethers');
+ const { createHash } = jest.requireActual('node:crypto');
+ return {
+ ...actual,
+ toUtf8Bytes: (value: string) => Buffer.from(value, 'utf8'),
+ sha256: (data: Uint8Array) =>
+ '0x' + createHash('sha256').update(Buffer.from(data)).digest('hex'),
+ };
+});
+
/* -------------------------------------------------------------------------- */
/* propertyCache mock */
/* -------------------------------------------------------------------------- */
@@ -302,7 +322,9 @@ describe('cacheManager', () => {
retries: 0,
timestamp: expect.any(Number),
});
- expect(queue[0].id).toMatch(/^\d+-[a-z0-9]+$/);
+ // Items are identified with a UUID (crypto.randomUUID), not the legacy
+ // `Date.now()-random` scheme.
+ expect(queue[0].id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('clears the queue', () => {