diff --git a/package-lock.json b/package-lock.json index 73944842..b5559021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@tanstack/react-query-devtools": "^5.100.2", "@tanstack/react-virtual": "^3.13.24", "@testing-library/dom": "^10.4.1", + "@upstash/redis": "^1.38.0", "@wagmi/connectors": "^7.1.2", "@wagmi/core": "3.4.0", "@walletconnect/web3-provider": "^1.8.0", @@ -195,6 +196,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -225,6 +227,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -336,6 +339,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -389,6 +393,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -2989,6 +2994,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3618,7 +3624,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.59.1" @@ -6987,7 +6993,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6997,7 +7003,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -7718,6 +7724,15 @@ "win32" ] }, + "node_modules/@upstash/redis": { + "version": "1.38.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.3.tgz", + "integrity": "sha512-vtS0BonQHU6kDSWvHTISh+LOuLIEk+jeMXebv20CDJ/aHGOG085SGa8OpI+I+Ow8WgPFhqH23XG8kQ2LXXRnag==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vitest/browser": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz", @@ -10164,6 +10179,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -13668,6 +13684,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -16813,6 +16830,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -19256,7 +19274,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" @@ -19275,7 +19293,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -19288,6 +19306,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -22467,7 +22486,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -22544,6 +22563,12 @@ "node": ">=0.10.0" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -23773,6 +23798,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/lib/__tests__/batchTransaction.test.ts b/src/lib/__tests__/batchTransaction.test.ts index 98d9dbc6..d1caf1a8 100644 --- a/src/lib/__tests__/batchTransaction.test.ts +++ b/src/lib/__tests__/batchTransaction.test.ts @@ -231,6 +231,25 @@ describe("BatchTransactionService", () => { expect(result.error).toBe("Insufficient balance"); }); + it("returns the provider message when no revert data is available", async () => { + const { BatchTransactionService } = await import("../batchTransaction"); + const executor: BatchPurchaseExecutor = { + execute: jest.fn(async () => { + throw new Error("RPC connection failed"); + }), + }; + + const result = await BatchTransactionService.executeBatchPurchase( + [validItem], + walletAddress, + 0.005, + executor, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("RPC connection failed"); + }); + it("returns a user rejection without fabricating a hash", async () => { const { BatchTransactionService } = await import("../batchTransaction"); const executor: BatchPurchaseExecutor = { diff --git a/src/lib/__tests__/csrf.test.ts b/src/lib/__tests__/csrf.test.ts index 327f4610..0d1a1a35 100644 --- a/src/lib/__tests__/csrf.test.ts +++ b/src/lib/__tests__/csrf.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { NextResponse } from 'next/server'; import { + getAuthStatePart, + getCsrfSessionId, generateTokenForSession, validateCsrf, withCsrf, @@ -84,6 +86,33 @@ const signWithSecret = (secret: string, sessionId: string, authState: string) => .update(`${sessionId}:${authState}`) .digest('hex'); +describe('session and authentication binding', () => { + it('returns an existing CSRF session without replacing it', () => { + const request = createRequest({ sessionId: 'existing-session' }); + + expect(getCsrfSessionId(asNextRequest(request))).toEqual({ + sessionId: 'existing-session', + isNew: false, + }); + }); + + it('creates a new session when the CSRF cookie is missing', () => { + const randomUUID = jest.spyOn(crypto, 'randomUUID').mockReturnValue('generated-session'); + + expect(getCsrfSessionId(asNextRequest(createRequest()))).toEqual({ + sessionId: 'generated-session', + isNew: true, + }); + + randomUUID.mockRestore(); + }); + + it('reads the auth cookie and defaults to an anonymous state', () => { + expect(getAuthStatePart(asNextRequest(createRequest({ authToken: 'auth-1' })))).toBe('auth-1'); + expect(getAuthStatePart(asNextRequest(createRequest()))).toBe(''); + }); +}); + describe('generateTokenForSession', () => { it('fails closed (throws) when CSRF_SECRET is unset', () => { delete process.env.CSRF_SECRET; @@ -108,7 +137,9 @@ describe('generateTokenForSession', () => { it('produces different tokens for different sessions/auth states', () => { const tokenA = generateTokenForSession('session-1', 'auth-1'); const tokenB = generateTokenForSession('session-2', 'auth-1'); + const tokenC = generateTokenForSession('session-1', 'auth-2'); expect(tokenA).not.toBe(tokenB); + expect(tokenA).not.toBe(tokenC); }); }); diff --git a/src/lib/__tests__/portfolioService.test.ts b/src/lib/__tests__/portfolioService.test.ts index bf50d63c..bc4156a2 100644 --- a/src/lib/__tests__/portfolioService.test.ts +++ b/src/lib/__tests__/portfolioService.test.ts @@ -17,6 +17,14 @@ jest.mock('@/utils/logger', () => ({ }, })); +jest.mock('@/config/chains', () => ({ + CHAIN_CONFIG: { + 1: { name: 'Ethereum', symbol: 'ETH', color: '#627EEA' }, + 137: { name: 'Polygon', symbol: 'MATIC', color: '#8247E5' }, + 56: { name: 'Binance Smart Chain', symbol: 'BNB', color: '#F3BA2F' }, + }, +})); + import { PortfolioService } from '@/lib/portfolioService'; const ADDRESS = '0xdeadbeef'; @@ -109,3 +117,51 @@ describe('PortfolioService.fetchPortfolioOverview (#504)', () => { expect(perfSpy).toHaveBeenCalledWith(ADDRESS, 7); }); }); + +describe('PortfolioService.fetchMultiChainPortfolio', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('maps holdings and gas balances into per-chain and aggregate totals', async () => { + const portfolio = await PortfolioService.fetchMultiChainPortfolio('0xdeadbeef'); + + expect(portfolio.error).toBeNull(); + expect(portfolio.isLoading).toBe(false); + expect(portfolio.totalValueUSD).toBe(608570); + expect(portfolio.totalValueNative.get(1)).toBeCloseTo(130.65); + expect(portfolio.totalValueNative.get(137)).toBe(106500); + expect(portfolio.totalValueNative.get(56)).toBeCloseTo(138.2); + + expect(portfolio.chains).toEqual(expect.arrayContaining([ + expect.objectContaining({ + chainId: 1, + totalValueUSD: 344000, + totalValueNative: expect.any(Number), + gasBalance: '2.45', + gasBalanceUSD: 6500, + holdings: expect.arrayContaining([ + expect.objectContaining({ propertyId: 'prop-1', quantity: 150 }), + expect.objectContaining({ propertyId: 'prop-2', quantity: 75 }), + ]), + }), + expect.objectContaining({ + chainId: 137, + totalValueUSD: 187650, + totalValueNative: 106500, + gasBalance: '8500', + gasBalanceUSD: 7650, + holdings: [expect.objectContaining({ propertyId: 'prop-3' })], + }), + expect.objectContaining({ + chainId: 56, + totalValueUSD: 76920, + totalValueNative: 138.2, + gasBalance: '3.2', + gasBalanceUSD: 1920, + holdings: [expect.objectContaining({ propertyId: 'prop-4' })], + }), + ])); + expect(portfolio.chains.find((chain) => chain.chainId === 1)?.totalValueNative).toBeCloseTo(130.65); + }); +}); diff --git a/src/lib/__tests__/secondaryMarketService.test.ts b/src/lib/__tests__/secondaryMarketService.test.ts index 121052d5..d42aadbd 100644 --- a/src/lib/__tests__/secondaryMarketService.test.ts +++ b/src/lib/__tests__/secondaryMarketService.test.ts @@ -63,9 +63,7 @@ describe('secondaryMarketService', () => { it('filters by blockchain', async () => { const ethereumListings = await secondaryMarketService.getListings({ blockchain: 'ethereum' }); - for (const listing of ethereumListings) { - expect(listing.blockchain).toBe('ethereum'); - } + expect(ethereumListings.map((listing) => listing.id)).toEqual(['sec-1']); }); it('filters by property name (propertyId)', async () => { @@ -74,17 +72,19 @@ describe('secondaryMarketService', () => { expect(filtered[0].propertyId).toBe('prop-1'); }); - it('filters by price range via propertyId and blockchain combined', async () => { - const allListings = await secondaryMarketService.getListings(); - const allIds = allListings.map((l) => l.id); + it('applies propertyId and blockchain filters together', async () => { + const filtered = await secondaryMarketService.getListings({ + propertyId: 'prop-1', + blockchain: 'ethereum', + }); - const ethereumOnly = await secondaryMarketService.getListings({ blockchain: 'ethereum' }); - const ethereumIds = ethereumOnly.map((l) => l.id); + expect(filtered.map((listing) => listing.id)).toEqual(['sec-1']); + }); - expect(ethereumIds.length).toBeLessThanOrEqual(allIds.length); - for (const id of ethereumIds) { - expect(allIds).toContain(id); - } + it('returns no listings when a filter matches nothing', async () => { + await expect( + secondaryMarketService.getListings({ propertyId: 'missing-property' }) + ).resolves.toEqual([]); }); }); }); diff --git a/src/lib/portfolioService.ts b/src/lib/portfolioService.ts index 6030f871..1534bc74 100644 --- a/src/lib/portfolioService.ts +++ b/src/lib/portfolioService.ts @@ -87,7 +87,7 @@ export class PortfolioService { const totalValueNative = new Map(); // Fetch data for each supported chain - for (const chainId of Object.keys(CHAIN_CONFIG) as unknown as ChainId[]) { + for (const chainId of Object.keys(CHAIN_CONFIG).map(Number) as ChainId[]) { const holdings = MOCK_TOKEN_HOLDINGS[chainId] || []; const gasBalance = MOCK_GAS_BALANCES[chainId] || { balance: '0', balanceUSD: 0 };