+
@@ -302,6 +303,19 @@ function SavedSearchesContent() {
)}
+
+ {/* Saved Searches Grid */}
+ {!isLoading && filteredAndSortedSearches.length > 0 && (
+
+ {filteredAndSortedSearches.map((search) => (
+ handleDeleteSearch(search.id)}
+ />
+ ))}
+
+ )}
);
diff --git a/src/store/__tests__/walletPersistence.test.ts b/src/store/__tests__/walletPersistence.test.ts
new file mode 100644
index 00000000..4722f681
--- /dev/null
+++ b/src/store/__tests__/walletPersistence.test.ts
@@ -0,0 +1,176 @@
+import { act } from '@testing-library/react';
+import {
+ createWalletStore,
+ useWalletStore,
+ WALLET_STORAGE_KEY,
+ WALLET_STORE_VERSION,
+ WALLET_SESSION_TTL_MS,
+} from '../walletStore';
+
+jest.mock('@/config/chains', () => ({
+ DEFAULT_CHAIN_ID: 1,
+ CHAIN_IDS: { ETHEREUM: 1, POLYGON: 137, BSC: 56, FOUNDRY: 31337 },
+}));
+
+describe('WalletPersistence and restore behavior', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useWalletStore.getState().reset();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe('persist on connect', () => {
+ it('persists connected wallet state and metadata to localStorage', () => {
+ const store = createWalletStore(true);
+
+ act(() => {
+ store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask', 137);
+ });
+
+ const raw = localStorage.getItem(WALLET_STORAGE_KEY);
+ expect(raw).not.toBeNull();
+
+ const parsed = JSON.parse(raw!);
+ expect(parsed.version).toBe(WALLET_STORE_VERSION);
+ expect(parsed.state.isConnected).toBe(true);
+ expect(parsed.state.address).toBe('0x1234567890123456789012345678901234567890');
+ expect(parsed.state.walletType).toBe('metamask');
+ expect(parsed.state.chainId).toBe(137);
+ expect(typeof parsed.state.lastUpdated).toBe('number');
+ });
+
+ it('persists account switch when connecting with a different address', () => {
+ const store = createWalletStore(true);
+
+ act(() => {
+ store.getState().setConnected('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'metamask', 1);
+ });
+
+ let raw = localStorage.getItem(WALLET_STORAGE_KEY);
+ expect(JSON.parse(raw!).state.address).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+
+ act(() => {
+ store.getState().setConnected('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'metamask', 1);
+ });
+
+ raw = localStorage.getItem(WALLET_STORAGE_KEY);
+ expect(JSON.parse(raw!).state.address).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
+ });
+ });
+
+ describe('restore on init', () => {
+ it('restores connected state from localStorage across reload/re-init', () => {
+ const existingState = {
+ state: {
+ isConnected: true,
+ address: '0x1111111111111111111111111111111111111111',
+ walletType: 'walletconnect',
+ chainId: 56,
+ lastUpdated: Date.now(),
+ },
+ version: WALLET_STORE_VERSION,
+ };
+ localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(existingState));
+
+ const newStore = createWalletStore(true);
+ const state = newStore.getState();
+
+ expect(state.isConnected).toBe(true);
+ expect(state.address).toBe('0x1111111111111111111111111111111111111111');
+ expect(state.walletType).toBe('walletconnect');
+ expect(state.chainId).toBe(56);
+ });
+
+ it('clears state on init if session has expired beyond TTL', () => {
+ const expiredTimestamp = Date.now() - (WALLET_SESSION_TTL_MS + 60000); // Expired 1 min ago
+ const expiredState = {
+ state: {
+ isConnected: true,
+ address: '0xexpired111111111111111111111111111111111',
+ walletType: 'metamask',
+ chainId: 1,
+ lastUpdated: expiredTimestamp,
+ },
+ version: WALLET_STORE_VERSION,
+ };
+ localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(expiredState));
+
+ const newStore = createWalletStore(true);
+ const state = newStore.getState();
+
+ expect(state.isConnected).toBe(false);
+ expect(state.address).toBeNull();
+ });
+ });
+
+ describe('version and compatibility failure handling', () => {
+ it('resets state and clears storage if version mismatch is encountered', () => {
+ const outdatedState = {
+ state: {
+ isConnected: true,
+ address: '0xoldversion111111111111111111111111111111',
+ walletType: 'metamask',
+ chainId: 1,
+ lastUpdated: Date.now(),
+ },
+ version: 999, // Incompatible version
+ };
+ localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(outdatedState));
+
+ const newStore = createWalletStore(true);
+ const state = newStore.getState();
+
+ expect(state.isConnected).toBe(false);
+ expect(state.address).toBeNull();
+ expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
+ });
+
+ it('handles corrupted JSON payload in localStorage gracefully', () => {
+ localStorage.setItem(WALLET_STORAGE_KEY, 'not-valid-json{[[[');
+
+ expect(() => {
+ const store = createWalletStore(true);
+ expect(store.getState().isConnected).toBe(false);
+ }).not.toThrow();
+ });
+ });
+
+ describe('disconnect and clear paths', () => {
+ it('clears storage item on setDisconnected', () => {
+ const store = createWalletStore(true);
+
+ act(() => {
+ store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask');
+ });
+ expect(localStorage.getItem(WALLET_STORAGE_KEY)).not.toBeNull();
+
+ act(() => {
+ store.getState().setDisconnected();
+ });
+
+ expect(store.getState().isConnected).toBe(false);
+ expect(store.getState().address).toBeNull();
+ expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
+ });
+
+ it('clears storage item on reset', () => {
+ const store = createWalletStore(true);
+
+ act(() => {
+ store.getState().setConnected('0x1234567890123456789012345678901234567890', 'metamask');
+ });
+ expect(localStorage.getItem(WALLET_STORAGE_KEY)).not.toBeNull();
+
+ act(() => {
+ store.getState().reset();
+ });
+
+ expect(store.getState().isConnected).toBe(false);
+ expect(store.getState().address).toBeNull();
+ expect(localStorage.getItem(WALLET_STORAGE_KEY)).toBeNull();
+ });
+ });
+});
diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts
index 76c24ed5..81be2edd 100644
--- a/src/store/walletStore.ts
+++ b/src/store/walletStore.ts
@@ -8,10 +8,15 @@
* Long-term: migrate components to use wagmi hooks directly and remove this store.
*/
-import { create } from 'zustand';
+import { create, type StateCreator } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
import { DEFAULT_CHAIN_ID } from '@/config/chains';
import type { ChainId } from '@/config/chains';
+export const WALLET_STORAGE_KEY = 'propchain-wallet-state';
+export const WALLET_STORE_VERSION = 1;
+export const WALLET_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
export type WalletType = 'metamask' | 'walletconnect' | 'coinbase' | null;
export interface WalletState {
@@ -43,7 +48,7 @@ export interface WalletActions {
export type WalletStore = WalletState & WalletActions;
-export const useWalletStore = create()((set) => ({
+const storeCreator: StateCreator = (set) => ({
isConnected: false,
address: null,
walletType: null,
@@ -68,6 +73,13 @@ export const useWalletStore = create()((set) => ({
},
setDisconnected: () => {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ try {
+ window.localStorage.removeItem(WALLET_STORAGE_KEY);
+ } catch {
+ // Ignore storage errors in restricted contexts
+ }
+ }
set({
isConnected: false,
address: null,
@@ -110,16 +122,101 @@ export const useWalletStore = create()((set) => ({
setLastUpdated: (timestamp: number) => set({ lastUpdated: timestamp }),
- reset: () => set({
- isConnected: false,
- address: null,
- walletType: null,
- chainId: DEFAULT_CHAIN_ID,
- isConnecting: false,
- isSwitchingNetwork: false,
- error: null,
- balance: null,
- isLoading: false,
- lastUpdated: null,
- }),
-}));
+ reset: () => {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ try {
+ window.localStorage.removeItem(WALLET_STORAGE_KEY);
+ } catch {
+ // Ignore storage errors in restricted contexts
+ }
+ }
+ set({
+ isConnected: false,
+ address: null,
+ walletType: null,
+ chainId: DEFAULT_CHAIN_ID,
+ isConnecting: false,
+ isSwitchingNetwork: false,
+ error: null,
+ balance: null,
+ isLoading: false,
+ lastUpdated: null,
+ });
+ },
+});
+
+export const createWalletStore = (shouldPersist = true) => {
+ if (shouldPersist && typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
+ return create()(
+ persist(storeCreator, {
+ name: WALLET_STORAGE_KEY,
+ version: WALLET_STORE_VERSION,
+ storage: {
+ getItem: (name: string) => {
+ try {
+ const str = window.localStorage.getItem(name);
+ return str ? JSON.parse(str) : null;
+ } catch {
+ return null;
+ }
+ },
+ setItem: (name: string, value: unknown) => {
+ try {
+ const val = value as { state?: { isConnected?: boolean } } | null;
+ if (!val?.state?.isConnected) {
+ window.localStorage.removeItem(name);
+ } else {
+ window.localStorage.setItem(name, JSON.stringify(value));
+ }
+ } catch {
+ // Ignore storage write errors
+ }
+ },
+ removeItem: (name: string) => {
+ try {
+ window.localStorage.removeItem(name);
+ } catch {
+ // Ignore storage removal errors
+ }
+ },
+ },
+ partialize: (state) => ({
+ isConnected: state.isConnected,
+ address: state.address,
+ walletType: state.walletType,
+ chainId: state.chainId,
+ lastUpdated: state.lastUpdated,
+ }),
+ migrate: (persistedState: unknown, version: number) => {
+ if (version !== WALLET_STORE_VERSION || !persistedState) {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ try {
+ window.localStorage.removeItem(WALLET_STORAGE_KEY);
+ } catch {
+ // Ignore storage errors
+ }
+ }
+ return {
+ isConnected: false,
+ address: null,
+ walletType: null,
+ chainId: DEFAULT_CHAIN_ID,
+ lastUpdated: null,
+ };
+ }
+ return persistedState;
+ },
+ onRehydrateStorage: () => (state) => {
+ if (!state) return;
+ if (state.lastUpdated && Date.now() - state.lastUpdated > WALLET_SESSION_TTL_MS) {
+ state.setDisconnected();
+ }
+ },
+ })
+ );
+ }
+ return create()(storeCreator);
+};
+
+export const useWalletStore = createWalletStore(true);
+
diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts
index 9f7d45a9..c7d12369 100644
--- a/src/utils/searchUtils.ts
+++ b/src/utils/searchUtils.ts
@@ -19,14 +19,18 @@ const getResolvedLocale = (locale?: string): string => {
*/
/**
- * Convert search filters to URL parameters
- */
-/**
- * Converts search filters and sort options to a URL query string.
- *
- * @param filters - The search filters to convert.
- * @param sortBy - The current sort option.
- * @returns A URL-encoded query string.
+ * Converts search filters and sort options into a URL-encoded query string.
+ * Parameter keys are canonicalized and invariant to user locale.
+ *
+ * @param filters - The search filters object containing query, price range, property types, etc.
+ * @param sortBy - The current property sort option to include in parameters.
+ * @returns A URL-encoded query string representation of the filters and sort option.
+ *
+ * @example
+ * ```ts
+ * const queryStr = filtersToUrlParams({ query: 'penthouse', priceRange: [1000, 5000000], propertyTypes: ['residential'] }, 'price-asc');
+ * // returns "q=penthouse&minPrice=1000&maxPrice=5000000&types=residential&sort=price-asc"
+ * ```
*/
export function filtersToUrlParams(filters: SearchFilters, sortBy: SortOption): string {
const params = new URLSearchParams();
@@ -49,10 +53,18 @@ export function filtersToUrlParams(filters: SearchFilters, sortBy: SortOption):
}
/**
- * Parses URL query parameters into search filters and sort options.
- *
- * @param searchParams - The URL search parameters.
- * @returns An object containing partial filters and the sort option.
+ * Parses URL query parameters into partial search filters and a sort option.
+ * Accepts standard URL parameter keys invariant across locales.
+ *
+ * @param searchParams - The URLSearchParams instance containing query keys to parse.
+ * @returns An object containing the parsed partial `filters` and the active `sortBy` option.
+ *
+ * @example
+ * ```ts
+ * const params = new URLSearchParams('q=condo&minPrice=200000&sort=newest');
+ * const { filters, sortBy } = urlParamsToFilters(params);
+ * // filters: { query: 'condo', priceRange: [200000, 10000000] }, sortBy: 'newest'
+ * ```
*/
export function urlParamsToFilters(searchParams: URLSearchParams): {
filters: Partial;
@@ -119,11 +131,20 @@ export function urlParamsToFilters(searchParams: URLSearchParams): {
}
/**
- * Formats a numeric price for display with currency.
- *
- * @param price - The numeric price.
- * @param currency - The currency code (default: 'USD').
- * @returns The formatted price string.
+ * Formats a numeric price for localized currency display.
+ * Resolves locale using the provided `locale` argument, falling back to
+ * `navigator.language` in browser environments or 'en-US' as default.
+ *
+ * @param price - The numeric price value to format.
+ * @param currency - The ISO currency code (defaults to 'USD').
+ * @param locale - Optional BCP 47 language tag (e.g. 'en-US', 'de-DE'). Falls back to browser/environment locale.
+ * @returns Formatted currency string according to the resolved locale and currency.
+ *
+ * @example
+ * ```ts
+ * formatPrice(250000, 'USD', 'en-US'); // "$250,000"
+ * formatPrice(250000, 'EUR', 'de-DE'); // "250.000 €"
+ * ```
*/
export function formatPrice(price: number, currency: string = 'USD', locale?: string): string {
return new Intl.NumberFormat(getResolvedLocale(locale), {
@@ -135,30 +156,53 @@ export function formatPrice(price: number, currency: string = 'USD', locale?: st
}
/**
- * Formats a number with comma separators.
- *
- * @param num - The number to format.
- * @returns The formatted number string.
+ * Formats a number with locale-sensitive grouping separators.
+ * Resolves locale using the provided `locale` argument, falling back to
+ * `navigator.language` in browser environments or 'en-US' as default.
+ *
+ * @param num - The numeric value to format.
+ * @param locale - Optional BCP 47 language tag (e.g. 'en-US', 'fr-FR'). Falls back to browser/environment locale.
+ * @returns Formatted number string with appropriate group separators for the locale.
+ *
+ * @example
+ * ```ts
+ * formatNumber(1250000, 'en-US'); // "1,250,000"
+ * formatNumber(1250000, 'fr-FR'); // "1 250 000"
+ * ```
*/
export function formatNumber(num: number, locale?: string): string {
return new Intl.NumberFormat(getResolvedLocale(locale)).format(num);
}
/**
- * Formats a return on investment (ROI) value as a percentage.
- *
- * @param roi - The ROI value.
- * @returns The formatted ROI string.
+ * Formats a return on investment (ROI) number as a percentage string with 1 decimal place.
+ *
+ * @param roi - The numeric return on investment percentage (e.g. 8.5 for 8.5%).
+ * @returns Formatted percentage string ending with '%'.
+ *
+ * @example
+ * ```ts
+ * formatROI(8.54); // "8.5%"
+ * formatROI(12); // "12.0%"
+ * ```
*/
export function formatROI(roi: number): string {
return `${roi.toFixed(1)}%`;
}
/**
- * Formats a date string for user-friendly display.
- *
- * @param dateString - The ISO date string.
- * @returns The formatted date string.
+ * Formats an ISO date string for localized, human-friendly date display.
+ * Resolves locale using the provided `locale` argument, falling back to
+ * `navigator.language` in browser environments or 'en-US' as default.
+ *
+ * @param dateString - The ISO date string or date-compatible string to format.
+ * @param locale - Optional BCP 47 language tag (e.g. 'en-US', 'es-ES'). Falls back to browser/environment locale.
+ * @returns Localized date string formatted with numeric year, short month, and numeric day.
+ *
+ * @example
+ * ```ts
+ * formatDate('2024-01-15T00:00:00Z', 'en-US'); // "Jan 15, 2024"
+ * ```
*/
export function formatDate(dateString: string, locale?: string): string {
const date = new Date(dateString);
@@ -170,10 +214,16 @@ export function formatDate(dateString: string, locale?: string): string {
}
/**
- * Calculates and returns a relative "time ago" string from a date.
- *
- * @param dateString - The ISO date string.
- * @returns A human-readable time ago string.
+ * Calculates and returns a relative elapsed time ("time ago") string from a date.
+ *
+ * @param dateString - The ISO date string to compare against the current time.
+ * @returns Human-readable relative time interval (e.g. "Just now", "2 hours ago", "3 days ago").
+ *
+ * @example
+ * ```ts
+ * timeAgo(new Date(Date.now() - 3600 * 1000).toISOString()); // "1 hour ago"
+ * timeAgo(new Date().toISOString()); // "Just now"
+ * ```
*/
export function timeAgo(dateString: string): string {
const date = new Date(dateString);
@@ -200,11 +250,17 @@ export function timeAgo(dateString: string): string {
}
/**
- * Truncates text to a maximum length and adds an ellipsis.
- *
- * @param text - The text to truncate.
- * @param maxLength - The maximum allowed length.
- * @returns The truncated text.
+ * Truncates text to a specified maximum length, appending an ellipsis ('...') if trimmed.
+ *
+ * @param text - The input string to truncate.
+ * @param maxLength - Maximum permitted character length including the ellipsis.
+ * @returns Truncated string with ellipsis if length exceeded, or original text if within limit.
+ *
+ * @example
+ * ```ts
+ * truncateText('Luxury Manhattan Penthouse with Terrace', 20); // "Luxury Manhattan..."
+ * truncateText('Short text', 20); // "Short text"
+ * ```
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
@@ -213,10 +269,16 @@ export function truncateText(text: string, maxLength: number): string {
}
/**
- * Returns the hex color code associated with a blockchain.
- *
- * @param blockchain - The name of the blockchain.
- * @returns The hex color code.
+ * Returns the hex brand color code associated with a supported blockchain network.
+ *
+ * @param blockchain - The blockchain identifier (e.g. 'ethereum', 'polygon', 'bsc').
+ * @returns Hex color code string for the network, or fallback gray ('#666666') if unrecognized.
+ *
+ * @example
+ * ```ts
+ * getBlockchainColor('ethereum'); // "#627EEA"
+ * getBlockchainColor('polygon'); // "#8247E5"
+ * ```
*/
export function getBlockchainColor(blockchain: string): string {
const colors: Record = {
@@ -228,34 +290,53 @@ export function getBlockchainColor(blockchain: string): string {
}
/**
- * Returns a descriptive icon label for a property type.
- * Note: Rendering the actual icon (lucide-react) should be done
- * at the component level. This utility returns the type string for
- * use with icon mapping components.
- *
- * @param type - The property type.
- * @returns The property type label.
+ * Returns the property type identifier label for icon resolution.
+ * UI components map this returned string to the appropriate Lucide or design icon.
+ *
+ * @param type - The property type string (e.g. 'residential', 'commercial', 'industrial').
+ * @returns The normalized property type string.
+ *
+ * @example
+ * ```ts
+ * getPropertyTypeIcon('residential'); // "residential"
+ * ```
*/
export function getPropertyTypeIcon(type: string): string {
return type; // Components should map this to the appropriate lucide-react icon
}
/**
- * Validates whether a search query meets minimum length requirements.
- *
- * @param query - The search query to validate.
- * @returns True if the query is valid, false otherwise.
+ * Validates whether a search query string satisfies the minimum search criteria (non-whitespace length >= 2).
+ *
+ * @param query - The user search input string to validate.
+ * @returns True if the trimmed query has at least 2 characters, false otherwise.
+ *
+ * @example
+ * ```ts
+ * isValidSearchQuery('NY'); // true
+ * isValidSearchQuery(' N '); // false
+ * isValidSearchQuery(' '); // false
+ * ```
*/
export function isValidSearchQuery(query: string): boolean {
return query.trim().length >= 2;
}
/**
- * Creates a debounced version of a function.
- *
- * @param func - The function to debounce.
- * @param wait - The debounce timeout in milliseconds.
- * @returns A debounced version of the function.
+ * Creates a debounced version of a function that delays execution until after
+ * the specified wait time has elapsed since the last time it was invoked.
+ *
+ * @typeParam TArgs - The argument types of the target function.
+ * @typeParam TResult - The return type of the target function.
+ * @param func - The target function to debounce.
+ * @param wait - Milliseconds to delay invocation after the last call.
+ * @returns Debounced wrapper function accepting the same arguments.
+ *
+ * @example
+ * ```ts
+ * const handleSearch = debounce((query: string) => fetchResults(query), 300);
+ * handleSearch('prop');
+ * ```
*/
export function debounce(
func: (...args: TArgs) => TResult,
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..744d2a79
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,75 @@
+# PropChain Testing Guide
+
+This directory contains the automated end-to-end (E2E), integration, and accessibility test suites for PropChain FrontEnd using Playwright.
+
+---
+
+## Canonical Playwright Fixtures (`tests/fixtures/index.ts`)
+
+All Playwright specs should import `test` and `expect` from `tests/fixtures` (or relative `../fixtures` or `./fixtures`) rather than importing directly from `@playwright/test`.
+
+### Exported Fixtures & Helpers
+
+- **`test`**: Extended Playwright test runner providing built-in fixtures:
+ - **`wallet`**: Sets up a mocked Web3 Ethereum provider (`window.ethereum`) pre-configured with default or custom addresses, chain ID, and mock signing methods (`eth_requestAccounts`, `eth_accounts`, `eth_chainId`, `personal_sign`, etc.).
+ - **`api`**: Intercepts and stubs backend API requests (`/api/properties`, `/api/properties/:id`, `/api/properties/:id/purchase`, `/api/transactions`, `/api/test`) with deterministic mock responses.
+ - **`walletMswFixture`**: Combined fixture combining both wallet mock and API route mocks.
+- **`expect`**: Re-exported Playwright `expect` with all standard matchers.
+- **`setupWalletMock(page, options?)`**: Programmatic helper to inject a mocked Ethereum provider into any page context.
+ - Options: `address`, `chainId`, `balance`, `shouldReject`, `isMetaMask`, `delay`, `noWallet`.
+- **`setupApiMock(page)`**: Programmatic helper to register API routes for properties and transactions.
+
+### Usage Example
+
+```typescript
+import { test, expect } from '../fixtures';
+
+test.describe('My Feature Flow', () => {
+ test('uses wallet and api mocks', async ({ page, wallet, api }) => {
+ await page.goto('/properties');
+ await expect(page.getByTestId('property-card')).toBeVisible();
+ });
+});
+```
+
+---
+
+## E2E Test Suites (`tests/e2e/`)
+
+| Test File | Description | Fixtures Used |
+|-----------|-------------|---------------|
+| `saved-searches.spec.ts` | Covers creating, listing, running/restoring, and deleting saved searches on dashboard, persistence across reload, and fallback on corrupted storage. | `wallet`, `api` |
+| `wallet-connection.spec.ts` | Verifies wallet connection modal, MetaMask, WalletConnect, network switching, and session restore across page reload. | `wallet` |
+| `property-purchase-flow.spec.ts` | End-to-end investment/purchase flow from property details through token selection and confirmation. | `wallet`, `api` |
+| `property-purchase.spec.ts` | Property listing search, filter by price range and location, and purchase confirmation. | `setupWalletMock`, `setupApiMock` |
+| `purchase-confirmation.spec.ts` | Detailed verification of purchase confirmation modal and transaction states. | `wallet`, `api` |
+| `referral-leaderboard.spec.ts` | Referral share link generation and leaderboard ranking table verification. | `setupWalletMock` |
+| `property-alerts.spec.ts` | Price alert creation, threshold validation, and reload persistence. | `test`, `expect` |
+| `governance-voting.spec.ts` | Governance proposal listing, voting choices, token weight changes, and accessible live regions. | `test`, `expect` |
+| `transaction-history.spec.ts` | Filtering transaction rows, status filtering, and CSV export download. | `test`, `expect` |
+| `onboarding-tour.spec.ts` | Onboarding dialog focus trapping, keyboard navigation, and axe-core accessibility checks. | `test`, `expect` |
+| `csp.spec.ts` | Content Security Policy headers, nonces, and report-only validation. | `test`, `expect` |
+| `msw-verification.spec.ts` | Verifies API mock handlers for test, property, and purchase endpoints. | `api` |
+| `accessibility.spec.ts` | Automated axe-core accessibility scans across primary site routes. | `test`, `expect` |
+| `visual-regression.spec.ts` | Visual baseline and screenshot regression comparisons. | `setupWalletMock` |
+
+---
+
+## Running Tests
+
+### Unit & Integration Tests (Jest)
+```bash
+npm test
+```
+
+### E2E Tests (Playwright)
+```bash
+# Run all E2E specs
+npx playwright test
+
+# Run a specific spec
+npx playwright test tests/e2e/saved-searches.spec.ts
+
+# Run with UI mode
+npx playwright test --ui
+```
diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts
index 79b68cca..acb9f20a 100644
--- a/tests/accessibility.spec.ts
+++ b/tests/accessibility.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from './fixtures';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Quality Gate Matrix', () => {
diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts
index 72e74117..a02eae6d 100644
--- a/tests/e2e/accessibility.spec.ts
+++ b/tests/e2e/accessibility.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
import { injectAxe, checkA11y } from 'axe-playwright';
test.describe('Accessibility Tests', () => {
diff --git a/tests/e2e/csp.spec.ts b/tests/e2e/csp.spec.ts
index 1467299c..37821af6 100644
--- a/tests/e2e/csp.spec.ts
+++ b/tests/e2e/csp.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
test.describe('Content Security Policy', () => {
test('should have CSP headers on main page', async ({ page }) => {
diff --git a/tests/e2e/governance-voting.spec.ts b/tests/e2e/governance-voting.spec.ts
index 346e7c10..9aaebb72 100644
--- a/tests/e2e/governance-voting.spec.ts
+++ b/tests/e2e/governance-voting.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
/**
* Governance Voting E2E Tests
diff --git a/tests/e2e/msw-verification.spec.ts b/tests/e2e/msw-verification.spec.ts
index fb9b8372..5f1abaaf 100644
--- a/tests/e2e/msw-verification.spec.ts
+++ b/tests/e2e/msw-verification.spec.ts
@@ -1,25 +1,12 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
/**
* MSW Verification Test
- * Verifies that MSW is properly mocking API calls without requiring a backend
+ * Verifies that the canonical API mocking fixture is properly mocking API calls without requiring a backend
*/
test.describe('MSW API Mocking Verification', () => {
- test('should mock API responses successfully', async ({ page }) => {
- // Mock API endpoint
- await page.route('**/api/test', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- success: true,
- message: 'MSW is working',
- data: { test: 'value' },
- }),
- });
- });
-
+ test('should mock API responses successfully', async ({ page, api }) => {
// Create a test page that makes an API call
await page.setContent(`
@@ -41,35 +28,11 @@ test.describe('MSW API Mocking Verification', () => {