diff --git a/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx b/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx
new file mode 100644
index 0000000..5d0b6f2
--- /dev/null
+++ b/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { OracleStalenessIndicator } from "./OracleStalenessIndicator"
+
+describe("OracleStalenessIndicator", () => {
+ it("renders green dot for fresh staleness", () => {
+ const { container } = render()
+ const dot = container.querySelector(".bg-green-500")
+ expect(dot).toBeInTheDocument()
+ })
+
+ it("renders yellow dot for warning staleness", () => {
+ const { container } = render()
+ const dot = container.querySelector(".bg-yellow-500")
+ expect(dot).toBeInTheDocument()
+ })
+
+ it("renders red dot for stale staleness", () => {
+ const { container } = render()
+ const dot = container.querySelector(".bg-red-500")
+ expect(dot).toBeInTheDocument()
+ })
+
+ it("does not show label when showLabel is false", () => {
+ render()
+ expect(screen.queryByText("Stale")).not.toBeInTheDocument()
+ })
+
+ it("shows Stale label when showLabel is true and staleness is stale", () => {
+ render()
+ expect(screen.getByText("Stale")).toBeInTheDocument()
+ })
+
+ it("does not show Stale label when showLabel is true but staleness is fresh", () => {
+ render()
+ expect(screen.queryByText("Stale")).not.toBeInTheDocument()
+ })
+
+ it("does not show Stale label when showLabel is true but staleness is warning", () => {
+ render()
+ expect(screen.queryByText("Stale")).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx
new file mode 100644
index 0000000..663bd1e
--- /dev/null
+++ b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx
@@ -0,0 +1,223 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { cleanup, render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+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", () => ({
+ ExchangeRouterClient: class {},
+ SyntheticsReaderClient: class {},
+ exchangeRouterClient: {},
+ syntheticsReaderClient: {},
+ buildCreateOrderTransaction: vi.fn(),
+ buildCancelOrderTransaction: vi.fn(),
+}))
+
+vi.mock("@/features/trade/hooks/useTokenPrices", () => ({
+ useTokenPrices: () => ({
+ getMidPrice: () => 1,
+ isStale: () => false,
+ getPrice: () => ({ minPrice: 1, maxPrice: 1 }),
+ getStaleness: () => "fresh" as const,
+ }),
+}))
+
+const mockBalances: { data: Record | undefined } = { data: undefined }
+
+vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({
+ useTokenBalances: () => ({
+ data: mockBalances.data,
+ isLoading: false,
+ }),
+}))
+
+vi.mock("@/features/trade/lib/stellar", () => ({
+ cancelOrder: vi.fn(),
+ createIncreaseOrder: vi.fn(),
+ createDecreaseOrder: vi.fn(),
+ claimFundingFees: vi.fn(),
+}))
+
+function createMockPosition(overrides: Record = {}) {
+ return {
+ key: "pos-1",
+ account: "GABCDEF123456789",
+ marketAddress: "0xbtc",
+ marketName: "BTC/USD",
+ indexToken: "WBTC",
+ collateralToken: "USDC",
+ collateralAmount: 1,
+ collateralUsd: 100000,
+ sizeUsd: 50000,
+ sizeInUsdRaw: 50000000000n,
+ entryPrice: 50000,
+ markPrice: 51000,
+ liquidationPrice: 45000,
+ leverage: 10,
+ pnl: 1000,
+ pnlPercent: 10,
+ isLong: true,
+ pnlAfterFees: 950,
+ fundingFeeUsd: 50,
+ ...overrides,
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let CollateralDialog: typeof import("./CollateralDialog").CollateralDialog
+
+describe("CollateralDialog", () => {
+ beforeEach(async () => {
+ CollateralDialog = (await import("./CollateralDialog")).CollateralDialog
+ useWalletStore.setState({
+ address: "GABCDEF123456789",
+ walletId: null,
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+ mockBalances.data = { USDC: 10000, XLM: 100 }
+ })
+
+ afterEach(() => {
+ cleanup()
+ document.body.innerHTML = ""
+ })
+
+ it("renders nothing when position is null", () => {
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders nothing when mode is null", () => {
+ const position = createMockPosition()
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders add collateral dialog with correct title", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getByRole("heading", { name: /Add Collateral.*BTC/ })).toBeInTheDocument()
+ })
+
+ it("renders remove collateral dialog with correct title", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getByRole("heading", { name: /Remove Collateral.*BTC/ })).toBeInTheDocument()
+ })
+
+ it("shows wallet balance in add mode", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getAllByText(/Wallet Balance/).length).toBeGreaterThanOrEqual(1)
+ expect(screen.getByText("10,000 USDC")).toBeInTheDocument()
+ })
+
+ it("shows Use Max button in add mode", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getByText("Use Max")).toBeInTheDocument()
+ })
+
+ it("does not show Use Max button in remove mode", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.queryByText("Use Max")).not.toBeInTheDocument()
+ })
+
+ it("validates insufficient wallet balance in add mode", async () => {
+ mockBalances.data = { USDC: 5, XLM: 100 }
+ const position = createMockPosition()
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ const inputs = screen.getAllByPlaceholderText("0.00")
+ const input = inputs[inputs.length - 1]
+ await user.type(input, "10")
+ expect(screen.getByText("Insufficient wallet balance")).toBeInTheDocument()
+ })
+
+ it("validates cannot remove all collateral in remove mode", async () => {
+ const position = createMockPosition({ collateralAmount: 1 })
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ const inputs = screen.getAllByPlaceholderText("0.00")
+ const input = inputs[inputs.length - 1]
+ await user.type(input, "1")
+ expect(screen.getByText(/Cannot remove all collateral/)).toBeInTheDocument()
+ })
+
+ it("disables confirm button when amount is empty", () => {
+ const position = createMockPosition()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ const confirmButton = screen.getByRole("button", { name: /Confirm Add Collateral/ })
+ expect(confirmButton).toBeDisabled()
+ })
+
+ it("enables confirm button with valid amount in add mode", async () => {
+ mockBalances.data = { USDC: 10000, XLM: 100 }
+ const position = createMockPosition()
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ const inputs = screen.getAllByPlaceholderText("0.00")
+ const input = inputs[inputs.length - 1]
+ await user.type(input, "100")
+ const confirmButton = screen.getByRole("button", { name: /Confirm Add Collateral/ })
+ expect(confirmButton).toBeEnabled()
+ })
+
+ it("calls onClose when cancel is clicked", async () => {
+ const position = createMockPosition()
+ const onClose = vi.fn()
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ const cancelButton = screen.getByRole("button", { name: "Cancel" })
+ await user.click(cancelButton)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/web/src/features/trade/components/positions/OrdersList.test.tsx b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx
new file mode 100644
index 0000000..b035c58
--- /dev/null
+++ b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx
@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { cleanup, render, screen } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children}
+ }
+}
+
+let mockData: Array> = []
+let mockIsLoading = false
+let mockIsDisabled = false
+
+vi.mock("@/features/trade/hooks/useOrdersWithIndexer", () => ({
+ useOrdersWithIndexer: () => ({
+ data: mockData,
+ isLoading: mockIsLoading,
+ isDisabled: mockIsDisabled,
+ }),
+}))
+
+vi.mock("@/lib/contracts", () => ({
+ ExchangeRouterClient: class {},
+ SyntheticsReaderClient: class {},
+ exchangeRouterClient: {},
+ syntheticsReaderClient: {},
+ buildCancelOrderTransaction: vi.fn(),
+}))
+
+vi.mock("@/features/trade/lib/stellar", () => ({
+ cancelOrder: vi.fn(),
+ createIncreaseOrder: vi.fn(),
+ createDecreaseOrder: vi.fn(),
+ claimFundingFees: vi.fn(),
+}))
+
+function createMockOrder(overrides: Record = {}) {
+ return {
+ key: "order-1",
+ account: "GABCDEF123456789",
+ marketAddress: "0xbtc",
+ marketName: "BTC/USD",
+ orderType: "MarketIncrease",
+ status: "active",
+ isLong: true,
+ sizeUsd: 50000,
+ triggerPrice: 0,
+ updatedAt: Date.now(),
+ ...overrides,
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let OrdersList: typeof import("./OrdersList").OrdersList
+
+describe("OrdersList", () => {
+ beforeEach(async () => {
+ OrdersList = (await import("./OrdersList")).OrdersList
+ mockData = []
+ mockIsLoading = false
+ mockIsDisabled = false
+ })
+
+ afterEach(() => {
+ cleanup()
+ document.body.innerHTML = ""
+ })
+
+ it("renders loading skeleton when loading", () => {
+ mockIsLoading = true
+ const { container } = render(, { wrapper: createWrapper() })
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument()
+ })
+
+ it("renders empty state when no orders", () => {
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("No open orders")).toBeInTheDocument()
+ })
+
+ it("renders empty state with indexer disabled message", () => {
+ mockIsDisabled = true
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText(/indexer disabled/i)).toBeInTheDocument()
+ })
+
+ it("renders active orders with market name and type", () => {
+ mockData = [createMockOrder()]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("BTC/USD")).toBeInTheDocument()
+ expect(screen.getByText("Long")).toBeInTheDocument()
+ expect(screen.getByText("MarketIncrease")).toBeInTheDocument()
+ })
+
+ it("renders multiple orders", () => {
+ mockData = [
+ createMockOrder({ key: "order-1", marketName: "BTC/USD" }),
+ createMockOrder({ key: "order-2", marketName: "ETH/USD" }),
+ ]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("BTC/USD")).toBeInTheDocument()
+ expect(screen.getByText("ETH/USD")).toBeInTheDocument()
+ })
+
+ it("renders frozen status badge", () => {
+ mockData = [createMockOrder({ status: "frozen" })]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("Frozen")).toBeInTheDocument()
+ })
+
+ it("renders cancel button for each order", () => {
+ mockData = [
+ createMockOrder({ key: "order-1" }),
+ createMockOrder({ key: "order-2" }),
+ ]
+ render(, { wrapper: createWrapper() })
+ const cancelButtons = screen.getAllByRole("button", { name: "Cancel" })
+ expect(cancelButtons).toHaveLength(2)
+ })
+})
diff --git a/apps/web/src/features/trade/components/positions/PositionsList.test.tsx b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx
new file mode 100644
index 0000000..897cbb1
--- /dev/null
+++ b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx
@@ -0,0 +1,208 @@
+import { afterEach, 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"
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children}
+ }
+}
+
+let mockPositions: Array> = []
+let mockIsLoading = false
+let mockIsDisabled = false
+let mockFundingRate = { ratePerHour: 0.001, nextEpochTs: Date.now() + 3600000 }
+
+vi.mock("@/features/trade/hooks/usePositionsWithIndexer", () => ({
+ usePositionsWithIndexer: () => ({
+ data: mockPositions,
+ isLoading: mockIsLoading,
+ isDisabled: mockIsDisabled,
+ }),
+}))
+
+vi.mock("@/features/trade/hooks/useFundingRate", () => ({
+ useFundingRate: () => ({
+ data: mockFundingRate,
+ }),
+}))
+
+vi.mock("@/lib/contracts", () => ({
+ ExchangeRouterClient: class {},
+ SyntheticsReaderClient: class {},
+ exchangeRouterClient: {},
+ syntheticsReaderClient: {},
+}))
+
+vi.mock("@/features/trade/lib/stellar", () => ({
+ cancelOrder: vi.fn(),
+ createIncreaseOrder: vi.fn(),
+ createDecreaseOrder: vi.fn(),
+ claimFundingFees: vi.fn(),
+}))
+
+vi.mock("@/features/trade/hooks/useTokenPrices", () => ({
+ useTokenPrices: () => ({
+ getMidPrice: () => 1,
+ isStale: () => false,
+ getPrice: () => ({ minPrice: 1, maxPrice: 1 }),
+ getStaleness: () => "fresh" as const,
+ }),
+}))
+
+vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({
+ useTokenBalances: () => ({
+ data: {} as Record,
+ isLoading: false,
+ }),
+}))
+
+vi.mock("@/features/trade/hooks/useTokenList", () => ({
+ useTokenList: () => ({
+ data: [],
+ isLoading: false,
+ }),
+}))
+
+vi.mock("@/shared/components/TokenIcon", () => ({
+ TokenIcon: ({ symbol, size }: { symbol: string; size: number }) => (
+
+ {symbol}
+
+ ),
+}))
+
+function createMockPosition(overrides: Record = {}) {
+ return {
+ key: "pos-1",
+ account: "GABCDEF123456789",
+ marketAddress: "0xbtc",
+ marketName: "BTC/USD",
+ indexToken: "WBTC",
+ collateralToken: "USDC",
+ collateralAmount: 1,
+ collateralUsd: 100000,
+ sizeUsd: 50000,
+ sizeInUsdRaw: 50000000000n,
+ entryPrice: 50000,
+ markPrice: 51000,
+ liquidationPrice: 45000,
+ leverage: 10,
+ pnl: 1000,
+ pnlPercent: 10,
+ isLong: true,
+ pnlAfterFees: 950,
+ fundingFeeUsd: 50,
+ ...overrides,
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let PositionsList: typeof import("./PositionsList").PositionsList
+
+describe("PositionsList", () => {
+ beforeEach(async () => {
+ PositionsList = (await import("./PositionsList")).PositionsList
+ mockPositions = []
+ mockIsLoading = false
+ mockIsDisabled = false
+ mockFundingRate = { ratePerHour: 0.001, nextEpochTs: Date.now() + 3600000 }
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ document.body.innerHTML = ""
+ })
+
+ it("renders loading skeleton when loading", () => {
+ mockIsLoading = true
+ const { container } = render(, { wrapper: createWrapper() })
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument()
+ })
+
+ it("renders empty state when no positions", () => {
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("No open positions")).toBeInTheDocument()
+ })
+
+ it("renders empty state with start trading link", () => {
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("Start trading →")).toBeInTheDocument()
+ })
+
+ it("renders positions with market name, side badge, and size", () => {
+ mockPositions = [createMockPosition()]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("BTC/USD")).toBeInTheDocument()
+ expect(screen.getByText("Long")).toBeInTheDocument()
+ })
+
+ it("renders multiple positions", () => {
+ mockPositions = [
+ createMockPosition({ key: "pos-1", marketName: "BTC/USD" }),
+ createMockPosition({ key: "pos-2", marketName: "ETH/USD" }),
+ ]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("BTC/USD")).toBeInTheDocument()
+ expect(screen.getByText("ETH/USD")).toBeInTheDocument()
+ })
+
+ it("renders short position badge", () => {
+ mockPositions = [createMockPosition({ isLong: false })]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText("Short")).toBeInTheDocument()
+ })
+
+ it("renders action buttons for each position", () => {
+ mockPositions = [createMockPosition()]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "+ Collateral" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "- Collateral" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument()
+ })
+
+ it("renders funding fee when positive", () => {
+ mockPositions = [createMockPosition({ fundingFeeUsd: 50 })]
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByRole("button", { name: "Claim" })).toBeInTheDocument()
+ })
+
+ it("does not render Claim button when funding fee is zero", () => {
+ mockPositions = [createMockPosition({ fundingFeeUsd: 0 })]
+ render(, { wrapper: createWrapper() })
+ expect(screen.queryByRole("button", { name: "Claim" })).not.toBeInTheDocument()
+ })
+
+ it("renders indexer disabled message when disabled", () => {
+ mockIsDisabled = true
+ render(, { wrapper: createWrapper() })
+ expect(screen.getByText(/indexer disabled/i)).toBeInTheDocument()
+ })
+
+ it("shows positive PnL in green", () => {
+ mockPositions = [createMockPosition({ pnlAfterFees: 950 })]
+ render(, { wrapper: createWrapper() })
+ const pnlElement = screen.getByText(/\$950\.00/)
+ expect(pnlElement).toBeInTheDocument()
+ expect(pnlElement.className).toContain("text-green-500")
+ })
+
+ it("shows negative PnL in red", () => {
+ mockPositions = [createMockPosition({ pnlAfterFees: -500 })]
+ render(, { wrapper: createWrapper() })
+ const pnlElement = screen.getByText((content) => content.includes("-$") && content.includes("500"))
+ expect(pnlElement).toBeInTheDocument()
+ })
+})