diff --git a/apps/web/bunfig.toml b/apps/web/bunfig.toml index 062f5ad..2371856 100644 --- a/apps/web/bunfig.toml +++ b/apps/web/bunfig.toml @@ -1,5 +1,4 @@ [test] -coverageThreshold = 0.65 -coverageReporter = ["text", "lcov"] -coverageSkipTestFiles = true -coveragePathIgnorePatterns = ["register-dom.ts", "setup-tests.ts", "test/**", "src/shared/lib/format.ts"] +# Register the happy-dom global environment before any test file runs so +# React component tests have a DOM to render into. +preload = ["./test/happydom.ts"] diff --git a/apps/web/package.json b/apps/web/package.json index 7270960..d9fe7da 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "test:coverage": "bun test --preload ./setup-tests.ts src/shared/lib src/features/trade/lib src/features/pools/lib --coverage", "build": "vite build", "preview": "vite preview", + "test": "bun test", "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit" @@ -36,13 +37,9 @@ "zustand": "^5.0.4" }, "devDependencies": { - "@happy-dom/global-registrator": "^20.10.6", - "@pythnetwork/hermes-client": "^3.1.0", - "@repo/vitest-config": "workspace:*", + "@happy-dom/global-registrator": "^20.11.1", "@tanstack/eslint-config": "^0.3.0", - "@tanstack/react-query-devtools": "^5.101.1", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", + "@tanstack/react-query-devtools": "^5.100.9", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.1.0", diff --git a/apps/web/src/app/config/env.test.ts b/apps/web/src/app/config/env.test.ts new file mode 100644 index 0000000..2840f2e --- /dev/null +++ b/apps/web/src/app/config/env.test.ts @@ -0,0 +1,60 @@ +/** + * apps/web/src/app/config/env.test.ts (issue #243) + * + * Proves startup validation fails loudly when a required VITE_ var is missing. + * + * Each scenario loads env.ts in its own fresh Bun subprocess (see + * test/config-harness.ts). That guarantees: + * - module imports are isolated per test (fresh module graph every time), + * - env vars set for one scenario never poison another test file, and + * - the module-load throw is captured exactly instead of aborting the suite. + */ + +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { VALID_ENV, loadEnv } from "../../../test/config-harness" + +/** A full valid env with a single required key removed. */ +function envWithout(key: keyof typeof VALID_ENV): Record { + const env: Record = { ...VALID_ENV } + delete env[key] + return env +} + +describe("ENV startup validation (#243)", () => { + it("throws the exact error when VITE_NETWORK is missing", () => { + const result = loadEnv(envWithout("VITE_NETWORK")) + assert.equal(result.ok, false) + assert.equal(result.message, "Missing env var: VITE_NETWORK") + }) + + it("throws the exact error when VITE_RPC_URL is missing", () => { + const result = loadEnv(envWithout("VITE_RPC_URL")) + assert.equal(result.ok, false) + assert.equal(result.message, "Missing env var: VITE_RPC_URL") + }) + + it("throws the exact error when VITE_HORIZON_URL is missing", () => { + const result = loadEnv(envWithout("VITE_HORIZON_URL")) + assert.equal(result.ok, false) + assert.equal(result.message, "Missing env var: VITE_HORIZON_URL") + }) + + it("throws the exact error when a required contract ID is missing", () => { + const result = loadEnv(envWithout("VITE_CONTRACT_DATA_STORE")) + assert.equal(result.ok, false) + assert.equal(result.message, "Missing env var: VITE_CONTRACT_DATA_STORE") + }) + + it("loads successfully and derives values when every required var is present", () => { + const result = loadEnv(VALID_ENV) + assert.equal(result.ok, true) + assert.ok(result.env) + assert.equal(result.env.NETWORK, "testnet") + assert.equal(result.env.RPC_URL, VALID_ENV.VITE_RPC_URL) + assert.equal(result.env.HORIZON_URL, VALID_ENV.VITE_HORIZON_URL) + // ORACLE_URL is optional and falls back when unset. + assert.equal(result.env.ORACLE_URL, "https://arbitrum-api.gmxinfra.io") + }) +}) diff --git a/apps/web/src/app/config/network.test.ts b/apps/web/src/app/config/network.test.ts new file mode 100644 index 0000000..b0ab6d1 --- /dev/null +++ b/apps/web/src/app/config/network.test.ts @@ -0,0 +1,58 @@ +/** + * apps/web/src/app/config/network.test.ts (issue #244) + * + * Verifies network.ts derives the active config from ENV. + * + * Each variant loads network.ts in its own fresh Bun subprocess + * (see test/config-harness.ts) so the module cache — including the transitive + * env.ts — is reset between the testnet and mainnet variants. + */ + +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { Networks } from "@stellar/stellar-sdk" + +import { VALID_ENV, loadNetwork } from "../../../test/config-harness" + +describe("NETWORK derivation (#244)", () => { + it("derives the testnet config from env", () => { + const net = loadNetwork({ + ...VALID_ENV, + VITE_NETWORK: "testnet", + VITE_RPC_URL: "https://rpc.testnet.example", + VITE_HORIZON_URL: "https://horizon.testnet.example", + }) + + assert.equal(net.name, "testnet") + assert.equal(net.rpcUrl, "https://rpc.testnet.example") // from env + assert.equal(net.horizonUrl, "https://horizon.testnet.example") // from env + assert.equal(net.networkPassphrase, Networks.TESTNET) + assert.equal(net.explorerBaseUrl, "https://stellar.expert/explorer/testnet") + assert.equal(net.txUrl, "https://stellar.expert/explorer/testnet/tx/TXHASH") + assert.equal( + net.accountUrl, + "https://stellar.expert/explorer/testnet/account/GACCOUNT" + ) + }) + + it("derives the mainnet config from env", () => { + const net = loadNetwork({ + ...VALID_ENV, + VITE_NETWORK: "mainnet", + VITE_RPC_URL: "https://rpc.mainnet.example", + VITE_HORIZON_URL: "https://horizon.mainnet.example", + }) + + assert.equal(net.name, "mainnet") + assert.equal(net.rpcUrl, "https://rpc.mainnet.example") // from env + assert.equal(net.horizonUrl, "https://horizon.mainnet.example") // from env + assert.equal(net.networkPassphrase, Networks.PUBLIC) + assert.equal(net.explorerBaseUrl, "https://stellar.expert/explorer/public") + assert.equal(net.txUrl, "https://stellar.expert/explorer/public/tx/TXHASH") + assert.equal( + net.accountUrl, + "https://stellar.expert/explorer/public/account/GACCOUNT" + ) + }) +}) diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx new file mode 100644 index 0000000..14c5baf --- /dev/null +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx @@ -0,0 +1,182 @@ +/** + * ConfirmationDialog.test.tsx (issue #227) + * + * Verifies the confirmation dialog renders the order summary and wires the + * Confirm / Cancel callbacks. Uses semantic assertions (label→value lookups, + * roles, accessible names) — no snapshots. + * + * The dialog transitively imports the Soroban transaction layer (which loads + * config/env.ts and would throw without a full env, and must never fire a real + * transaction). Those modules are replaced with mocks; the pure formatting and + * pricing helpers are kept real so the assertions exercise production output. + * The heavy Base UI is stubbed with pass-through elements for speed. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { cleanup, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { formatUsd } from "../../lib/trade-math" + +// ── Wallet: a connected account so the confirm path runs the order flow ────── +let walletAddress: string | null = + "GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +mock.module("@/features/wallet/store/wallet-store", () => ({ + useWalletStore: (selector: (state: { address: string | null }) => T): T => + selector({ address: walletAddress }), +})) + +// ── Fees: fixed, deterministic ─────────────────────────────────────────────── +const TOTAL_FEES_USD = 12.34 +mock.module("../../hooks/useTradeFees", () => ({ + useTradeFees: () => ({ + positionFeeUsd: 0, + priceImpactUsd: 0, + executionFeeUsd: 0, + totalFeesUsd: TOTAL_FEES_USD, + feesBreakdown: [], + }), +})) + +// ── Transaction layer: mocked so no real transaction is ever submitted ─────── +const createSwapOrder = mock(() => Promise.resolve({})) +const sendBatchOrderTxn = mock(() => Promise.resolve({})) +mock.module("../../lib/stellar", () => ({ createSwapOrder, sendBatchOrderTxn })) + +mock.module("@/lib/soroban/simulate", () => ({ + estimateFee: mock(() => Promise.resolve({ total: "0.5" })), +})) +mock.module("@/lib/contracts/exchange-router-client", () => ({ + buildCreateOrderTransaction: mock(() => Promise.resolve({})), + buildBatchOrderTransaction: mock(() => Promise.resolve({})), +})) +mock.module("../../lib/order-encoding", () => ({ + toCreateOrderParams: (o: unknown) => o, + toDecreaseOrderParams: (o: unknown) => o, +})) + +// ── Base UI dialog: lightweight stand-ins (render children when open) ───────── +mock.module("@workspace/ui/components/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})) + +const { ConfirmationDialog } = await import("./ConfirmationDialog") + +// ── Fixed order fixture: a 10x Long on BTC, collateral in USDC ─────────────── +const SIZE_USD = 5000 +const ENTRY_PRICE = 50_000 +const LIQUIDATION_PRICE = 45_000 + +function makeTradeState() { + return { + tradeType: "Long", + tradeMode: "Market", + fromTokenAddress: "USDC", + toTokenAddress: "BTC", + marketAddress: "BTC-BTC-USDC", + collateralAddress: "USDC", + fromAmount: "1000", + triggerPrice: "", + leverage: 10, + sidecarOrders: [], + clearSidecarOrders: mock(() => {}), + tradeFlags: { + isLong: true, + isShort: false, + isSwap: false, + isPosition: true, + isMarket: true, + isLimit: false, + isTrigger: false, + }, + } +} + +type DialogProps = React.ComponentProps + +function renderDialog(overrides: Partial = {}) { + const onClose = mock(() => {}) + render( + + ) + return { onClose } +} + +/** Read the value rendered next to a summary row label. */ +function rowValue(label: string): string { + const labelEl = screen.getByText(label) + return labelEl.nextElementSibling?.textContent ?? "" +} + +beforeEach(() => { + walletAddress = "GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + createSwapOrder.mockClear() + sendBatchOrderTxn.mockClear() +}) + +afterEach(cleanup) + +describe("ConfirmationDialog (#227)", () => { + it("renders the order summary from the fixture", async () => { + renderDialog() + + // Fee estimation effect settles (uses mocked estimateFee). + await screen.findByText("~0.5 XLM") + + const heading = screen.getByRole("heading") + // market + side are shown in the title. + expect(heading.textContent).toContain("BTC") // market + expect(heading.textContent).toContain("Long") // side + + expect(rowValue("Size")).toBe(formatUsd(SIZE_USD)) // size + expect(rowValue("Collateral")).toBe("1000 USDC") // collateral + expect(rowValue("Total fees")).toBe(formatUsd(TOTAL_FEES_USD)) // estimated fees + expect(rowValue("Entry price")).toBe(formatUsd(ENTRY_PRICE)) // price + }) + + it("runs the order flow and closes when Confirm is clicked", async () => { + const user = userEvent.setup() + const { onClose } = renderDialog() + await screen.findByText("~0.5 XLM") + + await user.click(screen.getByRole("button", { name: /Confirm Long/i })) + + await waitFor(() => expect(sendBatchOrderTxn).toHaveBeenCalledTimes(1)) + expect(onClose).toHaveBeenCalledTimes(1) + expect(createSwapOrder).not.toHaveBeenCalled() + }) + + it("closes without submitting when Cancel is clicked", async () => { + const user = userEvent.setup() + const { onClose } = renderDialog() + await screen.findByText("~0.5 XLM") + + await user.click(screen.getByRole("button", { name: "Cancel" })) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(sendBatchOrderTxn).not.toHaveBeenCalled() + expect(createSwapOrder).not.toHaveBeenCalled() + }) +}) 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 537ed31..e7f6688 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,231 +1,165 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +/** + * TradePanel.test.tsx (issue #226) + * + * Covers trade-input validation: invalid, zero, and excessive amounts each + * disable the submit button (and surface a validation message), so a real + * transaction is never initiated. Interactions use @testing-library/user-event. + * + * The real useTradeState hook is kept (so typing genuinely drives state and the + * submit button's disabled logic); its data sources, the price/fee/balance + * hooks, and the heavy child components are mocked for determinism. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { cleanup, render, screen } from "@testing-library/react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import type { TradePanel as TradePanelComponent } from "./TradePanel" -import { useWalletStore } from "@/features/wallet/store/wallet-store" - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - return function Wrapper({ children }: { children: React.ReactNode }) { - return {children} - } -} - -vi.mock("@/lib/contracts", () => ({ - // ── Re-exported clients (from @workspace/contracts) ──────────────────── - ExchangeRouterClient: class {}, - GlvRouterClient: class {}, - OrderVaultClient: class {}, - ReferralStorageClient: class {}, - SacTokenClient: class {}, - StakingRouterClient: class {}, - SyntheticsReaderClient: class {}, - TokenClient: class {}, - VestingRouterClient: class {}, - // ── Instances created by contracts.ts ────────────────────────────────── - exchangeRouterClient: {}, - syntheticsReaderClient: {}, - referralStorageClient: {}, - orderVaultClient: {}, - sacTokenClient: {}, - stakingRouterClient: { getStakerInfo: vi.fn() }, - // ── Referral helpers ────────────────────────────────────────────────── - referralPromptStorageKey: (a: string) => `referral-prompt:${a}`, - affiliateCodeStorageKey: (a: string) => `affiliate-code:${a}`, - getTraderReferralCode: vi.fn(() => ""), - readStoredReferralCode: 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(), - // ── Error helpers ───────────────────────────────────────────────────── - mapContractError: vi.fn(), - parseSorobanError: vi.fn((e: unknown) => String(e)), - mapReferralContractError: vi.fn(), - // ── Misc ────────────────────────────────────────────────────────────── - checkAllowance: vi.fn(), - getTokenClient: vi.fn(), - getGlvRouterClient: vi.fn(), - getStakingRouterClient: vi.fn(), - getVestingRouterClient: 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", +import userEvent from "@testing-library/user-event" + +// ── Data sources behind the real useTradeState ─────────────────────────────── +mock.module("../../hooks/useMarkets", () => ({ + useMarkets: () => ({ + markets: [], + getMarket: () => undefined, + getMarketsForIndexToken: () => [], + }), +})) +mock.module("../../hooks/useTokenList", () => ({ + useTokenList: () => ({ + tokens: [], + indexTokens: [], + stableTokens: [], + getToken: () => undefined, + }), })) -vi.mock("../../hooks/useTokenPrices", () => ({ +// ── Price / fee hooks ──────────────────────────────────────────────────────── +mock.module("../../hooks/useTokenPrices", () => ({ useTokenPrices: () => ({ + prices: {}, + isLoading: false, + error: null, + getPrice: () => undefined, getMidPrice: () => 1, - isStale: () => false, - getPrice: () => ({ minPrice: 1, maxPrice: 1 }), - getStaleness: () => "fresh" as const, }), })) - -vi.mock("../../hooks/useTradeFees", () => ({ +mock.module("../../hooks/useTradeFees", () => ({ useTradeFees: () => ({ positionFeeUsd: 0, priceImpactUsd: 0, executionFeeUsd: 0, - executionFeeXlm: 0, totalFeesUsd: 0, feesBreakdown: [], }), })) -const mockBalances: { data: Record | undefined } = { data: undefined } - -vi.mock("../../../wallet/hooks/useTokenBalances", () => ({ - useTokenBalances: () => ({ - data: mockBalances.data, - isLoading: false, - }), +// ── Wallet balances: USDC balance of 500 (the default collateral token) ────── +const WALLET_BALANCE = 500 +mock.module("../../../wallet/hooks/useTokenBalances", () => ({ + useTokenBalances: () => ({ data: { USDC: WALLET_BALANCE } }), })) -function createMockTrade(overrides: Record = {}) { - return { - tradeType: "Long" as const, - tradeMode: "Market" as const, - tradeFlags: { - isLong: true, - isShort: false, - isSwap: false, - isPosition: true, - isMarket: true, - isLimit: false, - isTrigger: false, - }, - fromAmount: "", - leverage: 10, - fromTokenAddress: "TUSDC", - toTokenAddress: "TWBTC", - marketAddress: "0xmarket", - collateralAddress: "TUSDC", - availableTradeModes: ["Market", "Limit", "Trigger"], - advanced: { advancedDisplay: false, slippagePct: 0.3 }, - setTradeType: vi.fn(), - setTradeMode: vi.fn(), - setLeverage: vi.fn(), - setTriggerPrice: vi.fn(), - setFromAmount: vi.fn(), - switchTokens: vi.fn(), - setAdvanced: vi.fn(), - setSlippagePct: vi.fn(), - sidecarOrders: [], - addSidecarOrder: vi.fn(), - removeSidecarOrder: vi.fn(), - clearSidecarOrders: vi.fn(), - setActivePosition: vi.fn(), - setFromTokenAddress: vi.fn(), - setToTokenAddress: vi.fn(), - setMarketAddress: vi.fn(), - setCollateralAddress: vi.fn(), - setToAmount: vi.fn(), - ...overrides, - } -} - -let TradePanel: TradePanelComponent +// ── Heavy children — irrelevant to input validation, and the dialog must never +// submit a real transaction, so both are stubbed out. ────────────────────── +mock.module("./TradeInfoRows", () => ({ TradeInfoRows: () => null })) +mock.module("./ConfirmationDialog", () => ({ ConfirmationDialog: () => null })) + +// ── Base UI wrappers — stubbed with pass-throughs (fast, and Base UI's Tabs / +// Slider are prohibitively slow under happy-dom). ────────────────────────── +mock.module("@workspace/ui/components/tabs", () => ({ + Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
, + TabsList: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TabsTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TabsContent: ({ + value, + children, + }: { + value: string + children: React.ReactNode + }) => + // Render only the default (Long) tab's content so a single input exists. + value === "Long" ?
{children}
: null, +})) +mock.module("@workspace/ui/components/slider", () => ({ + Slider: () => null, +})) +mock.module("@workspace/ui/components/separator", () => ({ + Separator: () =>
, +})) +mock.module("@workspace/ui/components/badge", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})) -describe("TradePanel", () => { - beforeAll(async () => { - TradePanel = (await import("./TradePanel")).TradePanel - }) +const { TradePanel } = await import("./TradePanel") - beforeEach(() => { - useWalletStore.setState({ - address: null, - walletId: null, - status: "disconnected", - network: "testnet", - pendingTransactionXdr: null, - }) - }) +/** The pay/collateral amount input. */ +function amountInput() { + return screen.getByPlaceholderText("0.00") +} - afterEach(() => { - cleanup() - }) +/** The submit button (named e.g. "Long BTC", distinct from the "Long" tab). */ +function submitButton() { + return screen.getByRole("button", { name: /Long\s+BTC/i }) +} - it("renders side selector with Long, Short, and Swap tabs", () => { - render(, { wrapper: createWrapper() }) +/** Whether the submit button is currently disabled. */ +function submitDisabled(): boolean { + return submitButton().hasAttribute("disabled") +} - expect(screen.getByRole("tab", { name: "Long" })).toBeInTheDocument() - expect(screen.getByRole("tab", { name: "Short" })).toBeInTheDocument() - expect(screen.getByRole("tab", { name: "Swap" })).toBeInTheDocument() - }) +beforeEach(() => { + localStorage.clear() // ensure the default trade state (Long, empty amount) +}) - it("renders order mode tabs (Market, Limit, Trigger)", () => { - render(, { wrapper: createWrapper() }) +afterEach(cleanup) - expect(screen.getByRole("tab", { name: "Market" })).toBeInTheDocument() - expect(screen.getByRole("tab", { name: "Limit" })).toBeInTheDocument() - expect(screen.getByRole("tab", { name: "Trigger" })).toBeInTheDocument() +describe("TradePanel input validation (#226)", () => { + it("disables submit while no amount is entered", () => { + render() + expect(submitDisabled()).toBe(true) }) - it("renders collateral input for position trades", () => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByText("Collateral")).toBeInTheDocument() - expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument() - }) + it("rejects an invalid (negative) amount", async () => { + const user = userEvent.setup() + render() - it("renders leverage slider with current value", () => { - render(, { wrapper: createWrapper() }) + await user.type(amountInput(), "-5") - expect(screen.getByText("Leverage")).toBeInTheDocument() - expect(screen.getByText("10×")).toBeInTheDocument() + expect(screen.getByRole("alert").textContent).toBe("Enter a valid amount") + expect(submitDisabled()).toBe(true) }) - it("renders submit button with trade type and token label", () => { - render(, { wrapper: createWrapper() }) + it("rejects a zero amount", async () => { + const user = userEvent.setup() + render() - const button = screen.getByRole("button", { name: /Long.*TWBTC/i }) - expect(button).toBeInTheDocument() - }) - - it("disables submit button when wallet is not connected", () => { - render(, { wrapper: createWrapper() }) + await user.type(amountInput(), "0") - const button = screen.getByRole("button", { name: /Long/i }) - expect(button).toBeDisabled() + expect(screen.getByRole("alert").textContent).toBe("Enter a valid amount") + expect(submitDisabled()).toBe(true) }) - it("enables submit button when wallet connected with sufficient balance and amount entered", () => { - useWalletStore.setState({ - address: "GABCDEF123456789", - status: "connected", - }) - mockBalances.data = { TUSDC: 10000, XLM: 100 } + it("rejects an excessive amount above the wallet balance", async () => { + const user = userEvent.setup() + render() - render(, { wrapper: createWrapper() }) + await user.type(amountInput(), String(WALLET_BALANCE + 1000)) - const button = screen.getByRole("button", { name: /Long/i }) - expect(button).toBeEnabled() + expect(screen.getByRole("alert").textContent).toBe("Insufficient balance") + expect(submitDisabled()).toBe(true) }) - it("shows Market label instead of Receive for position trades", () => { - render(, { wrapper: createWrapper() }) + it("enables submit for a valid amount within balance", async () => { + const user = userEvent.setup() + render() + + await user.type(amountInput(), "100") - const marketLabels = screen.getAllByText("Market") - expect(marketLabels.length).toBeGreaterThanOrEqual(2) + expect(screen.queryByRole("alert")).toBeNull() + expect(submitDisabled()).toBe(false) }) }) diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx index 4f5b644..5358ced 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx @@ -1,5 +1,11 @@ import { useMemo, useState } from "react" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs" +import { Input } from "@workspace/ui/components/input" import { Button } from "@workspace/ui/components/button" import { Input } from "@workspace/ui/components/input" import { Separator } from "@workspace/ui/components/separator" @@ -7,6 +13,8 @@ import { Numeric } from "@workspace/ui/components/numeric" import { useTokenPrices } from "../../hooks/useTokenPrices" import { useTradeFees } from "../../hooks/useTradeFees" import { useTokenBalances } from "../../../wallet/hooks/useTokenBalances" +// NB: useTokenBalances is also consumed here (not only in TradeInputs) to +// validate the entered amount against the wallet balance before submit. import { estimateLiquidationPrice, sizeFromCollateralAndLeverage, @@ -36,37 +44,38 @@ export function TradePanel({ trade }: TradePanelProps) { const account = useWalletStore((state) => state.address) const { - tradeType, tradeMode, tradeFlags, - fromAmount, leverage, - fromTokenAddress, toTokenAddress, marketAddress, collateralAddress, + tradeType, + tradeMode, + tradeFlags, + fromAmount, + leverage, + fromTokenAddress, + toTokenAddress, + marketAddress, + collateralAddress, availableTradeModes, - setTradeType, setTradeMode, + setTradeType, + setTradeMode, setLeverage, setTriggerPrice, advanced, } = trade - const debouncedFromAmount = useDebounce(fromAmount, 300) + const { data: balances } = useTokenBalances() + const entryPrice = getMidPrice(toTokenAddress) - const collateralUsd = parseFloat(debouncedFromAmount || "0") * getMidPrice(collateralAddress!) - const sizeUsd = tradeFlags.isSwap ? collateralUsd : sizeFromCollateralAndLeverage(collateralUsd, leverage) - const activeInputTokenAddress = tradeFlags.isSwap ? fromTokenAddress : collateralAddress! + const collateralUsd = + parseFloat(fromAmount || "0") * getMidPrice(collateralAddress) + const sizeUsd = tradeFlags.isSwap + ? collateralUsd + : sizeFromCollateralAndLeverage(collateralUsd, leverage) - const fees = useTradeFees({ sizeUsd, marketAddress, isIncrease: true, tradeType }) - const walletBalance = balances?.[activeInputTokenAddress] - const xlmBalance = balances?.["XLM"] ?? 0 - const collateralAmount = Number(fromAmount || "0") - const fromTokenLabel = formatTokenLabel(activeInputTokenAddress) - const toTokenLabel = formatTokenLabel(toTokenAddress) - const hasCollateralError = walletBalance !== undefined && collateralAmount > walletBalance - const hasXlmError = xlmBalance < fees.executionFeeXlm - const validationError = hasCollateralError - ? `Insufficient ${fromTokenLabel} balance` - : hasXlmError - ? `Insufficient XLM balance for execution fees (requires ~${fees.executionFeeXlm.toFixed(2)} XLM)` - : undefined - const priceStale = isStale(toTokenAddress) - const canTrade = collateralAmount > 0 && !validationError && !priceStale + const fees = useTradeFees({ + sizeUsd, + marketAddress, + isIncrease: true, + tradeType, + }) const liquidationPrice = useMemo(() => { if (!tradeFlags.isPosition || sizeUsd <= 0 || entryPrice <= 0) return 0 @@ -81,12 +90,21 @@ export function TradePanel({ trade }: TradePanelProps) { return (
{/* ── Trade type tabs: Long / Short / Swap ───────────────────── */} - setTradeType(v as TradeType)}> + setTradeType(v as TradeType)} + > - + Long - + Short @@ -129,7 +147,7 @@ export function TradePanel({ trade }: TradePanelProps) { className="pr-12 font-mono text-sm" onChange={(e) => setTriggerPrice(e.target.value)} /> - + USD
@@ -215,9 +233,9 @@ export function TradePanel({ trade }: TradePanelProps) {