diff --git a/PR_DOCUMENTATION.md b/PR_DOCUMENTATION.md new file mode 100644 index 0000000..4908025 --- /dev/null +++ b/PR_DOCUMENTATION.md @@ -0,0 +1,38 @@ +# PR Documentation + +## Issue + +**Replace hardcoded mobile API hosts with env-driven runtime configuration** (#199) + +## Summary + +This PR introduces environment-driven runtime configuration for the mobile app API base URL, removing hardcoded localhost assumptions and enabling contributors to target the correct backend without code changes. + +## What this PR addresses + +- Hardcoded mobile API host values that only work on local emulators or a specific development setup +- Runtime configuration for both development and physical device scenarios +- Clear validation and failure behavior when configuration is invalid or missing + +## Acceptance Criteria + +- API base URLs are sourced from environment-aware configuration +- Development and device setup behavior is documented +- Invalid configuration fails clearly and predictably + +## Expected changes + +- A mobile runtime config layer that reads the API base URL from environment variables or platform-specific runtime settings +- Support for distinguishing between emulator/local host and device-hosted backend endpoints +- Documentation updates in `veilend-mobile/README.md` or related onboarding docs describing how to configure the mobile app at runtime +- Validation logic that surfaces misconfiguration during startup instead of silently failing + +## Validation + +- Verify the app uses the configured API host at runtime instead of hardcoded values +- Confirm the mobile app can connect to backend services on both emulator and device environments when configured correctly +- Check that invalid or missing configuration produces a clear error message and prevents ambiguous failures + +## Notes + +The mobile API host configuration change aligns with the current mobile architecture goal of making VeilLend easy to run in contributor environments while preserving Stellar-native privacy-first UX. diff --git a/veilend-mobile/.env.example b/veilend-mobile/.env.example index 411ab30..097493e 100644 --- a/veilend-mobile/.env.example +++ b/veilend-mobile/.env.example @@ -2,6 +2,15 @@ # Copy this file to `.env` and fill values as needed for your environment. # Backend API endpoints +# For web / Expo web, use a localhost endpoint or a running local backend. +# Example for local web development: +# API_URL_WEB=http://localhost:3000 +# For Android emulator, use the Android emulator loopback address: +# API_URL_MOBILE=http://10.0.2.2:3000 +# For iOS simulator, use localhost: +# API_URL_MOBILE=http://localhost:3000 +# For a physical device, use a machine IP accessible on the same network: +# API_URL_MOBILE=http://192.168.1.100:3000 API_URL_WEB=http://localhost:3000 API_URL_MOBILE=http://10.0.2.2:3000 diff --git a/veilend-mobile/README.md b/veilend-mobile/README.md new file mode 100644 index 0000000..2201a7f --- /dev/null +++ b/veilend-mobile/README.md @@ -0,0 +1,45 @@ +# VeilLend Mobile Runtime Configuration + +The VeilLend mobile app now uses runtime configuration for backend API hosts instead of hardcoded localhost values. + +## Supported Environment Variables + +- `API_URL_WEB` — backend URL used when running on web or Expo web. +- `API_URL_MOBILE` — backend URL used when running in mobile emulators and on devices. + +## Setup + +1. Copy the example environment file: + +```bash +cd veilend-mobile +cp .env.example .env +``` + +2. Set the backend endpoints for your environment. + +### Example values + +- Web development: `API_URL_WEB=http://localhost:3000` +- Android emulator: `API_URL_MOBILE=http://10.0.2.2:3000` +- iOS simulator: `API_URL_MOBILE=http://localhost:3000` +- Physical device: `API_URL_MOBILE=http://:3000` + +## Device scenarios + +- **Android emulator:** use `10.0.2.2` to reach the host machine from the emulator. +- **iOS simulator:** use `localhost` because the simulator shares the host network. +- **Physical device:** use your computer's LAN IP address (for example `192.168.1.100`) so the device can reach the backend. + +## Validation + +The app validates these values at startup and will fail clearly if a required configuration is missing or invalid. + +- Missing `API_URL_WEB` or `API_URL_MOBILE` will surface a startup error. +- Invalid URLs will be rejected with a descriptive message. + +## Runtime config flow + +- `app.config.ts` reads `API_URL_WEB` and `API_URL_MOBILE` from the environment and injects them into Expo `extra`. +- `src/utils/config.ts` resolves the active backend URL for the current platform. +- `src/utils/api.ts` uses the resolved runtime API host as the Axios `baseURL`. diff --git a/veilend-mobile/app.config.ts b/veilend-mobile/app.config.ts new file mode 100644 index 0000000..ccd57eb --- /dev/null +++ b/veilend-mobile/app.config.ts @@ -0,0 +1,20 @@ +import 'dotenv/config'; + +const requiredEnv = (key: string): string => { + const value = process.env[key]?.trim(); + if (!value) { + throw new Error( + `Missing required environment variable ${key}. Copy veilend-mobile/.env.example to veilend-mobile/.env and set ${key}.` + ); + } + return value; +}; + +export default ({ config }: any) => ({ + ...config, + extra: { + ...config.extra, + API_URL_WEB: requiredEnv('API_URL_WEB'), + API_URL_MOBILE: requiredEnv('API_URL_MOBILE'), + }, +}); diff --git a/veilend-mobile/package-lock.json b/veilend-mobile/package-lock.json index 440544a..4dde2e2 100644 --- a/veilend-mobile/package-lock.json +++ b/veilend-mobile/package-lock.json @@ -43,6 +43,7 @@ "devDependencies": { "@types/react": "~19.1.10", "babel-preset-expo": "^54.0.10", + "dotenv": "^16.1.4", "postcss": "^8.5.6", "tailwindcss": "^3.3.2", "tsx": "^4.22.4", @@ -2560,6 +2561,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/@expo/env/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@expo/env/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2945,6 +2958,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/@expo/metro-config/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@expo/metro-config/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5881,15 +5906,16 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.1.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.1.4.tgz", + "integrity": "sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, "node_modules/dotenv-expand": { @@ -5907,6 +5933,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/veilend-mobile/package.json b/veilend-mobile/package.json index 2fa36a6..2fb698c 100644 --- a/veilend-mobile/package.json +++ b/veilend-mobile/package.json @@ -46,6 +46,7 @@ "devDependencies": { "@types/react": "~19.1.10", "babel-preset-expo": "^54.0.10", + "dotenv": "^16.1.4", "postcss": "^8.5.6", "tailwindcss": "^3.3.2", "tsx": "^4.22.4", diff --git a/veilend-mobile/src/utils/api.ts b/veilend-mobile/src/utils/api.ts index edbe327..c06a2a0 100644 --- a/veilend-mobile/src/utils/api.ts +++ b/veilend-mobile/src/utils/api.ts @@ -1,11 +1,9 @@ import axios from 'axios'; import { useStore } from '../store/store'; -import { Platform } from 'react-native'; import { reportError } from './errorReporting'; +import { getApiBaseUrl } from './config'; -const API_URL = Platform.OS === 'web' - ? 'http://localhost:3000' - : 'http://10.0.2.2:3000'; +const API_URL = getApiBaseUrl(); const api = axios.create({ baseURL: API_URL, diff --git a/veilend-mobile/src/utils/config.ts b/veilend-mobile/src/utils/config.ts new file mode 100644 index 0000000..8831026 --- /dev/null +++ b/veilend-mobile/src/utils/config.ts @@ -0,0 +1,48 @@ +import Constants from 'expo-constants'; +import { Platform } from 'react-native'; + +const CONFIG_KEYS = { + web: 'API_URL_WEB', + default: 'API_URL_MOBILE', +}; + +function getRawConfigValue(key: string): string | undefined { + const extraConfig = (Constants.expoConfig?.extra ?? {}) as Record; + const extraValue = extraConfig[key]; + + if (typeof extraValue === 'string' && extraValue.trim()) { + return extraValue.trim(); + } + + const envValue = process.env[key]; + if (typeof envValue === 'string' && envValue.trim()) { + return envValue.trim(); + } + + return undefined; +} + +function validateUrl(url: string, key: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(); + } + return url; + } catch { + throw new Error(`Invalid ${key} value: ${url}. Use a valid http:// or https:// URL.`); + } +} + +export function getApiBaseUrl(): string { + const key = Platform.OS === 'web' ? CONFIG_KEYS.web : CONFIG_KEYS.default; + const apiUrl = getRawConfigValue(key); + + if (!apiUrl) { + throw new Error( + `${key} is not configured. Set ${key} in veilend-mobile/.env or via your EAS/build environment variables.` + ); + } + + return validateUrl(apiUrl, key); +} diff --git a/veilend-web/package-lock.json b/veilend-web/package-lock.json index 83dc600..c2dc31c 100644 --- a/veilend-web/package-lock.json +++ b/veilend-web/package-lock.json @@ -23,18 +23,30 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9", "eslint-config-next": "16.2.9", "eslint-config-prettier": "^10.1.8", + "jsdom": "^29.1.1", "prettier": "^3.8.4", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -48,6 +60,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -370,6 +433,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", @@ -408,6 +503,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -453,6 +558,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.74.3", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.74.3.tgz", @@ -1314,6 +1572,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3752,6 +4028,13 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -4489,6 +4772,107 @@ "tailwindcss": "4.3.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -4575,6 +4959,59 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5269,6 +5706,27 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", @@ -5882,6 +6340,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "11.1.4", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", @@ -6452,6 +6920,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6487,6 +6976,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6573,6 +7076,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -6707,6 +7217,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6745,6 +7266,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -6858,6 +7387,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -8420,6 +8962,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8533,6 +9088,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8939,6 +9504,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -9231,6 +9803,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9737,6 +10360,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9756,6 +10390,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -9851,6 +10492,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -10480,6 +11131,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10737,6 +11401,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -10982,6 +11684,16 @@ "dev": true, "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -11067,6 +11779,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11361,6 +12087,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -12059,6 +12798,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12141,6 +12893,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.7", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", @@ -12296,6 +13055,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12317,6 +13096,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -12978,6 +13783,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12987,6 +13805,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13140,6 +13993,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/veilend-web/package.json b/veilend-web/package.json index 05d6b2f..490ac10 100644 --- a/veilend-web/package.json +++ b/veilend-web/package.json @@ -27,12 +27,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9", "eslint-config-next": "16.2.9", "eslint-config-prettier": "^10.1.8", + "jsdom": "^29.1.1", "prettier": "^3.8.4", "tailwindcss": "^4", "typescript": "^5", diff --git a/veilend-web/src/__tests__/campaignAnalytics.test.ts b/veilend-web/src/__tests__/campaignAnalytics.test.ts new file mode 100644 index 0000000..f6ec223 --- /dev/null +++ b/veilend-web/src/__tests__/campaignAnalytics.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { trackCampaignEvent } from '@/lib/campaignAnalytics'; +import { POST } from '@/app/api/campaign-events/route'; + +const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + +describe('trackCampaignEvent', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'fetch', { + value: fetchMock, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'fetch', { + value: fetchMock, + writable: true, + configurable: true, + }); + vi.stubGlobal('navigator', { sendBeacon: vi.fn() } as any); + window.history.pushState({}, '', '/campaign?utm_source=test-source'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete (window as any).fetch; + delete (globalThis as any).fetch; + }); + + it('uses navigator.sendBeacon when available', () => { + trackCampaignEvent('campaign_cta_click', { ctaId: 'button-1' }); + + expect(navigator.sendBeacon).toHaveBeenCalledTimes(1); + expect(navigator.sendBeacon).toHaveBeenCalledWith('/api/campaign-events', expect.any(Blob)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('falls back to fetch when navigator.sendBeacon is unavailable', async () => { + Object.defineProperty(navigator, 'sendBeacon', { + value: undefined, + configurable: true, + writable: true, + }); + window.history.pushState({}, '', '/dashboard?utm_source=test-source'); + + trackCampaignEvent('campaign_page_visit', { ctaLabel: 'launch' }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/campaign-events', expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: expect.any(String), + keepalive: true, + })); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.payload.path).toBe('/dashboard'); + expect(body.payload.source).toBe('test-source'); + expect(body.payload.ctaLabel).toBe('launch'); + }); +}); + +describe('campaign events route', () => { + let consoleInfoSpy: ReturnType; + + beforeEach(() => { + consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 400 for invalid JSON payload', async () => { + const request = new Request('http://localhost/api/campaign-events', { + method: 'POST', + body: '{invalid-json', + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Invalid JSON payload' }); + }); + + it('returns 400 for unsupported campaign events', async () => { + const request = new Request('http://localhost/api/campaign-events', { + method: 'POST', + body: JSON.stringify({ event: 'invalid_event', campaign: 'grantfox-oss-stellar' }), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Unsupported campaign event' }); + }); + + it('accepts valid campaign events and sanitizes payload values', async () => { + const requestBody = { + event: 'campaign_cta_click', + campaign: 'grantfox-oss-stellar', + timestamp: ' 2026-07-16T12:00:00Z ', + payload: { + path: ' /dashboard ', + referrer: ' ', + source: ' campaign ', + ctaId: ' join-button ', + ctaLabel: ' Join Now ', + targetUrl: ' https://example.com/page ', + interestArea: ' lending ', + }, + }; + + const request = new Request('http://localhost/api/campaign-events', { + method: 'POST', + body: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ ok: true }); + expect(consoleInfoSpy).toHaveBeenCalledWith( + '[campaign-analytics]', + expect.objectContaining({ + event: 'campaign_cta_click', + campaign: 'grantfox-oss-stellar', + payload: expect.objectContaining({ + path: '/dashboard', + source: 'campaign', + ctaId: 'join-button', + ctaLabel: 'Join Now', + targetUrl: 'https://example.com/page', + interestArea: 'lending', + }), + }) + ); + }); +}); diff --git a/veilend-web/src/__tests__/dashboardData.test.ts b/veilend-web/src/__tests__/dashboardData.test.ts new file mode 100644 index 0000000..381afc6 --- /dev/null +++ b/veilend-web/src/__tests__/dashboardData.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { fetchDashboardData } from '@/lib/api/dashboard'; + +const fetchMock = vi.fn(); + +describe('fetchDashboardData', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps positions and transactions into dashboard data', async () => { + const positions = [ + { + assetAddress: 'USDC-ABC', + depositedAmount: '10000000', + borrowedAmount: '5000000', + }, + { + assetAddress: 'XLM-XYZ', + depositedAmount: '20000000', + borrowedAmount: '0', + }, + ]; + + const transactions = [ + { + id: '1', + type: 'deposit', + amount: '5000000', + assetAddress: 'USDC-ABC', + timestamp: '2026-07-16T10:00:00Z', + txHash: 'hash1', + }, + { + id: '2', + type: 'borrow', + amount: '10000000', + assetAddress: 'XLM-XYZ', + timestamp: '2026-07-16T12:00:00Z', + txHash: 'hash2', + }, + ]; + + fetchMock.mockImplementation((input: RequestInfo) => { + const url = String(input); + + if (url.includes('/indexer/positions/')) { + return Promise.resolve(new Response(JSON.stringify({ positions }), { status: 200 })); + } + + if (url.includes('/indexer/transactions/')) { + return Promise.resolve(new Response(JSON.stringify({ transactions }), { status: 200 })); + } + + return Promise.resolve(new Response(null, { status: 404 })); + }); + + const dashboard = await fetchDashboardData('GTESTADDRESS'); + + expect(dashboard.portfolio.totalDepositedUsd).toBeCloseTo(1.22, 5); + expect(dashboard.portfolio.totalBorrowedUsd).toBeCloseTo(0.5, 5); + expect(dashboard.portfolio.totalBalanceUsd).toBeCloseTo(0.72, 5); + expect(dashboard.portfolio.depositedAssets).toHaveLength(2); + expect(dashboard.portfolio.borrowedAssets).toHaveLength(1); + expect(dashboard.recentActivity[0].id).toBe('2'); + expect(dashboard.recentActivity[1].id).toBe('1'); + }); + + it('returns a valid dashboard structure when indexer data is empty', async () => { + fetchMock.mockImplementation((input: RequestInfo) => { + const url = String(input); + + if (url.includes('/indexer/positions/')) { + return Promise.resolve(new Response(JSON.stringify({ positions: [] }), { status: 200 })); + } + + if (url.includes('/indexer/transactions/')) { + return Promise.resolve(new Response(JSON.stringify({ transactions: [] }), { status: 200 })); + } + + return Promise.resolve(new Response(null, { status: 404 })); + }); + + const dashboard = await fetchDashboardData('GEMPTYADDRESS'); + + expect(dashboard.portfolio.totalDepositedUsd).toBe(0); + expect(dashboard.portfolio.totalBorrowedUsd).toBe(0); + expect(dashboard.portfolio.totalBalanceUsd).toBe(0); + expect(dashboard.portfolio.healthFactor).toBeCloseTo(99.99, 2); + expect(dashboard.portfolio.depositedAssets).toHaveLength(0); + expect(dashboard.portfolio.borrowedAssets).toHaveLength(0); + expect(dashboard.recentActivity).toHaveLength(0); + }); + + it('throws a helpful error when the indexer returns a failure', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); + + await expect(fetchDashboardData()).rejects.toThrow('Could not load live dashboard data. Is the NestJS indexer running?'); + }); +}); diff --git a/veilend-web/src/__tests__/useStellarWallet.test.tsx b/veilend-web/src/__tests__/useStellarWallet.test.tsx new file mode 100644 index 0000000..8e53345 --- /dev/null +++ b/veilend-web/src/__tests__/useStellarWallet.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useStellarWallet } from '@/hooks/useStellarWallet'; +import * as auth from '@/lib/stellar/auth'; +import * as walletLib from '@/lib/stellar/wallet'; + +vi.mock('@/lib/stellar/auth', () => ({ + isWalletAuthenticated: vi.fn(), + getAuthenticatedWallet: vi.fn(), + createAuthSession: vi.fn(), + clearAuthSession: vi.fn(), +})); + +vi.mock('@/lib/stellar/wallet', () => ({ + connectFreighter: vi.fn(), + isFreighterInstalled: vi.fn(), + disconnectWallet: vi.fn(), +})); + +function WalletHookHarness() { + const wallet = useStellarWallet(); + + return ( +
+
{wallet.address ?? 'null'}
+
{wallet.publicKey ?? 'null'}
+
{String(wallet.isConnected)}
+
{String(wallet.isAuthenticated)}
+
{String(wallet.isInstalled)}
+
{String(wallet.isLoading)}
+
{wallet.error ?? 'null'}
+ + + +
+ ); +} + +const mockedAuth = vi.mocked(auth, { shallow: true }); +const mockedWalletLib = vi.mocked(walletLib, { shallow: true }); + +describe('useStellarWallet', () => { + beforeEach(() => { + window.localStorage.clear(); + mockedAuth.isWalletAuthenticated.mockReset(); + mockedAuth.getAuthenticatedWallet.mockReset(); + mockedAuth.createAuthSession.mockReset(); + mockedAuth.clearAuthSession.mockReset(); + mockedWalletLib.isFreighterInstalled.mockReset(); + mockedWalletLib.connectFreighter.mockReset(); + mockedWalletLib.disconnectWallet.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initializes with wallet installation state and unauthenticated session', async () => { + mockedWalletLib.isFreighterInstalled.mockReturnValue(true); + mockedAuth.isWalletAuthenticated.mockReturnValue(false); + mockedAuth.getAuthenticatedWallet.mockReturnValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('isInstalled')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); + expect(screen.getByTestId('isConnected')).toHaveTextContent('false'); + expect(screen.getByTestId('isLoading')).toHaveTextContent('false'); + expect(screen.getByTestId('error')).toHaveTextContent('null'); + }); + + it('connects successfully and creates an auth session', async () => { + mockedWalletLib.isFreighterInstalled.mockReturnValue(true); + mockedAuth.isWalletAuthenticated.mockReturnValue(false); + mockedAuth.getAuthenticatedWallet.mockReturnValue(null); + mockedWalletLib.connectFreighter.mockResolvedValue({ address: 'GTESTADDRESS', publicKey: 'GTESTADDRESS' }); + mockedAuth.createAuthSession.mockReturnValue({ address: 'GTESTADDRESS', publicKey: 'GTESTADDRESS', authenticated: true }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('isInstalled')).toHaveTextContent('true'); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId('isConnected')).toHaveTextContent('true'); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('true'); + expect(screen.getByTestId('address')).toHaveTextContent('GTESTADDRESS'); + expect(screen.getByTestId('error')).toHaveTextContent('null'); + }); + + expect(mockedAuth.createAuthSession).toHaveBeenCalledWith('GTESTADDRESS', 'GTESTADDRESS'); + }); + + it('disconnects and clears auth session', async () => { + mockedWalletLib.isFreighterInstalled.mockReturnValue(true); + mockedAuth.isWalletAuthenticated.mockReturnValue(true); + mockedAuth.getAuthenticatedWallet.mockReturnValue('GTESTADDRESS'); + mockedWalletLib.disconnectWallet.mockResolvedValue(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('true'); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /disconnect/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId('isConnected')).toHaveTextContent('false'); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); + expect(screen.getByTestId('address')).toHaveTextContent('null'); + }); + + expect(mockedAuth.clearAuthSession).toHaveBeenCalled(); + }); + + it('sets error state when connect fails', async () => { + mockedWalletLib.isFreighterInstalled.mockReturnValue(true); + mockedAuth.isWalletAuthenticated.mockReturnValue(false); + mockedAuth.getAuthenticatedWallet.mockReturnValue(null); + mockedWalletLib.connectFreighter.mockRejectedValue(new Error('Freighter unavailable')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('isInstalled')).toHaveTextContent('true'); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^connect$/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('Freighter unavailable'); + expect(screen.getByTestId('isLoading')).toHaveTextContent('false'); + expect(screen.getByTestId('isConnected')).toHaveTextContent('false'); + }); + }); +}); diff --git a/veilend-web/src/__tests__/wallet.test.tsx b/veilend-web/src/__tests__/wallet.test.tsx new file mode 100644 index 0000000..a0ee10e --- /dev/null +++ b/veilend-web/src/__tests__/wallet.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { useWallet } from '@/context/WalletContext'; +import { WalletConnect, isFreighterInstalled } from '@/components/WalletConnect'; +import { WalletStatus } from '@/components/WalletStatus'; + +vi.mock('@/context/WalletContext', () => ({ + WalletProvider: ({ children }: { children: React.ReactNode }) => children, + useWallet: vi.fn(), +})); + +const mockedUseWallet = vi.mocked(useWallet); + +describe('WalletStatus', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: true, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByText(/Initializing wallet/i)).toBeInTheDocument(); + }); + + it('renders error state with details', () => { + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: false, + error: 'Freighter wallet not found', + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByText(/Wallet error/i)).toBeInTheDocument(); + expect(screen.getByText(/Freighter wallet not found/i)).toBeInTheDocument(); + }); + + it('renders connected state with details', () => { + mockedUseWallet.mockReturnValue({ + address: 'GABCDEF1234567890', + publicKey: 'GABCDEF1234567890', + isConnected: true, + isAuthenticated: true, + isInstalled: true, + isLoading: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByText(/Connected/i)).toBeInTheDocument(); + expect(screen.getByText('GABCDE...7890')).toBeInTheDocument(); + }); + + it('renders not connected state', () => { + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByText(/Not connected/i)).toBeInTheDocument(); + }); +}); + +describe('WalletConnect', () => { + beforeEach(() => { + (window as any).freighter = { + isConnected: async () => ({ isConnected: true }), + getAddress: async () => ({ address: 'GABCDEF1234567890' }), + signTransaction: async () => ({ signedTxXdr: 'dummy' }), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete (window as any).freighter; + }); + + it('renders connect wallet button when wallet is not connected', () => { + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument(); + }); + + it('opens modal and triggers connect action', async () => { + const connectMock = vi.fn().mockResolvedValue(undefined); + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: false, + error: null, + connect: connectMock, + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })); + expect(screen.getByRole('heading', { name: /Connect Wallet/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^Connect$/i })); + + await waitFor(() => { + expect(connectMock).toHaveBeenCalled(); + }); + }); + + it('renders connected state and disconnects', async () => { + const disconnectMock = vi.fn().mockResolvedValue(undefined); + + mockedUseWallet.mockReturnValue({ + address: 'GABCDEF1234567890', + publicKey: 'GABCDEF1234567890', + isConnected: true, + isAuthenticated: true, + isInstalled: true, + isLoading: false, + error: null, + connect: vi.fn(), + disconnect: disconnectMock, + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByText('GABCDE...7890')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /disconnect/i })); + + await waitFor(() => { + expect(disconnectMock).toHaveBeenCalled(); + }); + }); + + it('shows install prompt when Freighter is not available', () => { + delete (window as any).freighter; + + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: false, + isLoading: false, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })); + + expect(screen.getByText(/Freighter wallet not detected/i)).toBeInTheDocument(); + expect(isFreighterInstalled()).toBe(false); + }); + + it('disables connect button while loading', () => { + mockedUseWallet.mockReturnValue({ + address: null, + publicKey: null, + isConnected: false, + isAuthenticated: false, + isInstalled: true, + isLoading: true, + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + expect(screen.getByRole('button', { name: /connecting/i })).toBeDisabled(); + }); +}); diff --git a/veilend-web/src/app/(dashboard)/page.tsx b/veilend-web/src/app/(dashboard)/page.tsx index 63f6429..1b38861 100644 --- a/veilend-web/src/app/(dashboard)/page.tsx +++ b/veilend-web/src/app/(dashboard)/page.tsx @@ -1,302 +1,23 @@ 'use client' -import * as React from "react" -import { - Wallet, - ShieldAlert, - ArrowUpRight, - ArrowDownLeft, - Layers, - Coins, - Activity, - RefreshCw, - Info -} from "lucide-react" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Progress } from "@/components/ui/progress" -import { Skeleton } from "@/components/ui/skeleton" -import { useWallet } from "@/context/WalletContext" -import { WalletConnect } from "@/components/WalletConnect" -import { WalletStatus } from "@/components/WalletStatus" +import { useEffect } from "react" +import { useRouter } from "next/navigation" -export default function VeilLendDashboard() { - const { isConnected, isAuthenticated } = useWallet(); - // Global simulation states to demonstrate acceptance criteria loading/empty loops - const [isLoading, setIsLoading] = React.useState(false) - const [isEmpty, setIsEmpty] = React.useState(false) +// This route was an alternate dashboard surface. Redirect users to the canonical +// `/dashboard` route to avoid duplicate experiences and keep a single active dashboard. +export default function RedirectToCanonicalDashboard() { + const router = useRouter() - const handleRefreshSimulation = () => { - setIsLoading(true) - setTimeout(() => setIsLoading(false), 1200) - } - - // Show wallet connection prompt if not connected - if (!isConnected || !isAuthenticated) { - return ( -
- - -
- -
- Connect Wallet - - Connect your Stellar wallet to access the VeilLend dashboard and manage your shielded assets. - -
- - -

- By connecting, you agree to the VeilLend Terms of Service. -

-
-
-
- ) - } + useEffect(() => { + router.replace('/dashboard') + }, [router]) return ( -
- - {/* Dashboard Top Management Header */} -
-
-

- Privacy Vault Command -

-
-

- Monitor shield variables, collateral ratios, and active Soroban smart credit allocations. -

- -
-
- - {/* Interactive Simulation Controls Area */} -
- - -
+
+
+

Redirecting to dashboard...

+

You will be forwarded to the primary VeilLend dashboard.

- - {/* --- LAYER 1: PROTOCOL RISK ENGINE OVERVIEW --- */} - {!isEmpty && !isLoading && ( -
- - - Net Account Value - $124,500.00 USDC - - - - - Collateral Factor Health - 78.4% - - - - - - - - Current Borrow Limit Utilization - 42.1% - - - - - -
- )} - - {/* --- LAYER 2: PRIMARY DATA GRIDS --- */} -
- - {/* SECTION A: SHIELDED ASSET BALANCES */} - - -
- - Shielded Liquidity Balances - - USDC / XLM Only -
- Your current unencumbered assets residing within zero-knowledge layers. -
- - {isLoading ? ( -
- - -
- ) : isEmpty ? ( -
-
- -
-

No shielded assets detected. Initialize a private transfer payload sequence to populate your balance limits.

-
- ) : ( -
-
-
-
U
-
-
USD Coin (Stellar Asset)
-
USDC
-
-
-
-
150,000.00
-
$150,000.00
-
-
-
-
-
X
-
-
Native Lumens
-
XLM
-
-
-
-
85,400.00
-
$11,102.00
-
-
-
- )} -
-
- - {/* SECTION B: COLLATERAL & CREDIT BORROWING TRACKS */} - - -
- - Debt & Collateralized Ratios - - Risk Engine -
- Active debt obligations measured against locked credit collateral pools. -
- - {isLoading ? ( -
- -
- ) : isEmpty ? ( -
-
- -
-

No current leverage parameters active. Lock collateral into a private Soroban contract framework to open lines of credit.

-
- ) : ( -
-
-
- Total Collateral Deposited -
$50,000.00
-
-
- Active Borrowed Liabilities -
$21,050.00
-
-
- -
-
- - Liquidation Point Safeguard Matrix - - 85.00% Max Threshold -
-
- -
-
-
-
- )} - - -
- - {/* --- LAYER 3: REAL-TIME CRYPTOGRAPHIC ACTIVITY LOGS --- */} - - -
- - Cryptographic Ledger Audit Log - - Live Handshakes Stream -
- Real-time status tracking of structural interactions across the VeilLend core. -
- - {isLoading ? ( -
- - -
- ) : isEmpty ? ( -
- No historical zero-knowledge proof evaluations or balance mutations exist on this account scope. -
- ) : ( -
- - - - - - - - - - - - - - - - - - - - -
Operation ActionExecution StatusBlock Sequence
- Shielded Balance Allocation Inbound - - ZK_PROOF_VERIFIED - #5829141
- Collateral Lock Allocation Call - - SOROBAN_SUCCESS - #5829023
-
- )} -
-
) } \ No newline at end of file diff --git a/veilend-web/src/lib/campaignAnalytics.ts b/veilend-web/src/lib/campaignAnalytics.ts index b257fba..ae0a940 100644 --- a/veilend-web/src/lib/campaignAnalytics.ts +++ b/veilend-web/src/lib/campaignAnalytics.ts @@ -46,12 +46,18 @@ export function trackCampaignEvent(event: CampaignEventName, payload: CampaignEv return; } - fetch(ANALYTICS_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: serializedBody, - keepalive: true, - }).catch(() => undefined); + const result = typeof fetch === 'function' + ? fetch(ANALYTICS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: serializedBody, + keepalive: true, + }) + : undefined; + + if (result && typeof result.catch === 'function') { + result.catch(() => undefined); + } } diff --git a/veilend-web/src/test/setup.ts b/veilend-web/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/veilend-web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/veilend-web/vitest.config.ts b/veilend-web/vitest.config.ts new file mode 100644 index 0000000..fb5af70 --- /dev/null +++ b/veilend-web/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: './src/test/setup.ts', + include: ['src/**/*.test.{ts,tsx}'], + }, +});