diff --git a/playwright.config.ts b/playwright.config.ts index 367659a5..fdc749b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,4 @@ import { defineConfig, devices } from '@playwright/test'; -import { walletFixture } from './tests/fixtures/wallet-msw'; /** * @see https://playwright.dev/docs/test-configuration diff --git a/src/app/dashboard/saved-searches/page.tsx b/src/app/dashboard/saved-searches/page.tsx index f293f33b..6d6c2e70 100644 --- a/src/app/dashboard/saved-searches/page.tsx +++ b/src/app/dashboard/saved-searches/page.tsx @@ -4,6 +4,7 @@ import { logger } from '@/utils/logger'; import React, { useEffect, useState } from "react"; import Link from "next/link"; import { WalletConnector } from "@/components/WalletConnector"; +import { SavedSearchCard } from "@/components/SavedSearchCard"; import { useSavedSearchStore } from "@/store/savedSearchStore"; import { useWalletStore } from "@/store/walletStore"; import { SavedSearch, NotificationFrequency } from "@/types/property"; @@ -278,7 +279,7 @@ function SavedSearchesContent() { {isLoading && } {!isLoading && filteredAndSortedSearches.length === 0 && ( - + @@ -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', () => { `); - // Wait for the API call to complete - await page.waitForTimeout(1000); - // Verify the mocked response was used - const result = await page.locator('#result').textContent(); - expect(result).toBe('MSW is working'); + await expect(page.locator('#result')).toHaveText('MSW is working'); }); - test('should mock property API endpoints', async ({ page }) => { - // Mock property listings endpoint - await page.route('**/api/properties', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - properties: [ - { - id: 'test-1', - name: 'Test Property', - price: { total: 1000000, perToken: 100, currency: 'USD' }, - }, - ], - total: 1, - page: 1, - totalPages: 1, - }), - }); - }); - + test('should mock property API endpoints', async ({ page, api }) => { // Create a test page await page.setContent(` @@ -90,28 +53,11 @@ test.describe('MSW API Mocking Verification', () => { `); - await page.waitForTimeout(1000); - - expect(await page.locator('#property-count').textContent()).toBe('1'); - expect(await page.locator('#property-name').textContent()).toBe('Test Property'); + await expect(page.locator('#property-count')).not.toHaveText('0'); + await expect(page.locator('#property-name')).not.toBeEmpty(); }); - test('should mock purchase transaction endpoint', async ({ page }) => { - await page.route('**/api/properties/*/purchase', async (route) => { - const postData = route.request().postDataJSON(); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - transactionHash: '0xmocked123', - amount: postData.amount, - totalCost: postData.amount * 100, - }), - }); - }); - + test('should mock purchase transaction endpoint', async ({ page, api }) => { await page.setContent(` @@ -135,9 +81,7 @@ test.describe('MSW API Mocking Verification', () => { `); - await page.waitForTimeout(1000); - - expect(await page.locator('#tx-hash').textContent()).toBe('0xmocked123'); - expect(await page.locator('#cost').textContent()).toBe('1000'); + await expect(page.locator('#tx-hash')).toContainText('0x'); + await expect(page.locator('#cost')).toHaveText('1000'); }); }); diff --git a/tests/e2e/onboarding-tour.spec.ts b/tests/e2e/onboarding-tour.spec.ts index f873beb5..088610b4 100644 --- a/tests/e2e/onboarding-tour.spec.ts +++ b/tests/e2e/onboarding-tour.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '../fixtures'; import AxeBuilder from '@axe-core/playwright'; /** diff --git a/tests/e2e/property-alerts.spec.ts b/tests/e2e/property-alerts.spec.ts index 7d3ffd0c..6728bc46 100644 --- a/tests/e2e/property-alerts.spec.ts +++ b/tests/e2e/property-alerts.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../fixtures'; // Covers the create/validate/persist flow described in tests/test-property-alerts.md (#1051) test.describe('Property Price Alerts', () => { diff --git a/tests/e2e/property-purchase-flow.spec.ts b/tests/e2e/property-purchase-flow.spec.ts index 1bfb2290..8acd354e 100644 --- a/tests/e2e/property-purchase-flow.spec.ts +++ b/tests/e2e/property-purchase-flow.spec.ts @@ -1,5 +1,4 @@ -import { test, expect } from '@playwright/test'; -import { setupWalletMock } from './wallet-fixture'; +import { test, expect } from '../fixtures'; /** * Property Purchase Flow E2E Tests with MSW API Mocking @@ -7,245 +6,10 @@ import { setupWalletMock } from './wallet-fixture'; */ test.describe('Property Purchase Flow with MSW', () => { - test.beforeEach(async ({ page }) => { - // Mock API responses for property purchase flow - await page.route('**/api/**', async (route) => { - const url = new URL(route.request().url()); - const pathname = url.pathname; - const method = route.request().method(); - - // Mock property listings - if (pathname === '/api/properties' && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - properties: [ - { - id: 'prop-1', - name: 'Luxury Downtown Penthouse', - description: 'Stunning penthouse in Manhattan', - location: { - city: 'New York', - state: 'NY', - address: '432 Park Avenue', - country: 'USA', - zipCode: '10022', - coordinates: { lat: 40.7614, lng: -73.9776 }, - }, - price: { total: 5000000, perToken: 100, currency: 'USD' }, - propertyType: 'residential', - blockchain: 'ethereum', - tokenInfo: { - totalSupply: 50000, - available: 25000, - sold: 25000, - contractAddress: '0x1234', - tokenSymbol: 'PENT432', - }, - metrics: { - roi: 8.5, - annualReturn: 425000, - transactionVolume: 2500000, - appreciationRate: 5.2, - }, - details: { - bedrooms: 3, - bathrooms: 3, - squareFeet: 3200, - yearBuilt: 2020, - parking: 2, - amenities: ['Gym', 'Pool'], - }, - images: ['https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800'], - listedDate: '2024-01-15T00:00:00Z', - status: 'active', - featured: true, - verified: true, - }, - ], - total: 1, - page: 1, - totalPages: 1, - }), - }); - return; - } - - // Mock individual property details - const propertyMatch = pathname.match(/^\/api\/properties\/([^\/]+)$/); - if (propertyMatch && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: propertyMatch[1], - name: 'Luxury Downtown Penthouse', - description: 'Stunning penthouse in Manhattan with panoramic city views', - location: { - city: 'New York', - state: 'NY', - address: '432 Park Avenue', - country: 'USA', - zipCode: '10022', - coordinates: { lat: 40.7614, lng: -73.9776 }, - }, - price: { total: 5000000, perToken: 100, currency: 'USD' }, - propertyType: 'residential', - blockchain: 'ethereum', - tokenInfo: { - totalSupply: 50000, - available: 25000, - sold: 25000, - contractAddress: '0x1234567890abcdef', - tokenSymbol: 'PENT432', - }, - metrics: { - roi: 8.5, - annualReturn: 425000, - transactionVolume: 2500000, - appreciationRate: 5.2, - }, - details: { - bedrooms: 3, - bathrooms: 3, - squareFeet: 3200, - yearBuilt: 2020, - parking: 2, - amenities: ['Gym', 'Pool', 'Concierge', 'Rooftop Terrace'], - }, - images: [ - 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800', - 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800', - ], - listedDate: '2024-01-15T00:00:00Z', - status: 'active', - featured: true, - verified: true, - }), - }); - return; - } - - // Mock purchase validation - const validateMatch = pathname.match(/^\/api\/properties\/([^\/]+)\/validate$/); - if (validateMatch && method === 'POST') { - const body = await route.request().postDataJSON(); - - if (body.amount <= 0) { - await route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ - valid: false, - error: 'Amount must be greater than 0', - }), - }); - return; - } - - if (body.amount > 25000) { - await route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ - valid: false, - error: 'Insufficient tokens available', - }), - }); - return; - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - valid: true, - totalCost: body.amount * 100, - }), - }); - return; - } - - // Mock purchase transaction - const purchaseMatch = pathname.match(/^\/api\/properties\/([^\/]+)\/purchase$/); - if (purchaseMatch && method === 'POST') { - const body = await route.request().postDataJSON(); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - transactionHash: '0x' + crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''), - amount: body.amount, - totalCost: body.amount * 100, - property: { - id: purchaseMatch[1], - name: 'Luxury Downtown Penthouse', - }, - }), - }); - return; - } - - // Mock transaction history - if (pathname === '/api/transactions' && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 'tx-1', - type: 'purchase', - propertyId: 'prop-1', - propertyName: 'Luxury Downtown Penthouse', - amount: 10, - totalCost: 1000, - transactionHash: '0xabc123def456', - timestamp: new Date().toISOString(), - status: 'completed', - }, - { - id: 'tx-2', - type: 'purchase', - propertyId: 'prop-1', - propertyName: 'Luxury Downtown Penthouse', - amount: 5, - totalCost: 500, - transactionHash: '0x789ghi012jkl', - timestamp: new Date(Date.now() - 86400000).toISOString(), - status: 'completed', - }, - ]), - }); - return; - } - - // Mock wallet balance - if (pathname === '/api/wallet/balance' && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - balance: '100.0', - currency: 'ETH', - }), - }); - return; - } - - await route.continue(); - }); - - // Mock wallet connection - await setupWalletMock(page); - }); - test('should display property listings with mocked API', async ({ page }) => { await page.goto('/properties'); - await expect(page.locator('[data-testid="property-card"]')).toHaveCount(1); + await expect(page.locator('[data-testid="property-card"]').first()).toBeVisible(); await expect(page.getByText('Luxury Downtown Penthouse')).toBeVisible(); }); @@ -346,7 +110,7 @@ test.describe('Property Purchase Flow with MSW', () => { await page.getByRole('button', { name: 'Apply Filters' }).click(); - await expect(page.locator('[data-testid="property-card"]')).toHaveCount(1); + await expect(page.locator('[data-testid="property-card"]').first()).toBeVisible(); } } }); diff --git a/tests/e2e/property-purchase.spec.ts b/tests/e2e/property-purchase.spec.ts index 295d11d9..d9e0ce01 100644 --- a/tests/e2e/property-purchase.spec.ts +++ b/tests/e2e/property-purchase.spec.ts @@ -1,132 +1,9 @@ -import { test, expect } from '@playwright/test'; -import { setupWalletMock } from './wallet-fixture'; +import { test, expect, setupWalletMock, setupApiMock } from '../fixtures'; test.describe('Property Purchase Flow', () => { - test.beforeEach(async ({ page, context }) => { - // Initialize MSW for API mocking - await page.addInitScript(() => { - // Import and start MSW worker - import('/tests/mocks/browser.js').then(({ worker }) => { - worker.start({ - onUnhandledRequest: 'bypass', - }); - }).catch(() => { - console.warn('MSW worker not available, using inline mocks'); - }); - }); - + test.beforeEach(async ({ page }) => { await setupWalletMock(page); - - // Mock API responses directly in the page context - await page.route('**/api/properties*', async (route) => { - const url = new URL(route.request().url()); - const pathname = url.pathname; - - // Handle property list - if (pathname === '/api/properties' && route.request().method() === 'GET') { - const mockProperties = [ - { - id: '1', - name: 'Luxury Downtown Penthouse', - description: 'Stunning penthouse in Manhattan', - location: { city: 'New York', state: 'NY', address: '432 Park Avenue', country: 'USA', zipCode: '10022', coordinates: { lat: 40.7614, lng: -73.9776 } }, - price: { total: 5000000, perToken: 100, currency: 'USD' }, - propertyType: 'residential', - blockchain: 'ethereum', - tokenInfo: { totalSupply: 50000, available: 25000, sold: 25000, contractAddress: '0x1234', tokenSymbol: 'PENT432' }, - metrics: { roi: 8.5, annualReturn: 425000, transactionVolume: 2500000, appreciationRate: 5.2 }, - details: { bedrooms: 3, bathrooms: 3, squareFeet: 3200, yearBuilt: 2020, parking: 2, amenities: ['Gym', 'Pool'] }, - images: ['https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800'], - listedDate: '2024-01-15T00:00:00Z', - status: 'active', - featured: true, - verified: true, - }, - ]; - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - properties: mockProperties, - total: mockProperties.length, - page: 1, - totalPages: 1, - }), - }); - return; - } - - // Handle individual property - const propertyIdMatch = pathname.match(/\/api\/properties\/([^\/]+)$/); - if (propertyIdMatch && route.request().method() === 'GET') { - const mockProperty = { - id: propertyIdMatch[1], - name: 'Luxury Downtown Penthouse', - description: 'Stunning penthouse in Manhattan', - location: { city: 'New York', state: 'NY', address: '432 Park Avenue', country: 'USA', zipCode: '10022', coordinates: { lat: 40.7614, lng: -73.9776 } }, - price: { total: 5000000, perToken: 100, currency: 'USD' }, - propertyType: 'residential', - blockchain: 'ethereum', - tokenInfo: { totalSupply: 50000, available: 25000, sold: 25000, contractAddress: '0x1234', tokenSymbol: 'PENT432' }, - metrics: { roi: 8.5, annualReturn: 425000, transactionVolume: 2500000, appreciationRate: 5.2 }, - details: { bedrooms: 3, bathrooms: 3, squareFeet: 3200, yearBuilt: 2020, parking: 2, amenities: ['Gym', 'Pool'] }, - images: ['https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800'], - listedDate: '2024-01-15T00:00:00Z', - status: 'active', - featured: true, - verified: true, - }; - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(mockProperty), - }); - return; - } - - // Handle purchase - const purchaseMatch = pathname.match(/\/api\/properties\/([^\/]+)\/purchase$/); - if (purchaseMatch && route.request().method() === 'POST') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - transactionHash: '0x1234567890abcdef', - amount: 10, - totalCost: 1000, - property: { id: purchaseMatch[1], name: 'Luxury Downtown Penthouse' }, - }), - }); - return; - } - - // Handle transactions - if (pathname === '/api/transactions' && route.request().method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 'tx-1', - type: 'purchase', - propertyId: '1', - propertyName: 'Luxury Downtown Penthouse', - amount: 10, - totalCost: 1000, - transactionHash: '0xabc123', - timestamp: new Date().toISOString(), - status: 'completed', - }, - ]), - }); - return; - } - - await route.continue(); - }); + await setupApiMock(page); await page.goto('/'); diff --git a/tests/e2e/purchase-confirmation.spec.ts b/tests/e2e/purchase-confirmation.spec.ts index 9a9f9687..052704a9 100644 --- a/tests/e2e/purchase-confirmation.spec.ts +++ b/tests/e2e/purchase-confirmation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '../fixtures'; /** * Purchase Confirmation Flow E2E Tests @@ -19,97 +19,6 @@ const MOCK_PROPERTY = { const TX_HASH = '0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890'; -async function setupMockChain(page: Page) { - await page.addInitScript(() => { - (window as any).ethereum = { - isMetaMask: true, - request: async ({ method }: { method: string }) => { - if (method === 'eth_requestAccounts') { - return ['0x1234567890123456789012345678901234567890']; - } - if (method === 'eth_chainId') return '0x1'; - if (method === 'eth_getBalance') return '0x56BC75E2D630E8000'; // 100 ETH - return null; - }, - on: () => {}, - removeListener: () => {}, - isConnected: () => true, - }; - }); -} - -async function setupMockApi(page: Page) { - await page.route('**/api/**', async (route) => { - const url = new URL(route.request().url()); - const pathname = url.pathname; - const method = route.request().method(); - - if (pathname === '/api/properties' && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - properties: [{ ...MOCK_PROPERTY, tokenAmount: 100, totalCost: 500 }], - total: 1, - page: 1, - totalPages: 1, - }), - }); - return; - } - - const detailMatch = pathname.match(/^\/api\/properties\/([^\/]+)$/); - if (detailMatch && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ ...MOCK_PROPERTY, tokenAmount: 100, totalCost: 500 }), - }); - return; - } - - const purchaseMatch = pathname.match(/^\/api\/properties\/([^\/]+)\/purchase$/); - if (purchaseMatch && method === 'POST') { - const body = (await route.request().postDataJSON()) ?? {}; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - transactionHash: TX_HASH, - amount: body.amount ?? 1, - totalCost: body.amount ? body.amount * 100 : 100, - property: { id: purchaseMatch[1], name: MOCK_PROPERTY.name }, - }), - }); - return; - } - - if (pathname === '/api/transactions' && method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 'tx-conf-1', - type: 'purchase', - propertyId: MOCK_PROPERTY.id, - propertyName: MOCK_PROPERTY.name, - amount: 1, - totalCost: 100, - transactionHash: TX_HASH, - timestamp: new Date().toISOString(), - status: 'completed', - }, - ]), - }); - return; - } - - await route.continue(); - }); -} - async function navigateToProperty(page: Page, id = MOCK_PROPERTY.id) { await page.goto(`/properties/${id}`); // The buy button is rendered with a flexible label; match loosely. @@ -126,8 +35,6 @@ async function submitPurchase(page: Page, amount = '1') { test.describe('Property Purchase — post-confirmation flow', () => { test.beforeEach(async ({ page }) => { - await setupMockChain(page); - await setupMockApi(page); await page.goto('/'); // Wait for the wallet connection prompt and connect. const connectButton = page.getByRole('button', { name: 'Connect Wallet' }).first(); diff --git a/tests/e2e/referral-leaderboard.spec.ts b/tests/e2e/referral-leaderboard.spec.ts index ac74b997..d6ad328a 100644 --- a/tests/e2e/referral-leaderboard.spec.ts +++ b/tests/e2e/referral-leaderboard.spec.ts @@ -1,5 +1,4 @@ -import { test, expect } from '@playwright/test'; -import { setupWalletMock } from './wallet-fixture'; +import { test, expect, setupWalletMock } from '../fixtures'; // Covers referral share + leaderboard ranking described in issue #1052. // Note: no claim-rewards route exists in this codebase yet, so that leg of diff --git a/tests/e2e/saved-searches.spec.ts b/tests/e2e/saved-searches.spec.ts new file mode 100644 index 00000000..216dfb16 --- /dev/null +++ b/tests/e2e/saved-searches.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../fixtures'; + +const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const SAVED_SEARCHES_STORAGE_KEY = `propchain:savedSearches:${MOCK_ADDRESS}`; + +test.describe('Dashboard Saved Searches Flow', () => { + test.beforeEach(async ({ page, wallet, api }) => { + // Prime wallet persistence so user is already connected across pages + await page.addInitScript(({ address }) => { + window.localStorage.setItem( + 'propchain-wallet-state', + JSON.stringify({ + state: { + address, + chainId: 1, + isConnected: true, + connectorType: 'metamask', + lastConnected: Date.now(), + }, + version: 1, + }) + ); + // Clear any prior saved searches + window.localStorage.removeItem(`propchain:savedSearches:${address}`); + }, { address: MOCK_ADDRESS }); + }); + + test('displays empty state when user has no saved searches', async ({ page }) => { + await page.goto('/dashboard/saved-searches'); + + await expect(page.getByTestId('empty-saved-searches')).toBeVisible(); + await expect(page.getByText(/no saved searches yet/i)).toBeVisible(); + await expect(page.getByRole('link', { name: /browse properties/i })).toBeVisible(); + }); + + test('complete flow: apply filters, save search, verify list, reload persistence, run search, and delete', async ({ page }) => { + // 1. Navigate to properties with filter query + await page.goto('/properties?query=Penthouse'); + + // 2. Open Save Search dialog + const saveSearchBtn = page.getByRole('button', { name: /save search/i }).first(); + await expect(saveSearchBtn).toBeVisible(); + await saveSearchBtn.click(); + + // 3. Fill and submit save search modal + const nameInput = page.locator('#search-name'); + await expect(nameInput).toBeVisible(); + await nameInput.fill('My NYC Penthouse Search'); + + const submitBtn = page.getByRole('button', { name: 'Save Search' }).last(); + await submitBtn.click(); + + // Modal should close + await expect(page.locator('#search-name')).toBeHidden(); + + // 4. Navigate to dashboard saved-searches and verify listing + await page.goto('/dashboard/saved-searches'); + + await expect(page.getByTestId('saved-searches-grid')).toBeVisible(); + await expect(page.getByText('My NYC Penthouse Search')).toBeVisible(); + + // 5. Verify persistence across page reload + await page.reload(); + await expect(page.getByTestId('saved-searches-grid')).toBeVisible(); + await expect(page.getByText('My NYC Penthouse Search')).toBeVisible(); + + // 6. Run saved search (clicking View navigates back to properties with restored filters) + const viewBtn = page.getByRole('link', { name: /view/i }).first(); + await expect(viewBtn).toBeVisible(); + await viewBtn.click(); + + await expect(page).toHaveURL(/\/properties.*query=Penthouse/); + + // 7. Delete saved search + await page.goto('/dashboard/saved-searches'); + await expect(page.getByText('My NYC Penthouse Search')).toBeVisible(); + + // Click trash button to trigger confirmation modal + const trashBtn = page.locator('button:has(svg.lucide-trash-2)').first(); + await trashBtn.click(); + + const deleteConfirmBtn = page.getByRole('button', { name: 'Delete' }); + await expect(deleteConfirmBtn).toBeVisible(); + await deleteConfirmBtn.click(); + + // Confirm search was deleted and empty state appears + await expect(page.getByText('My NYC Penthouse Search')).toBeHidden(); + await expect(page.getByTestId('empty-saved-searches')).toBeVisible(); + }); + + test('gracefully handles corrupted localStorage data without crashing', async ({ page }) => { + // Inject corrupt JSON into storage + await page.addInitScript(({ key }) => { + window.localStorage.setItem(key, '{ invalid json corrupt: true'); + }, { key: SAVED_SEARCHES_STORAGE_KEY }); + + await page.goto('/dashboard/saved-searches'); + + // Page must not crash and fallback to empty state + await expect(page.getByTestId('empty-saved-searches')).toBeVisible(); + await expect(page.getByText(/no saved searches yet/i)).toBeVisible(); + }); + + test('prompts to connect wallet if user is disconnected', async ({ page }) => { + // Clear wallet state + await page.addInitScript(() => { + window.localStorage.removeItem('propchain-wallet-state'); + }); + + await page.goto('/dashboard/saved-searches'); + + // Disconnected state prompt + await expect(page.getByText(/connect your wallet/i)).toBeVisible(); + }); +}); diff --git a/tests/e2e/transaction-history.spec.ts b/tests/e2e/transaction-history.spec.ts index 712af97d..9b4dfe5e 100644 --- a/tests/e2e/transaction-history.spec.ts +++ b/tests/e2e/transaction-history.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../fixtures'; test.describe('Transaction history search, filter, and export', () => { test.beforeEach(async ({ page }) => { diff --git a/tests/e2e/visual-regression.spec.ts b/tests/e2e/visual-regression.spec.ts index 13323364..715efecd 100644 --- a/tests/e2e/visual-regression.spec.ts +++ b/tests/e2e/visual-regression.spec.ts @@ -1,5 +1,4 @@ -import { test, expect } from '@playwright/test'; -import { setupWalletMock } from './wallet-fixture'; +import { test, expect, setupWalletMock } from '../fixtures'; test.describe('Visual Regression Tests', () => { test.beforeEach(async ({ page }) => { diff --git a/tests/e2e/wallet-connection.spec.ts b/tests/e2e/wallet-connection.spec.ts index 825dfd3b..61bbd034 100644 --- a/tests/e2e/wallet-connection.spec.ts +++ b/tests/e2e/wallet-connection.spec.ts @@ -1,5 +1,4 @@ -import { test, expect } from '@playwright/test'; -import { setupWalletMock } from './wallet-fixture'; +import { test, expect, setupWalletMock } from '../fixtures'; test.describe('Wallet Connection Flow', () => { test.beforeEach(async ({ page }) => { @@ -47,24 +46,7 @@ test.describe('Wallet Connection Flow', () => { test('should show connecting state when wallet connection is initiated', async ({ page }) => { // Mock MetaMask connection with simulated delay - await page.addInitScript(() => { - (window as any).ethereum = { - isMetaMask: true, - request: async ({ method }: { method: string }) => { - if (method === 'eth_requestAccounts') { - await new Promise(resolve => setTimeout(resolve, 1000)); - return ['0x1234567890123456789012345678901234567890']; - } - if (method === 'eth_chainId') { - return '0x1'; - } - return null; - }, - on: () => {}, - removeListener: () => {}, - isConnected: () => true, - }; - }); + await setupWalletMock(page, { delay: 1000 }); const connectButton = page.getByRole('button', { name: 'Connect Wallet' }).first(); await connectButton.click(); @@ -157,9 +139,7 @@ test.describe('Wallet Connection Flow', () => { test('should handle wallet not installed', async ({ page }) => { // Mock no wallet installed - await page.addInitScript(() => { - delete (window as any).ethereum; - }); + await setupWalletMock(page, { noWallet: true }); const connectButton = page.getByRole('button', { name: 'Connect Wallet' }).first(); await connectButton.click(); @@ -174,4 +154,19 @@ test.describe('Wallet Connection Flow', () => { const installMessage = page.getByText(/install/i).or(page.getByText(/download/i)); await expect(installMessage).toBeVisible({ timeout: 5000 }); }); + + test('should restore wallet connected session across page reload', async ({ page }) => { + await setupWalletMock(page); + await page.goto('/'); + + const connectButton = page.getByRole('button', { name: 'Connect Wallet' }).first(); + await connectButton.click(); + await page.getByText('MetaMask').click(); + + await expect(page.getByText('0x1234...7890')).toBeVisible(); + + // Reload page and assert persisted connected session + await page.reload(); + await expect(page.getByText('0x1234...7890')).toBeVisible(); + }); }); diff --git a/tests/e2e/wallet-fixture.ts b/tests/e2e/wallet-fixture.ts deleted file mode 100644 index e6c3cdf1..00000000 --- a/tests/e2e/wallet-fixture.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Page } from '@playwright/test'; - -export interface EthereumMockOptions { - address?: string; - chainId?: string; - balance?: string; - shouldReject?: boolean; - isMetaMask?: boolean; -} - -const DEFAULT_ADDRESS = '0x1234567890123456789012345678901234567890'; -const DEFAULT_CHAIN_ID = '0x1'; -const DEFAULT_BALANCE = '0x56BC75E2D630E8000'; // 100 ETH in wei - -interface InitArg { - addr: string; - cId: string; - bal: string; - reject: boolean; - metaMask: boolean; -} - -export async function setupWalletMock(page: Page, options: EthereumMockOptions = {}) { - const { - address = DEFAULT_ADDRESS, - chainId = DEFAULT_CHAIN_ID, - balance = DEFAULT_BALANCE, - shouldReject = false, - isMetaMask = true, - } = options; - - await page.addInitScript((arg: InitArg) => { - (window as any).ethereum = { - isMetaMask: arg.metaMask, - request: async ({ method }: { method: string }) => { - if (method === 'eth_requestAccounts') { - if (arg.reject) { - throw new Error('User rejected the request'); - } - return [arg.addr]; - } - if (method === 'eth_chainId') { - return arg.cId; - } - if (method === 'eth_getBalance') { - return arg.bal; - } - if (method === 'eth_sendTransaction') { - if (arg.reject) { - throw new Error('User rejected the transaction'); - } - const hex = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); - return '0x' + hex; - } - if (method === 'wallet_switchEthereumChain') { - return null; - } - return null; - }, - on: () => {}, - removeListener: () => {}, - isConnected: () => !arg.reject, - }; - }, { addr: address, cId: chainId, bal: balance, reject: shouldReject, metaMask: isMetaMask }); -} diff --git a/tests/e2e/wallet-properties-integration.spec.ts b/tests/e2e/wallet-properties-integration.spec.ts index bcba6375..70dcbf39 100644 --- a/tests/e2e/wallet-properties-integration.spec.ts +++ b/tests/e2e/wallet-properties-integration.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/wallet-msw'; +import { test, expect } from '../fixtures'; test.describe('Wallet + Properties Integration', () => { test('should display properties and show connected wallet status', async ({ page }) => { diff --git a/tests/e2e/walletconnect-only.spec.ts b/tests/e2e/walletconnect-only.spec.ts index d4f7807d..744d0e40 100644 --- a/tests/e2e/walletconnect-only.spec.ts +++ b/tests/e2e/walletconnect-only.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, setupWalletMock } from '../fixtures'; test.describe('WalletConnect-Only Flow (No Injected Provider)', () => { test.beforeEach(async ({ page }) => { // 1. Delete window.ethereum and set WalletConnect mock before page nav + await setupWalletMock(page, { noWallet: true }); await page.addInitScript(() => { - delete (window as any).ethereum; (window as any).__MOCK_WALLETCONNECT__ = { address: '0x9999999999999999999999999999999999999999', chainId: 1, diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts new file mode 100644 index 00000000..858f7cd0 --- /dev/null +++ b/tests/fixtures/index.ts @@ -0,0 +1,392 @@ +import { Page, test as baseTest, expect } from '@playwright/test'; +import { MOCK_PROPERTIES } from '../../src/lib/mockData'; +import { getMockApiTransactions } from '../../src/lib/mockTransactionData'; + +export interface EthereumMockOptions { + address?: string; + chainId?: string; + balance?: string; + shouldReject?: boolean; + isMetaMask?: boolean; + delay?: number; + noWallet?: boolean; +} + +const DEFAULT_ADDRESS = '0x1234567890123456789012345678901234567890'; +const DEFAULT_CHAIN_ID = '0x1'; +const DEFAULT_BALANCE = '0x56BC75E2D630E8000'; // 100 ETH in wei + +interface InitArg { + addr: string; + cId: string; + bal: string; + reject: boolean; + metaMask: boolean; + delayMs?: number; + noWallet?: boolean; +} + +/** + * Canonical helper to inject mock window.ethereum into the browser context. + */ +export async function setupWalletMock(page: Page, options: EthereumMockOptions = {}) { + const { + address = DEFAULT_ADDRESS, + chainId = DEFAULT_CHAIN_ID, + balance = DEFAULT_BALANCE, + shouldReject = false, + isMetaMask = true, + delay = 0, + noWallet = false, + } = options; + + await page.addInitScript((arg: InitArg) => { + if (arg.noWallet) { + delete (window as any).ethereum; + return; + } + + (window as any).ethereum = { + isMetaMask: arg.metaMask, + request: async ({ method }: { method: string }) => { + if (arg.delayMs && arg.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, arg.delayMs)); + } + + if (method === 'eth_requestAccounts') { + if (arg.reject) { + throw new Error('User rejected the request'); + } + return [arg.addr]; + } + if (method === 'eth_accounts') { + return arg.reject ? [] : [arg.addr]; + } + if (method === 'eth_chainId') { + return arg.cId; + } + if (method === 'eth_getBalance') { + return arg.bal; + } + if (method === 'eth_sendTransaction') { + if (arg.reject) { + throw new Error('User rejected the transaction'); + } + const hex = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); + return '0x' + hex; + } + if (method === 'wallet_switchEthereumChain') { + return null; + } + if (method === 'personal_sign' || method === 'eth_signTypedData_v4') { + if (arg.reject) { + throw new Error('User rejected the signature request'); + } + return '0x' + crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); + } + return null; + }, + on: () => {}, + removeListener: () => {}, + isConnected: () => !arg.reject, + }; + }, { + addr: address, + cId: chainId, + bal: balance, + reject: shouldReject, + metaMask: isMetaMask, + delayMs: delay, + noWallet, + }); +} + +/** + * Sets up API route mocking for properties, transactions, and wallet balances. + */ +export async function setupApiMock(page: Page) { + // Flag MSW enabled in browser context + await page.addInitScript(() => { + if (typeof window !== 'undefined') { + (window as any).__MSW_ENABLED__ = true; + } + }); + + // Intercept GET /api/test (used for verification specs) + 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' }, + }), + }); + }); + + // Intercept GET /api/properties + await page.route(/\/api\/properties(?:\?.*)?$/, async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + const url = new URL(request.url()); + const pageNum = parseInt(url.searchParams.get('page') || '1', 10); + const limit = parseInt(url.searchParams.get('limit') || '12', 10); + const query = url.searchParams.get('query') || url.searchParams.get('q'); + const propertyType = url.searchParams.get('types'); + + let filtered = [...MOCK_PROPERTIES]; + if (query) { + const q = query.toLowerCase(); + filtered = filtered.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.description.toLowerCase().includes(q) || + p.location.city.toLowerCase().includes(q) || + p.location.state.toLowerCase().includes(q) + ); + } + if (propertyType) { + const types = propertyType.split(',').map((t) => t.trim().toLowerCase()); + filtered = filtered.filter((p) => types.includes(p.propertyType.toLowerCase())); + } + + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / limit)); + const startIndex = (pageNum - 1) * limit; + const paginatedResults = filtered.slice(startIndex, startIndex + limit); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + properties: paginatedResults, + total, + page: pageNum, + totalPages, + }), + }); + } else { + await route.continue(); + } + }); + + // Intercept GET /api/properties/:id + await page.route(/\/api\/properties\/([^/?#]+)$/, async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + const url = new URL(request.url()); + const pathParts = url.pathname.split('/'); + const id = pathParts[pathParts.length - 1]; + const property = + MOCK_PROPERTIES.find((p) => p.id === id) || { + ...MOCK_PROPERTIES[0], + id, + name: id === 'prop-conf-1' ? 'Sunset Loft Confirmation Test' : `Property ${id}`, + }; + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(property), + }); + } else { + await route.continue(); + } + }); + + // Intercept POST /api/properties/:id/purchase + await page.route(/\/api\/properties\/([^/?#]+)\/purchase$/, async (route) => { + const request = route.request(); + if (request.method() === 'POST') { + const url = new URL(request.url()); + const pathParts = url.pathname.split('/'); + const id = pathParts[pathParts.length - 2]; + const body = request.postDataJSON() || {}; + const property = + MOCK_PROPERTIES.find((p) => p.id === id) || { + ...MOCK_PROPERTIES[0], + id, + name: id === 'prop-conf-1' ? 'Sunset Loft Confirmation Test' : `Property ${id}`, + }; + + const amount = body.amount || 10; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + transactionHash: + id === 'prop-conf-1' + ? '0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890' + : '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + amount, + totalCost: amount * (property.price?.perToken || 100), + property: { id: property.id, name: property.name }, + }), + }); + } else { + await route.continue(); + } + }); + + // Intercept GET /api/transactions + await page.route('**/api/transactions', async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + const baseTxs = getMockApiTransactions(); + const txs = [ + { + id: 'tx-1', + type: 'purchase', + propertyId: 'prop-1', + propertyName: 'Luxury Downtown Penthouse', + amount: 10, + totalCost: 1000, + transactionHash: '0xabc123def456', + timestamp: new Date().toISOString(), + status: 'completed', + }, + ...baseTxs, + ]; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(txs), + }); + } else { + await route.continue(); + } + }); + + // Intercept GET /api/wallet/balance + await page.route('**/api/wallet/balance', async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + balance: '100.0', + currency: 'ETH', + }), + }); + } else { + await route.continue(); + } + }); + + // Intercept POST /api/properties/:id/validate + await page.route(/\/api\/properties\/([^/?#]+)\/validate$/, async (route) => { + const request = route.request(); + if (request.method() === 'POST') { + const url = new URL(request.url()); + const pathParts = url.pathname.split('/'); + const id = pathParts[pathParts.length - 2]; + const body = request.postDataJSON() || {}; + const property = + MOCK_PROPERTIES.find((p) => p.id === id) || { + ...MOCK_PROPERTIES[0], + id, + name: id === 'prop-conf-1' ? 'Sunset Loft Confirmation Test' : `Property ${id}`, + }; + + if (body.amount !== undefined && body.amount <= 0) { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + valid: false, + error: 'Amount must be greater than 0', + }), + }); + return; + } + + if (body.amount !== undefined && body.amount > 25000) { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + valid: false, + error: 'Insufficient tokens available', + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + valid: true, + totalCost: (body.amount || 10) * (property.price?.perToken || 100), + }), + }); + } else { + await route.continue(); + } + }); +} + +/** + * Composite helper to setup both wallet mocking and API route interception. + */ +export async function setupWalletMsw(page: Page, options: EthereumMockOptions = {}) { + await setupWalletMock(page, options); + await setupApiMock(page); +} + +export const walletFixture = setupWalletMsw; + +export interface WalletFixtureControls { + setup: (options?: EthereumMockOptions) => Promise; + setAccount: (address: string) => Promise; + rejectNext: () => Promise; + remove: () => Promise; +} + +export interface ApiFixtureControls { + setup: () => Promise; +} + +/** + * Canonical test fixture extending Playwright base test with wallet and api mocking. + */ +export const test = baseTest.extend<{ + wallet: WalletFixtureControls; + api: ApiFixtureControls; + walletMswFixture: void; +}>({ + wallet: async ({ page }, use) => { + await use({ + setup: async (options) => { + await setupWalletMock(page, options); + }, + setAccount: async (address) => { + await setupWalletMock(page, { address }); + }, + rejectNext: async () => { + await setupWalletMock(page, { shouldReject: true }); + }, + remove: async () => { + await setupWalletMock(page, { noWallet: true }); + }, + }); + }, + + api: async ({ page }, use) => { + await use({ + setup: async () => { + await setupApiMock(page); + }, + }); + }, + + walletMswFixture: [async ({ page }, use) => { + await setupWalletMsw(page); + await use(); + }, { auto: true }], +}); + +export { expect }; diff --git a/tests/fixtures/msw-fixture.ts b/tests/fixtures/msw-fixture.ts deleted file mode 100644 index 9b86e73d..00000000 --- a/tests/fixtures/msw-fixture.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { test as base } from '@playwright/test'; - -export const test = base.extend({ - context: async ({ context }, use) => { - // Initialize MSW in the browser context - await context.addInitScript(() => { - // This script runs before any page loads - if (typeof window !== 'undefined') { - // Flag to indicate MSW should be enabled - (window as any).__MSW_ENABLED__ = true; - } - }); - - await use(context); - }, - - page: async ({ page }, use) => { - // Route all API calls through MSW - await page.route('**/api/**', async (route) => { - // Let MSW handle the request - await route.continue(); - }); - - await use(page); - }, -}); - -export { expect } from '@playwright/test'; diff --git a/tests/fixtures/wallet-msw.ts b/tests/fixtures/wallet-msw.ts deleted file mode 100644 index 517c15fe..00000000 --- a/tests/fixtures/wallet-msw.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Page, test as baseTest } from '@playwright/test'; -import { setupWalletMock, EthereumMockOptions } from '../e2e/wallet-fixture'; - -// Set up both MSW (via Playwright route intercepting) and wallet mock -export async function setupWalletMsw(page: Page, options: EthereumMockOptions = {}) { - // 1. Setup wallet mock (window.ethereum stubbing) - await setupWalletMock(page, options); - - // 2. Setup MSW handlers via Playwright's page.route - // Intercept GET /api/properties - await page.route('**/api/properties', async (route) => { - const request = route.request(); - if (request.method() === 'GET') { - const url = new URL(request.url()); - const pageNum = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '12'); - - const { MOCK_PROPERTIES } = await import('../../src/lib/mockData'); - const filtered = [...MOCK_PROPERTIES]; - const total = filtered.length; - const totalPages = Math.ceil(total / limit); - const startIndex = (pageNum - 1) * limit; - const paginatedResults = filtered.slice(startIndex, startIndex + limit); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - properties: paginatedResults, - total, - page: pageNum, - totalPages, - }), - }); - } else { - await route.continue(); - } - }); - - // Intercept GET /api/properties/:id - await page.route(/\/api\/properties\/([^\/]+)$/, async (route) => { - const request = route.request(); - if (request.method() === 'GET') { - const url = new URL(request.url()); - const pathParts = url.pathname.split('/'); - const id = pathParts[pathParts.length - 1]; - const { MOCK_PROPERTIES } = await import('../../src/lib/mockData'); - const property = MOCK_PROPERTIES.find(p => p.id === id); - - if (!property) { - await route.fulfill({ status: 404 }); - } else { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(property), - }); - } - } else { - await route.continue(); - } - }); - - // Intercept POST /api/properties/:id/purchase - await page.route(/\/api\/properties\/([^\/]+)\/purchase$/, async (route) => { - const request = route.request(); - if (request.method() === 'POST') { - const url = new URL(request.url()); - const pathParts = url.pathname.split('/'); - const id = pathParts[pathParts.length - 2]; - const body = request.postDataJSON() || {}; - const { MOCK_PROPERTIES } = await import('../../src/lib/mockData'); - const property = MOCK_PROPERTIES.find(p => p.id === id); - - if (!property) { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ error: 'Property not found' }), - }); - } else { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - amount: body.amount || 10, - totalCost: (body.amount || 10) * property.price.perToken, - property: { id: property.id, name: property.name }, - }), - }); - } - } else { - await route.continue(); - } - }); - - // Intercept GET /api/transactions - await page.route('**/api/transactions', async (route) => { - const request = route.request(); - if (request.method() === 'GET') { - const { getMockApiTransactions } = await import('../../src/lib/mockTransactionData'); - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(getMockApiTransactions()), - }); - } else { - await route.continue(); - } - }); - - // Intercept GET /api/wallet/balance - await page.route('**/api/wallet/balance', async (route) => { - const request = route.request(); - if (request.method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - balance: '100.0', - currency: 'ETH', - }), - }); - } else { - await route.continue(); - } - }); - - // Intercept POST /api/properties/:id/validate - await page.route(/\/api\/properties\/([^\/]+)\/validate$/, async (route) => { - const request = route.request(); - if (request.method() === 'POST') { - const url = new URL(request.url()); - const pathParts = url.pathname.split('/'); - const id = pathParts[pathParts.length - 2]; - const body = request.postDataJSON() || {}; - const { MOCK_PROPERTIES } = await import('../../src/lib/mockData'); - const property = MOCK_PROPERTIES.find(p => p.id === id); - - if (!property) { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ valid: false, error: 'Property not found' }), - }); - } else { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - valid: true, - totalCost: (body.amount || 10) * property.price.perToken, - }), - }); - } - } else { - await route.continue(); - } - }); -} - -// Export the walletFixture helper as requested -export const walletFixture = setupWalletMsw; - -// Custom fixture exporting test & expect -export const test = baseTest.extend<{ - walletMswFixture: void; -}>({ - walletMswFixture: [async ({ page }, use) => { - await setupWalletMsw(page); - await use(); - }, { auto: true }], -}); - -export { expect } from '@playwright/test';