diff --git a/apps/web/package.json b/apps/web/package.json index 48f7723..fa177bd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "vite dev --port 3000", - "test": "bun test --preload ./setup-tests.ts", + "test": "bun test --preload ./setup-tests.ts src/shared src/features/wallet/store src/features/pools src/features/trade", "build": "vite build", "preview": "vite preview", "lint": "eslint", diff --git a/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx b/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx new file mode 100644 index 0000000..c1896cf --- /dev/null +++ b/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx @@ -0,0 +1,301 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + getComposition, + getEstimatedApy, + getFundingRatePerHourPct, + getOpenInterestUsd, + getPoolTvlUsd, + rawToDisplay, + usdRawToDisplay, +} from "../lib/pool-math" +import type { FundingInfo, MarketProps, PoolValueInfo } from "@/lib/contracts" +import type { PoolMarketConfig } from "../data/markets" +import type { usePoolRowData as UsePoolRowDataType } from "./use-pool-row-data" +import { useWalletStore } from "@/features/wallet/store/wallet-store" + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const MARKET_TOKEN = "CDLZFC3SYJYDZT7K3VZWPU7JYQRDARVII7CJIU2M676KBN2JRVFFE25H" +const marketConfig: PoolMarketConfig = { + label: "TWBTC/TUSDC", + displayName: "BTC/USD", + marketToken: MARKET_TOKEN, + indexToken: "CB7T6QRC5B6Y7TQJ5Y6Q7R5C6B7T6QRC5B6Y7TQJ5Y", + longToken: "CB7T6QRC5B6Y7TQJ5Y6Q7R5C6B7T6QRC5B6Y7TQJ5Y", + shortToken: "CDLZFC3SYJYDZT7K3VZWPU7JYQRDARVII7CJIU2M676KBN2JRVFFE25H", + longSymbol: "TWBTC", + shortSymbol: "TUSDC", + decimals: 7, +} + +const marketProps: MarketProps = { + marketToken: MARKET_TOKEN, + indexToken: marketConfig.indexToken, + longToken: marketConfig.longToken, + shortToken: marketConfig.shortToken, +} + +const poolValueInfo: PoolValueInfo = { + poolValue: 500_000n * 10n ** 30n, + longTokenAmount: 10_000_000_000n, + shortTokenAmount: 500_000_000_000_000_000n, + longTokenUsd: 250_000n * 10n ** 30n, + shortTokenUsd: 250_000n * 10n ** 30n, + longPnl: 0n, + shortPnl: 0n, + netPnl: 0n, + totalBorrowingFees: 0n, + impactPoolAmount: 0n, +} + +const openInterest = { long: 1_000_000n * 10n ** 30n, short: 500_000n * 10n ** 30n } + +const fundingInfo: FundingInfo = { + fundingFactorPerSecond: 1_000_000_000_000_000_000_00n, + longFundingAmountPerSize: 0n, + shortFundingAmountPerSize: 0n, +} + +const USER_GM_BALANCE = 1_000_000_000n +const TOTAL_SUPPLY = 5_000_000_000n + +// ── Mock: @/lib/contracts ──────────────────────────────────────────────────── + +const mockSyntheticsReader = { + getMarket: vi.fn(), + getMarketPoolValueInfo: vi.fn(), + getOpenInterest: vi.fn(), + getFundingInfo: vi.fn(), +} + +const mockTokenClient = { + balance: vi.fn(), + totalSupply: vi.fn(), +} + +vi.mock("@/lib/contracts", () => ({ + // ── Re-exported client classes ────────────────────────────────────────── + ExchangeRouterClient: class {}, + GlvRouterClient: class {}, + OrderVaultClient: class {}, + ReferralStorageClient: class {}, + SacTokenClient: class {}, + StakingRouterClient: class {}, + SyntheticsReaderClient: class {}, + TokenClient: class {}, + VestingRouterClient: class {}, + // ── Instances created by contracts.ts ──────────────────────────────────── + exchangeRouterClient: { buildCreateOrderTransaction: vi.fn(), buildBatchOrderTransaction: vi.fn(), buildCancelOrderTransaction: vi.fn() }, + syntheticsReaderClient: mockSyntheticsReader, + referralStorageClient: {}, + orderVaultClient: {}, + sacTokenClient: { checkAllowance: vi.fn(), buildApproveTransaction: vi.fn() }, + stakingRouterClient: { getStakerInfo: vi.fn() }, + // ── Contract tx builders ────────────────────────────────────────────── + buildBatchOrderTransaction: vi.fn(), + buildCreateOrderTransaction: vi.fn(), + buildCancelOrderTransaction: vi.fn(), + buildCreateDepositTransaction: vi.fn(), + buildCreateWithdrawalTransaction: vi.fn(), + buildClaimRebatesTransaction: vi.fn(), + buildRegisterCodeTransaction: vi.fn(), + buildSetTraderReferralCodeTransaction: vi.fn(), + buildClaimFundingFeesTransaction: vi.fn(), + buildStakeSO4Transaction: vi.fn(), + buildUnstakeSO4Transaction: vi.fn(), + buildClaimRewardsTransaction: vi.fn(), + buildCompoundTransaction: vi.fn(), + buildDepositForVestingTransaction: vi.fn(), + buildApproveTransaction: vi.fn(), + // ── Token helpers ────────────────────────────────────────────────────── + getTokenClient: () => mockTokenClient, + checkAllowance: vi.fn(), + // ── Error helpers ───────────────────────────────────────────────────── + mapContractError: vi.fn(), + parseSorobanError: vi.fn((e: unknown) => String(e)), + mapReferralContractError: vi.fn(), + // ── Referral helpers ────────────────────────────────────────────────── + referralPromptStorageKey: (a: string) => `referral-prompt:${a}`, + affiliateCodeStorageKey: (a: string) => `affiliate-code:${a}`, + getTraderReferralCode: vi.fn(() => ""), + readStoredReferralCode: vi.fn(() => ""), + getTraderDiscountBps: vi.fn(), + getReferralCodeStats: vi.fn(), + getTraderRebateInfo: vi.fn(), + getAffiliateCode: vi.fn(), + saveReferralCode: vi.fn(), + AFFILIATE_CODE_STORAGE_KEY: "so4-affiliate-code", + REFERRAL_PROMPT_STORAGE_KEY: "so4-referral-prompt-done", + REFERRAL_CODE_STORAGE_KEY: "so4-referral-code", + // ── Misc ────────────────────────────────────────────────────────────── + getGlvRouterClient: vi.fn(), + getStakingRouterClient: vi.fn(), + getVestingRouterClient: vi.fn(), +})) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +let usePoolRowData: UsePoolRowDataType + +describe("usePoolRowData", () => { + beforeAll(async () => { + usePoolRowData = (await import("./use-pool-row-data")).usePoolRowData + }) + + beforeEach(() => { + vi.clearAllMocks() + useWalletStore.setState({ + address: null, + walletId: null, + status: "disconnected", + network: "testnet", + pendingTransactionXdr: null, + }) + }) + + describe("hook data integrity", () => { + it("returns full data when all Soroban calls succeed", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockResolvedValue(poolValueInfo) + mockSyntheticsReader.getOpenInterest.mockResolvedValue(openInterest) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.balance.mockResolvedValue(USER_GM_BALANCE) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + useWalletStore.setState({ address: "GABCDEF123456789", status: "connected" }) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.market).toEqual(marketProps) + expect(data.poolValue).toEqual(poolValueInfo) + expect(data.openInterest).toEqual(openInterest) + expect(data.fundingInfo).toEqual(fundingInfo) + expect(data.userGmBalance).toBe(USER_GM_BALANCE) + expect(data.totalSupply).toBe(TOTAL_SUPPLY) + expect(data.failures).toEqual([]) + }) + + it("returns 0n userGmBalance when wallet is disconnected", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockResolvedValue(poolValueInfo) + mockSyntheticsReader.getOpenInterest.mockResolvedValue(openInterest) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.balance.mockResolvedValue(USER_GM_BALANCE) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.userGmBalance).toBe(0n) + expect(mockTokenClient.balance).not.toHaveBeenCalled() + expect(data.failures).toEqual([]) + }) + + it("reports failures when calls reject with fallback nulls", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockRejectedValue(new Error("RPC timeout")) + mockSyntheticsReader.getOpenInterest.mockRejectedValue(new Error("Not found")) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.market).toEqual(marketProps) + expect(data.poolValue).toBeNull() + expect(data.openInterest).toBeNull() + expect(data.fundingInfo).toEqual(fundingInfo) + expect(data.userGmBalance).toBe(0n) + expect(data.totalSupply).toBe(TOTAL_SUPPLY) + expect(data.failures).toContain("pool value") + expect(data.failures).toContain("open interest") + }) + }) + + describe("derived fields from fixtures", () => { + it("computes TVL (liquidity) as $500k from poolValue fixture", () => { + const tvl = getPoolTvlUsd(poolValueInfo) + expect(tvl).toBe(500_000) + }) + + it("computes estimated APY from TVL", () => { + const tvl = getPoolTvlUsd(poolValueInfo) + expect(tvl).toBeGreaterThan(0) + const apy = getEstimatedApy(poolValueInfo) + expect(apy).not.toBeNull() + expect(apy!).toBeGreaterThan(0) + expect(apy!).toBeLessThanOrEqual(18) + }) + + it("returns null APY when pool value is unavailable", () => { + expect(getEstimatedApy(null)).toBeNull() + expect(getEstimatedApy(undefined)).toBeNull() + }) + + it("computes open interest USD from long + short", () => { + const oi = getOpenInterestUsd(openInterest) + // long = 1_000_000_000_000_000_000_000_000_000_000n at USD_DECIMALS(30) => 1_000_000 + // short = 500_000_000_000_000_000_000_000_000_000n at USD_DECIMALS(30) => 500_000 + expect(oi).toBe(1_500_000) + }) + + it("returns 0 open interest when null", () => { + expect(getOpenInterestUsd(null)).toBe(0) + expect(getOpenInterestUsd(undefined)).toBe(0) + }) + + it("computes hourly funding rate as percentage", () => { + // fundingFactorPerSecond = 1_000_000_000_000_000_000_00n + // at USD_DECIMALS(30): 1e-10 per second + // * 3600 seconds = 3.6e-7 => * 100 = 3.6e-5 % + const rate = getFundingRatePerHourPct(fundingInfo.fundingFactorPerSecond) + expect(rate).toBeCloseTo(0.000036, 6) + }) + + it("returns 0 funding rate when factor is null", () => { + expect(getFundingRatePerHourPct(null)).toBe(0) + expect(getFundingRatePerHourPct(undefined)).toBe(0) + }) + + it("computes pool composition with equal long/short split", () => { + const comp = getComposition(poolValueInfo) + expect(comp.longPct).toBeCloseTo(50, 0) + expect(comp.shortPct).toBeCloseTo(50, 0) + expect(comp.source).toBe("usd") + }) + + it("falls back to 50/50 split when pool value is null", () => { + const comp = getComposition(null) + expect(comp.longPct).toBe(50) + expect(comp.shortPct).toBe(50) + expect(comp.source).toBe("empty") + }) + + it("exposes token symbols from market config", () => { + expect(marketConfig.longSymbol).toBe("TWBTC") + expect(marketConfig.shortSymbol).toBe("TUSDC") + expect(marketConfig.label).toBe("TWBTC/TUSDC") + expect(marketConfig.displayName).toBe("BTC/USD") + }) + }) +}) diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx index 57bef46..3119a16 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx @@ -1,8 +1,8 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" -import { render, screen } from "@testing-library/react" +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { useWalletStore } from "@/features/wallet/store/wallet-store" import type { TradePanel as TradePanelComponent } from "./TradePanel" +import { useWalletStore } from "@/features/wallet/store/wallet-store" function createWrapper() { const queryClient = new QueryClient({ @@ -161,6 +161,10 @@ describe("TradePanel", () => { }) }) + afterEach(() => { + cleanup() + }) + it("renders side selector with Long, Short, and Swap tabs", () => { render(, { wrapper: createWrapper() })