diff --git a/.gitignore b/.gitignore index 18be5d21..5a29cf3c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,16 @@ test-results/ playwright-report/ playwright/.cache/ +# ─── Test snapshots ────────────────────────────────────────── +**/__snapshots__/ +*.snap +frontend/e2e/snapshots/ +frontend/e2e/test-results/ + +# ─── Playwright artifacts ──────────────────────────────────── +blob-report/ +playwright/.auth/ + # ─── Docker ────────────────────────────────────────────────── .docker/ diff --git a/frontend/e2e/core-flows.spec.ts b/frontend/e2e/core-flows.spec.ts new file mode 100644 index 00000000..0f82e18f --- /dev/null +++ b/frontend/e2e/core-flows.spec.ts @@ -0,0 +1,142 @@ +/** + * E2E: Core user flows (Issue #803) + * + * Tests run against a mock API — no real blockchain calls. + * + * Flows covered: + * 1. Home page → market list → click market → see detail + * 2. Portfolio page shows wallet connect prompt when not connected + * 3. Market detail shows "Connect Wallet" in BetForm when not connected + * 4. Filter by "Open" status shows only open markets + */ + +import { test, expect, Page } from '@playwright/test'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const OPEN_MARKET = { + market_id: 'mkt-open-1', + match_id: 'match-open-1', + fighter_a: 'Canelo Alvarez', + fighter_b: 'Gennady Golovkin', + weight_class: 'Super Middleweight', + title_fight: true, + venue: 'T-Mobile Arena', + scheduled_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(), + status: 'open', + outcome: null, + pool_a: '500000000', + pool_b: '300000000', + pool_draw: '200000000', + total_pool: '1000000000', + odds_a: 5000, + odds_b: 3000, + odds_draw: 2000, + fee_bps: 200, +}; + +const RESOLVED_MARKET = { + ...OPEN_MARKET, + market_id: 'mkt-resolved-1', + match_id: 'match-resolved-1', + fighter_a: 'Anthony Joshua', + fighter_b: 'Tyson Fury', + weight_class: 'Heavyweight', + status: 'resolved', + outcome: 'fighter_a', + scheduled_at: new Date(Date.now() - 86_400_000).toISOString(), +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function mockApiRoutes(page: Page, markets = [OPEN_MARKET, RESOLVED_MARKET]) { + // GET /api/markets (with optional status filter) + await page.route('**/api/markets*', (route) => { + const url = new URL(route.request().url()); + const statusFilter = url.searchParams.get('status'); + const filtered = statusFilter + ? markets.filter((m) => m.status === statusFilter) + : markets; + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ markets: filtered, total: filtered.length, page: 1, limit: 20 }), + }); + }); + + // GET /api/markets/:id + await page.route(`**/api/markets/${OPEN_MARKET.market_id}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(OPEN_MARKET), + }), + ); + + await page.route(`**/api/markets/${OPEN_MARKET.market_id}/bets`, (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }), + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test.describe('Core user flows', () => { + test('1. Home page → market list → click market → see detail', async ({ page }) => { + await mockApiRoutes(page); + + // Navigate to home + await page.goto('/'); + + // Market list renders both fighters + await expect(page.getByText('Canelo Alvarez')).toBeVisible(); + await expect(page.getByText('Gennady Golovkin')).toBeVisible(); + + // Click the market card link + await page.getByRole('link', { name: /Canelo Alvarez/i }).first().click(); + + // Should navigate to market detail + await page.waitForURL(`**/markets/${OPEN_MARKET.market_id}`); + + // Detail page shows fighter names + await expect(page.getByText('Canelo Alvarez')).toBeVisible(); + await expect(page.getByText('Gennady Golovkin')).toBeVisible(); + }); + + test('2. Portfolio page shows wallet connect prompt when not connected', async ({ page }) => { + // No Freighter mock → wallet not connected + await page.goto('/portfolio'); + + // Should show a connect wallet prompt + await expect(page.getByText(/connect/i)).toBeVisible(); + }); + + test('3. Market detail shows "Connect Wallet" in BetForm when not connected', async ({ page }) => { + await mockApiRoutes(page); + + // Navigate directly to market detail without connecting wallet + await page.goto(`/markets/${OPEN_MARKET.market_id}`); + + // BetPanel renders ConnectPrompt when wallet is not connected + await expect(page.getByText(/connect/i)).toBeVisible(); + + // The "Place Bet" button should NOT be visible + await expect(page.getByRole('button', { name: /place bet/i })).not.toBeVisible(); + }); + + test('4. Filter by "Open" status shows only open markets', async ({ page }) => { + await mockApiRoutes(page); + + await page.goto('/'); + + // Both markets visible initially + await expect(page.getByText('Canelo Alvarez')).toBeVisible(); + await expect(page.getByText('Anthony Joshua')).toBeVisible(); + + // Click the "Open" status filter button + await page.getByRole('button', { name: 'Open' }).click(); + + // Only the open market should be visible + await expect(page.getByText('Canelo Alvarez')).toBeVisible(); + await expect(page.getByText('Anthony Joshua')).not.toBeVisible(); + }); +}); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ce982ace..4be21c70 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -11,8 +11,15 @@ import './globals.css'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { - title: 'BOXMEOUT — Boxing Prediction Market on Stellar', + title: { + default: 'BoxMeOut — Boxing Prediction Markets', + template: '%s — BoxMeOut', + }, description: 'Decentralized boxing prediction market powered by Stellar Soroban smart contracts.', + openGraph: { + siteName: 'BoxMeOut', + type: 'website', + }, }; export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element { diff --git a/frontend/src/app/markets/[market_id]/page.tsx b/frontend/src/app/markets/[market_id]/page.tsx index f513cb12..aa2228ad 100644 --- a/frontend/src/app/markets/[market_id]/page.tsx +++ b/frontend/src/app/markets/[market_id]/page.tsx @@ -2,13 +2,34 @@ // BOXMEOUT — Market Detail Page (/markets/[market_id]) // ============================================================ +import type { Metadata } from 'next'; import { ErrorBoundary } from '../../../components/ui/ErrorBoundary'; import MarketDetailContent from './MarketDetailContent'; +import { fetchMarketById } from '../../../services/api'; interface MarketDetailPageProps { params: { market_id: string }; } +export async function generateMetadata({ params }: MarketDetailPageProps): Promise { + try { + const market = await fetchMarketById(params.market_id); + const title = `${market.fighter_a} vs ${market.fighter_b}`; + const description = `Bet on ${market.fighter_a} vs ${market.fighter_b} — ${market.weight_class}${market.title_fight ? ' Title Fight' : ''} on BoxMeOut.`; + return { + title, + description, + openGraph: { + title: `${title} — BoxMeOut`, + description, + type: 'website', + }, + }; + } catch { + return { title: 'Market' }; + } +} + export default function MarketDetailPage({ params }: MarketDetailPageProps): JSX.Element { return ( diff --git a/frontend/src/app/portfolio/layout.tsx b/frontend/src/app/portfolio/layout.tsx new file mode 100644 index 00000000..3d0cd2fe --- /dev/null +++ b/frontend/src/app/portfolio/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'My Portfolio', + description: 'View your active bets, bet history, and pending claims on BoxMeOut.', +}; + +export default function PortfolioLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/frontend/src/components/__tests__/BetPanel.test.tsx b/frontend/src/components/__tests__/BetPanel.test.tsx new file mode 100644 index 00000000..3cf0acca --- /dev/null +++ b/frontend/src/components/__tests__/BetPanel.test.tsx @@ -0,0 +1,264 @@ +/** + * Tests for BetPanel component (Issue #800) + * + * Covers: + * - Outcome selection buttons + * - Amount input validation (below min, above balance) + * - Projected payout updates on amount change + * - Disabled state when market is Locked + * - "Connect Wallet" prompt when not connected + * - Mocks usePlaceBet (useBet) hook + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BetPanel } from '../bet/BetPanel'; +import type { Market } from '../../types'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('next/link', () => { + const Link = ({ href, children, ...props }: { href: string; children: React.ReactNode; [key: string]: unknown }) => ( + {children} + ); + Link.displayName = 'Link'; + return Link; +}); + +// Mock useWallet — default: not connected +const mockUseWallet = jest.fn(); +jest.mock('../../hooks/useWallet', () => ({ + useWallet: () => mockUseWallet(), +})); + +// Mock useBet — default return values +const mockSetSide = jest.fn(); +const mockSetAmount = jest.fn(); +const mockSubmitBet = jest.fn(); +const mockReset = jest.fn(); +const mockUseBet = jest.fn(); +jest.mock('../../hooks/useBet', () => ({ + useBet: () => mockUseBet(), +})); + +// Mock useAppStore +jest.mock('../../store', () => ({ + useAppStore: (selector: (s: { setTxStatus: jest.Mock }) => unknown) => + selector({ setTxStatus: jest.fn() }), +})); + +// Mock child components that are not under test +jest.mock('../bet/BetConfirmModal', () => ({ + BetConfirmModal: () => null, +})); +jest.mock('../ui/TxStatusToast', () => ({ + TxStatusToast: () => null, +})); +jest.mock('../ui/ConnectPrompt', () => ({ + ConnectPrompt: () =>
Connect your wallet to place a bet
, +})); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const openMarket: Market = { + market_id: 'mkt-1', + match_id: 'match-1', + fighter_a: 'Canelo Alvarez', + fighter_b: 'Gennady Golovkin', + weight_class: 'Super Middleweight', + title_fight: true, + venue: 'T-Mobile Arena', + scheduled_at: new Date(Date.now() + 3_600_000).toISOString(), + status: 'open', + outcome: null, + pool_a: '500000000', + pool_b: '300000000', + pool_draw: '200000000', + total_pool: '1000000000', + odds_a: 5000, + odds_b: 3000, + odds_draw: 2000, + fee_bps: 200, +}; + +const lockedMarket: Market = { ...openMarket, status: 'locked' }; + +const defaultBetState = { + side: null, + setSide: mockSetSide, + amount: '', + setAmount: mockSetAmount, + estimatedPayout: null, + isSubmitting: false, + txStatus: { hash: null, status: 'idle' as const, error: null }, + error: null, + submitBet: mockSubmitBet, + reset: mockReset, +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('BetPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseBet.mockReturnValue(defaultBetState); + }); + + describe('when wallet is not connected', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: false }); + }); + + it('renders ConnectPrompt instead of the bet form', () => { + render(); + expect(screen.getByText(/connect your wallet/i)).toBeInTheDocument(); + }); + + it('does not render the Place Bet button', () => { + render(); + expect(screen.queryByRole('button', { name: /place bet/i })).not.toBeInTheDocument(); + }); + }); + + describe('when market is locked', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: true }); + }); + + it('shows "Betting is closed" message', () => { + render(); + expect(screen.getByText(/betting is closed/i)).toBeInTheDocument(); + }); + + it('shows the market status', () => { + render(); + expect(screen.getByText(/market is locked/i)).toBeInTheDocument(); + }); + + it('does not render the bet form', () => { + render(); + expect(screen.queryByRole('button', { name: /place bet/i })).not.toBeInTheDocument(); + }); + }); + + describe('outcome selection buttons', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: true }); + }); + + it('renders fighter_a, draw, and fighter_b buttons', () => { + render(); + expect(screen.getByRole('button', { name: 'Canelo Alvarez' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Draw' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Gennady Golovkin' })).toBeInTheDocument(); + }); + + it('calls setSide with fighter_a when fighter_a button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Canelo Alvarez' })); + expect(mockSetSide).toHaveBeenCalledWith('fighter_a'); + }); + + it('calls setSide with fighter_b when fighter_b button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Gennady Golovkin' })); + expect(mockSetSide).toHaveBeenCalledWith('fighter_b'); + }); + + it('calls setSide with draw when Draw button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Draw' })); + expect(mockSetSide).toHaveBeenCalledWith('draw'); + }); + + it('highlights the selected side button', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: 'fighter_a' }); + render(); + const btn = screen.getByRole('button', { name: 'Canelo Alvarez' }); + expect(btn.className).toContain('bg-amber-500'); + }); + }); + + describe('amount input', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: true }); + }); + + it('renders the amount input', () => { + render(); + expect(screen.getByPlaceholderText('0.00')).toBeInTheDocument(); + }); + + it('calls setAmount when input changes', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('0.00'), { target: { value: '10' } }); + expect(mockSetAmount).toHaveBeenCalledWith('10'); + }); + + it('shows minimum amount hint', () => { + render(); + expect(screen.getByText(/min: 1 xlm/i)).toBeInTheDocument(); + }); + + it('Place Bet button is disabled when amount is empty', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: 'fighter_a', amount: '' }); + render(); + expect(screen.getByRole('button', { name: /place bet/i })).toBeDisabled(); + }); + + it('Place Bet button is disabled when amount is 0', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: 'fighter_a', amount: '0' }); + render(); + expect(screen.getByRole('button', { name: /place bet/i })).toBeDisabled(); + }); + + it('Place Bet button is disabled when no side is selected', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: null, amount: '10' }); + render(); + expect(screen.getByRole('button', { name: /place bet/i })).toBeDisabled(); + }); + }); + + describe('projected payout', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: true }); + }); + + it('shows "—" when no payout is estimated', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, estimatedPayout: null }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows estimated payout in XLM when available', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, estimatedPayout: 12.3456 }); + render(); + expect(screen.getByText('12.3456 XLM')).toBeInTheDocument(); + }); + + it('shows platform fee percentage', () => { + render(); + // fee_bps 200 → 2% + expect(screen.getByText('2%')).toBeInTheDocument(); + }); + }); + + describe('Place Bet button enabled state', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ isConnected: true }); + }); + + it('is enabled when connected, side selected, valid amount, market open', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: 'fighter_a', amount: '10' }); + render(); + expect(screen.getByRole('button', { name: /place bet/i })).not.toBeDisabled(); + }); + + it('shows "Placing Bet…" when isSubmitting', () => { + mockUseBet.mockReturnValue({ ...defaultBetState, side: 'fighter_a', amount: '10', isSubmitting: true }); + render(); + expect(screen.getByRole('button', { name: /placing bet/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/MarketCard.test.tsx b/frontend/src/components/__tests__/MarketCard.test.tsx new file mode 100644 index 00000000..9ef5a245 --- /dev/null +++ b/frontend/src/components/__tests__/MarketCard.test.tsx @@ -0,0 +1,170 @@ +/** + * Tests for MarketCard component (Issue #801) + * + * Covers: + * - Correct rendering of fighter names + * - Correct status badge color per status + * - Countdown timer shows correct label + * - onClick navigation + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MarketCard } from '../market/MarketCard'; +import type { Market, MarketStatus } from '../../types'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('next/link', () => { + const Link = ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ); + Link.displayName = 'Link'; + return Link; +}); + +// Mock MarketOddsBar — not under test +jest.mock('../market/MarketOddsBar', () => ({ + MarketOddsBar: () =>
, +})); + +// Mock CountdownTimer — control output per test +const mockCountdownState = jest.fn(); +jest.mock('../../hooks/useMarketCountdown', () => ({ + useMarketCountdown: () => mockCountdownState(), +})); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeMarket(overrides: Partial = {}): Market { + return { + market_id: 'mkt-1', + match_id: 'match-1', + fighter_a: 'Canelo Alvarez', + fighter_b: 'Gennady Golovkin', + weight_class: 'Super Middleweight', + title_fight: false, + venue: 'T-Mobile Arena', + scheduled_at: new Date(Date.now() + 3_600_000).toISOString(), + status: 'open', + outcome: null, + pool_a: '500000000', + pool_b: '300000000', + pool_draw: '200000000', + total_pool: '1000000000', + odds_a: 5000, + odds_b: 3000, + odds_draw: 2000, + fee_bps: 200, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('MarketCard', () => { + beforeEach(() => { + mockCountdownState.mockReturnValue('2h 30m 00s'); + }); + + describe('fighter names', () => { + it('renders fighter_a name', () => { + render(); + expect(screen.getByText('Canelo Alvarez')).toBeInTheDocument(); + }); + + it('renders fighter_b name', () => { + render(); + expect(screen.getByText('Gennady Golovkin')).toBeInTheDocument(); + }); + + it('renders "vs" separator', () => { + render(); + expect(screen.getByText('vs')).toBeInTheDocument(); + }); + }); + + describe('status badge colors', () => { + const cases: Array<[MarketStatus, string]> = [ + ['open', 'bg-green-100'], + ['locked', 'bg-amber-100'], + ['resolved', 'bg-blue-100'], + ['cancelled', 'bg-gray-100'], + ['disputed', 'bg-red-100'], + ]; + + test.each(cases)('status "%s" renders badge with class %s', (status, expectedClass) => { + render(); + const badge = screen.getByText(status.charAt(0).toUpperCase() + status.slice(1)); + expect(badge.className).toContain(expectedClass); + }); + + it('capitalizes the status text in the badge', () => { + render(); + expect(screen.getByText('Open')).toBeInTheDocument(); + }); + }); + + describe('countdown timer', () => { + it('shows countdown label with time remaining', () => { + mockCountdownState.mockReturnValue('2h 30m 00s'); + render(); + expect(screen.getByText(/starts in/i)).toBeInTheDocument(); + expect(screen.getByText(/2h 30m 00s/i)).toBeInTheDocument(); + }); + + it('shows LIVE badge when fight has started', () => { + mockCountdownState.mockReturnValue('LIVE'); + render(); + expect(screen.getByText('LIVE')).toBeInTheDocument(); + }); + + it('shows ENDED when fight is over', () => { + mockCountdownState.mockReturnValue('ENDED'); + render(); + expect(screen.getByText('ENDED')).toBeInTheDocument(); + }); + }); + + describe('navigation', () => { + it('wraps the card in a link to the market detail page', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/markets/mkt-42'); + }); + }); + + describe('optional fields', () => { + it('shows title fight badge when title_fight is true', () => { + render(); + expect(screen.getByText(/title fight/i)).toBeInTheDocument(); + }); + + it('does not show title fight badge when title_fight is false', () => { + render(); + expect(screen.queryByText(/title fight/i)).not.toBeInTheDocument(); + }); + + it('shows weight class', () => { + render(); + expect(screen.getByText('Heavyweight')).toBeInTheDocument(); + }); + + it('shows total pool in XLM', () => { + // total_pool 1000000000 stroops = 100 XLM + render(); + expect(screen.getByText(/100.*xlm pooled/i)).toBeInTheDocument(); + }); + }); +});