Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/web/src/features/earn/components/earn-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

vi.mock("@/ui/Navbar", () => ({
Navbar: () => <nav data-testid="navbar" />,
}))

vi.mock("./portfolio/portfolio-tab", () => ({
PortfolioTab: () => <div data-testid="portfolio-content">Portfolio content</div>,
}))

vi.mock("./discover/discover-tab", () => ({
DiscoverTab: () => <div data-testid="discover-content">Discover content</div>,
}))

vi.mock("./additional/additional-opportunities-tab", () => ({
AdditionalOpportunitiesTab: () => (
<div data-testid="additional-content">Additional content</div>
),
}))

vi.mock("./distributions/distributions-tab", () => ({
DistributionsTab: () => (
<div data-testid="distributions-content">Distributions content</div>
),
}))

afterEach(() => {
vi.clearAllMocks()
})

describe("EarnPage — tab navigation (#234)", () => {
it("renders the Portfolio tab as selected by default", async () => {
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

expect(screen.getByRole("tab", { name: "Portfolio" })).toBeInTheDocument()
expect(screen.getByTestId("portfolio-content")).toBeInTheDocument()
})

it("renders all four tab triggers", async () => {
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

expect(screen.getByRole("tab", { name: "Portfolio" })).toBeInTheDocument()
expect(screen.getByRole("tab", { name: "Discover" })).toBeInTheDocument()
expect(screen.getByRole("tab", { name: "Additional opportunities" })).toBeInTheDocument()
expect(screen.getByRole("tab", { name: "Distributions" })).toBeInTheDocument()
})

it("shows Discover content when Discover tab is clicked", async () => {
const user = userEvent.setup()
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

await user.click(screen.getByRole("tab", { name: "Discover" }))

expect(screen.getByTestId("discover-content")).toBeInTheDocument()
})

it("shows Additional content when Additional opportunities tab is clicked", async () => {
const user = userEvent.setup()
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

await user.click(screen.getByRole("tab", { name: "Additional opportunities" }))

expect(screen.getByTestId("additional-content")).toBeInTheDocument()
})

it("shows Distributions content when Distributions tab is clicked", async () => {
const user = userEvent.setup()
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

await user.click(screen.getByRole("tab", { name: "Distributions" }))

expect(screen.getByTestId("distributions-content")).toBeInTheDocument()
})

it("returns to Portfolio content when Portfolio tab is clicked after switching", async () => {
const user = userEvent.setup()
const { EarnPage } = await import("./earn-page")
render(<EarnPage />)

await user.click(screen.getByRole("tab", { name: "Discover" }))
await user.click(screen.getByRole("tab", { name: "Portfolio" }))

expect(screen.getByTestId("portfolio-content")).toBeInTheDocument()
})
})
101 changes: 101 additions & 0 deletions apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import type { EarnStats } from "../../hooks/use-earn-data"

const earnStatsStub: { data: EarnStats; isLoading: boolean } = {
data: {
totalInvestmentUsd: 0,
totalEarnedUsd: 0,
totalPendingRewardsUsd: 0,
stakingPowerSharePct: 0,
},
isLoading: false,
}

vi.mock("../../hooks/use-earn-data", () => ({
useEarnStats: () => earnStatsStub,
}))

vi.mock("../../lib/earn", () => ({
claimRewards: vi.fn(() => Promise.resolve("TX_HASH")),
}))

afterEach(() => {
vi.clearAllMocks()
})

describe("RewardsBar — portfolio rewards (#235)", () => {
beforeEach(() => {
earnStatsStub.data = {
totalInvestmentUsd: 0,
totalEarnedUsd: 0,
totalPendingRewardsUsd: 0,
stakingPowerSharePct: 0,
}
earnStatsStub.isLoading = false
})

it("renders zero reward values when stats are empty", async () => {
const { RewardsBar } = await import("./rewards-bar")
render(<RewardsBar />)

expect(screen.getByText("Total investment value")).toBeInTheDocument()
expect(screen.getByText("Total pending rewards")).toBeInTheDocument()
})

it("disables Claim rewards button when pending rewards are zero", async () => {
const { RewardsBar } = await import("./rewards-bar")
render(<RewardsBar />)

const claimButton = screen.getByRole("button", { name: /Claim rewards/i })
expect(claimButton).toBeDisabled()
})

it("enables Claim rewards button when pending rewards are non-zero", async () => {
earnStatsStub.data = {
totalInvestmentUsd: 500,
totalEarnedUsd: 100,
totalPendingRewardsUsd: 42.5,
stakingPowerSharePct: 12,
}

const { RewardsBar } = await import("./rewards-bar")
render(<RewardsBar />)

const claimButton = screen.getByRole("button", { name: /Claim rewards/i })
expect(claimButton).not.toBeDisabled()
})

it("shows skeleton loaders while isLoading is true", async () => {
earnStatsStub.isLoading = true

const { RewardsBar } = await import("./rewards-bar")
const { container } = render(<RewardsBar />)

const skeletons = container.querySelectorAll(".animate-pulse")
expect(skeletons.length).toBeGreaterThan(0)
})

it("renders the info banner by default", async () => {
const { RewardsBar } = await import("./rewards-bar")
render(<RewardsBar />)

expect(
screen.getByText(/Protocol fees are accumulating/i),
).toBeInTheDocument()
})

it("renders Staking Power Share stat", async () => {
earnStatsStub.data = {
totalInvestmentUsd: 0,
totalEarnedUsd: 0,
totalPendingRewardsUsd: 0,
stakingPowerSharePct: 0,
}

const { RewardsBar } = await import("./rewards-bar")
render(<RewardsBar />)

expect(screen.getByText("Staking Power Share")).toBeInTheDocument()
})
})
142 changes: 142 additions & 0 deletions apps/web/src/features/earn/queries/useGLVVaultData.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useWalletStore } from "@/features/wallet/store/wallet-store"

// ── Contract mock ─────────────────────────────────────────────────────────────

const mockGetMarketPoolAmounts = vi.fn()

vi.mock("@/lib/contracts", () => ({
syntheticsReaderClient: {
getMarketPoolAmounts: mockGetMarketPoolAmounts,
},
// stubs for other re-exports consumed transitively
exchangeRouterClient: {},
referralStorageClient: {},
orderVaultClient: {},
sacTokenClient: {},
stakingRouterClient: {},
}))

// ── Helpers ───────────────────────────────────────────────────────────────────

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}

beforeEach(() => {
mockGetMarketPoolAmounts.mockReset()
useWalletStore.setState({
address: null,
walletId: null,
status: "disconnected",
network: "testnet",
pendingTransactionXdr: null,
})
})

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("useGLVVaultData (#236)", () => {
it("returns zero defaults for an unknown vault address", async () => {
const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData("glv-unknown-vault"), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current.data).toEqual({
apr: 0,
tvlUsd: 0,
underlyingPoolAllocations: [],
userGlvBalance: 0n,
})
})

it("returns vault data for a known vault address (success path)", async () => {
mockGetMarketPoolAmounts.mockResolvedValue({ poolValueUsd: 0 })

const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData("glv-btc-usdc"), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current.data?.apr).toBeGreaterThan(0)
expect(result.current.data?.tvlUsd).toBeGreaterThan(0)
expect(Array.isArray(result.current.data?.underlyingPoolAllocations)).toBe(true)
})

it("falls back to static TVL when RPC call throws (error path)", async () => {
mockGetMarketPoolAmounts.mockRejectedValue(new Error("RPC error"))

const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData("glv-btc-usdc"), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current.data?.tvlUsd).toBeGreaterThan(0)
expect(Array.isArray(result.current.data?.underlyingPoolAllocations)).toBe(true)
})

it("returns non-zero userGlvBalance when wallet is connected", async () => {
mockGetMarketPoolAmounts.mockResolvedValue({ poolValueUsd: 0 })

useWalletStore.setState({
address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
walletId: "freighter",
status: "connected",
network: "testnet",
pendingTransactionXdr: null,
})

const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData("glv-btc-usdc"), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current.data?.userGlvBalance).toBeGreaterThan(0n)
})

it("returns zero userGlvBalance when wallet is disconnected", async () => {
mockGetMarketPoolAmounts.mockResolvedValue({ poolValueUsd: 0 })

const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData("glv-btc-usdc"), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(result.current.data?.userGlvBalance).toBe(0n)
})

it("does not run query when glvAddress is empty", async () => {
const { useGLVVaultData } = await import("./useGLVVaultData")

const { result } = renderHook(() => useGLVVaultData(""), {
wrapper: createWrapper(),
})

// Query should be disabled — stays in pending/idle state
expect(result.current.isSuccess).toBe(false)
expect(result.current.data).toBeUndefined()
})
})
Loading
Loading