diff --git a/package-lock.json b/package-lock.json index cba81f7..edfac0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "4.1.9", "eslint": "^8.57.1", "eslint-config-next": "15.5.18", "jsdom": "^29.1.1", @@ -247,6 +248,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3569,6 +3580,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -4039,6 +4081,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -7845,6 +7906,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -8651,6 +8719,61 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9400,6 +9523,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index f5744cc..b92699d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "next start", "lint": "next lint", "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.include src/lib/soroban.ts", "playwright": "playwright test --config e2e/playwright.config.ts", "contributors:sync": "node scripts/sync-lernza-contributors.mjs", "contributors:github-insights": "node scripts/record-lernza-github-coauthors.mjs" @@ -42,6 +43,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "4.1.9", "eslint": "^8.57.1", "eslint-config-next": "15.5.18", "jsdom": "^29.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2294d7a..531fc28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.3(vite@8.1.0(@types/node@20.19.43)) + '@vitest/coverage-v8': + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.9) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -95,7 +98,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@20.19.43)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) + version: 4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) packages: @@ -164,6 +167,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1223,6 +1230,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -1363,6 +1379,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2515,6 +2534,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -2765,6 +2787,18 @@ packages: isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2781,6 +2815,9 @@ packages: js-sha3@0.8.0: resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3014,10 +3051,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4723,6 +4767,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -5819,6 +5865,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.0(@types/node@20.19.43) + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -6004,6 +6064,12 @@ snapshots: ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async-limiter@1.0.1: {} @@ -6833,7 +6899,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -6863,7 +6929,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -6878,7 +6944,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7534,6 +7600,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -7775,6 +7843,19 @@ snapshots: isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -7798,6 +7879,8 @@ snapshots: js-sha3@0.8.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -8022,10 +8105,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -9506,7 +9599,7 @@ snapshots: '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@4.1.9(@types/node@20.19.43)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)): + vitest@4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.43)) @@ -9530,6 +9623,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts new file mode 100644 index 0000000..854b8ee --- /dev/null +++ b/src/lib/soroban.service.test.ts @@ -0,0 +1,512 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { assembleTransactionMock } = vi.hoisted(() => ({ + assembleTransactionMock: vi.fn(), +})); + +vi.mock('@stellar/stellar-sdk', async importOriginal => { + const actual = await importOriginal(); + + return { + ...actual, + rpc: { + ...actual.rpc, + assembleTransaction: assembleTransactionMock, + }, + }; +}); + +import { + Account, + Contract, + type FeeBumpTransaction, + StrKey, + type Transaction, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { + SorobanService, + formatAssetAmount, + formatCredits, + formatLockTime, + parseCreditsFromXdrResult, + parsePoolsFromXdrResult, + sorobanService, + unlockAssets, +} from './soroban'; + +const POOL_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +const USER_PUBLIC_KEY = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)); +const POOL_ID = 'pool-xlm'; + +type MockRpcServer = { + getAccount: ReturnType; + simulateTransaction: ReturnType; + sendTransaction: ReturnType; + getTransaction: ReturnType; +}; + +function makePoolNative(overrides: Record = {}) { + return { + id: 'pool-xlm', + contract_address: POOL_CONTRACT_ID, + asset_code: 'XLM', + is_native: true, + daily_rate: BigInt(5_000_000), + min_lock_period: BigInt(86_400), + total_locked: BigInt(100_000_000), + total_users: BigInt(3), + is_active: true, + created_at: BigInt(1_700_000_000), + ...overrides, + }; +} + +function invokeContractFromOperation(op: xdr.Operation) { + return op + .body() + .invokeHostFunctionOp() + .hostFunction() + .invokeContract(); +} + +function makeMockRpcServer(overrides: Partial = {}): MockRpcServer { + return { + getAccount: vi.fn().mockResolvedValue(new Account(USER_PUBLIC_KEY, '0')), + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn().mockResolvedValue({ status: 'SUCCESS' }), + ...overrides, + }; +} + +function makeService({ + factory = false, + pool = true, + rpcServer = makeMockRpcServer(), +}: { + factory?: boolean; + pool?: boolean; + rpcServer?: MockRpcServer; +} = {}) { + const service = new SorobanService(); + const svc = service as unknown as { + factoryContract?: Contract; + poolContracts: Map; + rpcServer: MockRpcServer; + }; + + svc.rpcServer = rpcServer; + if (factory) svc.factoryContract = new Contract(POOL_CONTRACT_ID); + else svc.factoryContract = undefined; + if (pool) svc.poolContracts.set(POOL_ID, new Contract(POOL_CONTRACT_ID)); + + return { service, rpcServer }; +} + +function mockAssembleTransactionPassthrough() { + assembleTransactionMock.mockImplementation((transaction: Transaction | FeeBumpTransaction) => ({ + build: () => transaction, + })); + return assembleTransactionMock; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + assembleTransactionMock.mockReset(); +}); + +describe('soroban formatters', () => { + it('formats credits below 1000, thousands, and millions', () => { + expect(formatCredits('999.4')).toBe('999'); + expect(formatCredits('1500')).toBe('1.5K'); + expect(formatCredits('2500000')).toBe('2.5M'); + }); + + it('formats lock time with fake timers for past, day, hour, and sub-hour cases', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); + + const now = Date.now(); + + expect(formatLockTime(now - 1)).toBe('Unlockable now'); + expect(formatLockTime(now + 2 * 24 * 60 * 60 * 1000)).toBe('2 days remaining'); + expect(formatLockTime(now + 3 * 60 * 60 * 1000)).toBe('3 hours remaining'); + expect(formatLockTime(now + 30 * 60 * 1000)).toBe('Less than 1 hour'); + }); + + it('formats native and issued asset amounts with the asset code suffix', () => { + expect(formatAssetAmount('12.5', { code: 'XLM', isNative: true })).toBe('12.5 XLM'); + + const issued = formatAssetAmount('1234.5', { + code: 'USDC', + issuer: USER_PUBLIC_KEY, + isNative: false, + }); + + expect(issued).toMatch(/^(1,234\.5|1234\.5) USDC$/); + }); +}); + +describe('soroban XDR wrappers', () => { + it('parses a valid ScVec of pool maps into PoolInfo entries', () => { + const scVal = nativeToScVal([makePoolNative()]); + + const pools = parsePoolsFromXdrResult(scVal); + + expect(pools).toHaveLength(1); + expect(pools[0]).toMatchObject({ + id: 'pool-xlm', + contractAddress: POOL_CONTRACT_ID, + asset: { code: 'XLM', isNative: true }, + dailyRate: '0.5000000', + minLockPeriod: 86_400, + totalLocked: '10.0000000', + totalUsers: 3, + isActive: true, + createdAt: 1_700_000_000, + }); + }); + + it('returns an empty array for an empty ScVec', () => { + expect(parsePoolsFromXdrResult(nativeToScVal([]))).toEqual([]); + }); + + it('skips malformed entries and warns while keeping valid pools', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const scVal = nativeToScVal([ + makePoolNative({ id: 'valid-1' }), + 'malformed-entry', + makePoolNative({ id: 'valid-2' }), + ]); + + const pools = parsePoolsFromXdrResult(scVal); + + expect(pools.map(pool => pool.id)).toEqual(['valid-1', 'valid-2']); + expect(warnSpy).toHaveBeenCalledWith( + '[SmartDrop] parsePoolsFromXdr: skipping malformed pool at index 1:', + expect.any(TypeError), + ); + }); + + it('parses i128 credit stroops into display units', () => { + const credits = nativeToScVal(BigInt(25_000_000), { type: 'i128' }); + + expect(parseCreditsFromXdrResult(credits)).toBe('2.5000000'); + }); +}); + +describe('soroban transaction builders', () => { + it('builds lock_assets with the user ScAddress and amount as i128', async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ error: 'stop before signing' }); + const callSpy = vi.spyOn(Contract.prototype, 'call'); + + const result = await service.lockAssets(POOL_ID, USER_PUBLIC_KEY, '123456789', { + signTransaction: vi.fn(), + }); + + expect(result).toEqual({ + success: false, + error: 'Simulation failed: stop before signing', + }); + expect(callSpy).toHaveBeenCalledWith( + 'lock_assets', + expect.any(xdr.ScVal), + expect.any(xdr.ScVal), + ); + + const op = callSpy.mock.results[0].value as xdr.Operation; + const invokeContract = invokeContractFromOperation(op); + const [addressArg, amountArg] = invokeContract.args(); + + expect(invokeContract.functionName().toString()).toBe('lock_assets'); + expect(addressArg.switch()).toBe(xdr.ScValType.scvAddress()); + expect(addressArg.address().switch()).toBe(xdr.ScAddressType.scAddressTypeAccount()); + expect(scValToNative(addressArg)).toBe(USER_PUBLIC_KEY); + expect(amountArg.switch()).toBe(xdr.ScValType.scvI128()); + expect(scValToNative(amountArg)).toBe(BigInt(123_456_789)); + }); + + it('converts unlock display units to stroops before delegating', async () => { + const walletApi = { signTransaction: vi.fn() }; + const unlockSpy = vi + .spyOn(sorobanService, 'unlockAssets') + .mockResolvedValue({ success: true, transactionHash: 'abc123' }); + + await expect( + unlockAssets({ + poolContractId: 'pool-xlm', + publicKey: USER_PUBLIC_KEY, + amount: '1.2345678', + walletApi, + }), + ).resolves.toEqual({ success: true, transactionHash: 'abc123' }); + + expect(unlockSpy).toHaveBeenCalledWith( + 'pool-xlm', + USER_PUBLIC_KEY, + '12345678', + walletApi, + ); + }); +}); + +describe('SorobanService RPC reads', () => { + it('getFactoryPools returns parsed pools from a simulated factory call', async () => { + const { service, rpcServer } = makeService({ factory: true, pool: false }); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { retval: nativeToScVal([makePoolNative({ id: 'factory-pool' })]) }, + }); + + const pools = await service.getFactoryPools(); + + expect(pools).toHaveLength(1); + expect(pools[0]).toMatchObject({ + id: 'factory-pool', + contractAddress: POOL_CONTRACT_ID, + totalLocked: '10.0000000', + }); + expect(rpcServer.getAccount).toHaveBeenCalledWith( + 'GBQ3WPTHKJ5XKWLOKUZJLZL2GVXR6RWQCXUVDQZWM7Q2YNLDRVGM5ZWJ', + ); + expect(rpcServer.simulateTransaction).toHaveBeenCalledTimes(1); + }); + + it('getFactoryPools returns an empty list when the factory is not initialized', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ factory: false, pool: false }); + + await expect(service.getFactoryPools()).resolves.toEqual([]); + + expect(warnSpy).toHaveBeenCalledWith( + 'Factory contract not initialized; returning empty pool list', + ); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it('getFactoryPools returns an empty list when simulation reports an error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ factory: true, pool: false }); + rpcServer.simulateTransaction.mockResolvedValue({ error: 'factory unavailable' }); + + await expect(service.getFactoryPools()).resolves.toEqual([]); + }); + + it('getUserPosition returns a parsed user position from a simulated pool call', async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal({ + amount: BigInt(75_000_000), + locked_at: BigInt(1_700_000_000), + credits: BigInt(15_000_000), + is_locked: true, + unlockable_at: BigInt(1_700_086_400), + boost_allocation: 25, + }), + }, + }); + + const position = await service.getUserPosition(POOL_ID, USER_PUBLIC_KEY); + + expect(position).toMatchObject({ + user: USER_PUBLIC_KEY, + poolId: POOL_ID, + amount: '7.5000000', + lockedAt: 1_700_000_000, + credits: '1.5000000', + isLocked: true, + unlockableAt: 1_700_086_400, + boostAllocation: 25, + }); + }); + + it('getUserPosition returns null when the pool is not registered', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + + await expect(service.getUserPosition('missing-pool', USER_PUBLIC_KEY)).resolves.toBeNull(); + + expect(warnSpy).toHaveBeenCalledWith('Pool contract not found for ID: missing-pool'); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it('getUserPosition returns null when simulation reports an error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ error: 'position failed' }); + + await expect(service.getUserPosition(POOL_ID, USER_PUBLIC_KEY)).resolves.toBeNull(); + }); + + it('calculateUserCredits returns parsed credit display units', async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { retval: nativeToScVal(BigInt(12_345_678), { type: 'i128' }) }, + }); + + await expect(service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY)).resolves.toBe( + '1.2345678', + ); + }); + + it('calculateUserCredits returns zero when the pool is not registered', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + + await expect(service.calculateUserCredits('missing-pool', USER_PUBLIC_KEY)).resolves.toBe('0'); + + expect(warnSpy).toHaveBeenCalledWith('Pool contract not found for ID: missing-pool'); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it('calculateUserCredits returns zero when simulation reports an error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ error: 'credit failed' }); + + await expect(service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY)).resolves.toBe('0'); + }); +}); + +describe('SorobanService RPC writes', () => { + it('lockAssets signs and submits an assembled transaction on success', async () => { + const { service, rpcServer } = makeService(); + const assembleSpy = mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ result: {}, minResourceFee: '321' }); + rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'lock-hash' }); + const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + + const result = await service.lockAssets(POOL_ID, USER_PUBLIC_KEY, '50000000', walletApi); + + expect(result).toEqual({ + success: true, + transactionHash: 'lock-hash', + hash: 'lock-hash', + gasUsed: '321', + }); + expect(assembleSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ minResourceFee: '321' }), + ); + expect(walletApi.signTransaction).toHaveBeenCalledWith(expect.any(String), { + networkPassphrase: expect.any(String), + }); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it('unlockAssets signs and submits an assembled transaction on success', async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ result: {}, minResourceFee: '654' }); + rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'unlock-hash' }); + const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + + const result = await service.unlockAssets(POOL_ID, USER_PUBLIC_KEY, '25000000', walletApi); + + expect(result).toEqual({ + success: true, + transactionHash: 'unlock-hash', + hash: 'unlock-hash', + gasUsed: '654', + }); + expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it('unlockAssets throws when the pool is not registered', async () => { + const { service, rpcServer } = makeService({ pool: false }); + + await expect( + service.unlockAssets('missing-pool', USER_PUBLIC_KEY, '10000000', { + signTransaction: vi.fn(), + }), + ).rejects.toThrow('Pool contract not found for ID: missing-pool'); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it('setBoost signs and submits an assembled transaction on success', async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ result: {}, minResourceFee: '777' }); + rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'boost-hash' }); + const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + + const result = await service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, walletApi); + + expect(result).toEqual({ + success: true, + transactionHash: 'boost-hash', + hash: 'boost-hash', + gasUsed: '777', + }); + expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it('setBoost rejects invalid allocation percentages before RPC calls', async () => { + const { service, rpcServer } = makeService(); + + await expect(service.setBoost(POOL_ID, USER_PUBLIC_KEY, 101, { + signTransaction: vi.fn(), + })).resolves.toEqual({ + success: false, + error: 'Allocation percentage must be between 0 and 100', + }); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); +}); + +describe('SorobanService platform stats', () => { + it('getPlatformStats aggregates pool totals from getFactoryPools', async () => { + const { service } = makeService({ pool: false }); + vi.spyOn(service, 'getFactoryPools').mockResolvedValue([ + { + id: 'pool-1', + contractAddress: POOL_CONTRACT_ID, + asset: { code: 'XLM', isNative: true }, + dailyRate: '0.5000000', + minLockPeriod: 86_400, + totalLocked: '1000', + totalUsers: 25, + isActive: true, + createdAt: 1, + }, + { + id: 'pool-2', + contractAddress: POOL_CONTRACT_ID, + asset: { code: 'USDC', issuer: USER_PUBLIC_KEY, isNative: false }, + dailyRate: '0.2500000', + minLockPeriod: 43_200, + totalLocked: '2500', + totalUsers: 15, + isActive: true, + createdAt: 2, + }, + ]); + + await expect(service.getPlatformStats()).resolves.toEqual({ + totalValueLocked: '$3,500', + totalUsers: 40, + onlineUsers: 4, + totalPools: 2, + }); + }); + + it('getPlatformStats returns zero stats when getFactoryPools throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { service } = makeService({ pool: false }); + vi.spyOn(service, 'getFactoryPools').mockRejectedValue(new Error('stats failed')); + + await expect(service.getPlatformStats()).resolves.toEqual({ + totalValueLocked: '$0', + totalUsers: 0, + onlineUsers: 0, + totalPools: 0, + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f44d157..5c302ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ test: { globals: true, // Playwright specs live in tests/ and are run by `playwright test`, not vitest. - exclude: ['tests/**', 'node_modules/**'], + exclude: ['tests/**', 'e2e/**', 'node_modules/**'], environmentMatchGlobs: [ ['src/hooks/**', 'jsdom'], ['src/lib/**', 'node'],