diff --git a/apps/web/src/features/earn/components/earn-page.test.tsx b/apps/web/src/features/earn/components/earn-page.test.tsx
new file mode 100644
index 0000000..d595e8b
--- /dev/null
+++ b/apps/web/src/features/earn/components/earn-page.test.tsx
@@ -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: () => ,
+}))
+
+vi.mock("./portfolio/portfolio-tab", () => ({
+ PortfolioTab: () =>
Portfolio content
,
+}))
+
+vi.mock("./discover/discover-tab", () => ({
+ DiscoverTab: () => Discover content
,
+}))
+
+vi.mock("./additional/additional-opportunities-tab", () => ({
+ AdditionalOpportunitiesTab: () => (
+ Additional content
+ ),
+}))
+
+vi.mock("./distributions/distributions-tab", () => ({
+ DistributionsTab: () => (
+ Distributions content
+ ),
+}))
+
+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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await user.click(screen.getByRole("tab", { name: "Discover" }))
+ await user.click(screen.getByRole("tab", { name: "Portfolio" }))
+
+ expect(screen.getByTestId("portfolio-content")).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx b/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
new file mode 100644
index 0000000..495f23b
--- /dev/null
+++ b/apps/web/src/features/earn/components/portfolio/rewards-bar.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText("Staking Power Share")).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/features/earn/queries/useGLVVaultData.test.tsx b/apps/web/src/features/earn/queries/useGLVVaultData.test.tsx
new file mode 100644
index 0000000..1714591
--- /dev/null
+++ b/apps/web/src/features/earn/queries/useGLVVaultData.test.tsx
@@ -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 {children}
+ }
+}
+
+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()
+ })
+})
diff --git a/apps/web/src/features/referrals/queries/useReferralCode.test.tsx b/apps/web/src/features/referrals/queries/useReferralCode.test.tsx
new file mode 100644
index 0000000..08e5ce6
--- /dev/null
+++ b/apps/web/src/features/referrals/queries/useReferralCode.test.tsx
@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { useReferralCode } from "./useReferralCode"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+const mockGetReferralInfo = vi.fn()
+const mockReadStoredAffiliateCode = vi.fn()
+
+vi.mock("@/lib/contracts", () => ({
+ referralStorageClient: {
+ getReferralInfo: mockGetReferralInfo,
+ },
+ affiliateCodeStorageKey: (addr: string) => `affiliate-code:${addr}`,
+ // stubs consumed transitively
+ exchangeRouterClient: {},
+ syntheticsReaderClient: {},
+ orderVaultClient: {},
+ sacTokenClient: {},
+ stakingRouterClient: {},
+}))
+
+vi.mock("../lib/referrals", () => ({
+ readStoredAffiliateCode: mockReadStoredAffiliateCode,
+}))
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children}
+ }
+}
+
+function TestComponent() {
+ const { data, isLoading, isError } = useReferralCode()
+
+ if (isLoading) return Loading…
+ if (isError) return Error loading referral code
+ if (!data) return No referral code
+ return {data}
+}
+
+beforeEach(() => {
+ mockGetReferralInfo.mockReset()
+ mockReadStoredAffiliateCode.mockReset()
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+})
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("useReferralCode (#237)", () => {
+ it("does not fetch when wallet is disconnected — no loading or error shown", () => {
+ const Wrapper = createWrapper()
+ render()
+
+ // Query disabled — no loading/error states, falls to empty
+ expect(screen.queryByTestId("loading")).not.toBeInTheDocument()
+ expect(screen.queryByTestId("error")).not.toBeInTheDocument()
+ })
+
+ it("returns stored affiliate code from local storage (success path)", async () => {
+ mockReadStoredAffiliateCode.mockReturnValue("MYCODE")
+
+ useWalletStore.setState({
+ address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
+ walletId: "freighter",
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+
+ const Wrapper = createWrapper()
+ render()
+
+ await waitFor(() => expect(screen.getByTestId("code")).toBeInTheDocument())
+ expect(screen.getByTestId("code")).toHaveTextContent("MYCODE")
+ })
+
+ it("falls back to on-chain lookup when no stored code exists (success path)", async () => {
+ mockReadStoredAffiliateCode.mockReturnValue(null)
+ mockGetReferralInfo.mockResolvedValue({ code: "ONCHAIN" })
+
+ useWalletStore.setState({
+ address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
+ walletId: "freighter",
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+
+ const Wrapper = createWrapper()
+ render()
+
+ await waitFor(() => expect(screen.getByTestId("code")).toBeInTheDocument())
+ expect(screen.getByTestId("code")).toHaveTextContent("ONCHAIN")
+ })
+
+ it("shows empty state when no stored code and on-chain returns null (not found)", async () => {
+ mockReadStoredAffiliateCode.mockReturnValue(null)
+ mockGetReferralInfo.mockResolvedValue({ code: null })
+
+ useWalletStore.setState({
+ address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
+ walletId: "freighter",
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+
+ const Wrapper = createWrapper()
+ render()
+
+ await waitFor(() => expect(screen.getByTestId("empty")).toBeInTheDocument())
+ })
+
+ it("shows error state when on-chain lookup throws (RPC error)", async () => {
+ mockReadStoredAffiliateCode.mockReturnValue(null)
+ mockGetReferralInfo.mockRejectedValue(new Error("RPC error"))
+
+ useWalletStore.setState({
+ address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
+ walletId: "freighter",
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+
+ const Wrapper = createWrapper()
+ render()
+
+ await waitFor(() => expect(screen.getByTestId("error")).toBeInTheDocument())
+ })
+
+ it("query key includes wallet address", async () => {
+ mockReadStoredAffiliateCode.mockReturnValue(null)
+ mockGetReferralInfo.mockResolvedValue({ code: "ADDR_SCOPED" })
+
+ const addr1 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
+ const addr2 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ054321"
+
+ useWalletStore.setState({
+ address: addr1,
+ walletId: "freighter",
+ status: "connected",
+ network: "testnet",
+ pendingTransactionXdr: null,
+ })
+
+ const Wrapper = createWrapper()
+ render()
+
+ await waitFor(() => expect(screen.getByTestId("code")).toBeInTheDocument())
+
+ // The hook was called with the correct address
+ expect(mockGetReferralInfo).toHaveBeenCalledWith(addr1)
+ expect(mockGetReferralInfo).not.toHaveBeenCalledWith(addr2)
+ })
+})