From 4a60e0ebeef8d7aad757763b40be83391fda7202 Mon Sep 17 00:00:00 2001 From: iheomadev Date: Wed, 21 Oct 2026 17:13:23 +0000 Subject: [PATCH] FE-1: Consolidate duplicate frontend tests and review lib/stellar.ts & lib/config.ts (#1510) - Merge unique test cases from f105-f108 extra test files into main test files: - DisputeModal: added 'builds dispute_resolution against market contract address' test - MarketStatusBadge: merged cancel reason badge tests (Draw, NoContest, AdminCancelled) - ClaimButton: merged dispute window blocking tests from resolution test file - TransactionStatusModal: f105-f108 tests fully covered by existing a11y + main tests - Add cancelReason prop to MarketStatusBadge component for distinct cancel badges - Delete the four extra test files: DisputeModal.f105-f108.test.tsx, MarketStatusBadge.f105-f108.test.tsx, TransactionStatusModal.f105-f108.test.tsx, ClaimButton.resolution.test.tsx - Review lib/stellar.ts and lib/config.ts: no duplicated or conflicting logic found. Config exports are centralized in config.ts, re-exported by stellar.ts. Timeout constants are appropriately scoped (TX_TIMEOUT_SECONDS for tx validity, TX_TIMEOUT_MS for polling). RPC server creation goes through centralized rpcServerOptions() with allowHttpForRpc validation. --- .../ClaimButton.resolution.test.tsx | 93 ----------- frontend/components/ClaimButton.test.tsx | 52 +++++- .../DisputeModal.f105-f108.test.tsx | 156 ------------------ frontend/components/DisputeModal.test.tsx | 15 ++ .../MarketStatusBadge.f105-f108.test.tsx | 32 ---- .../components/MarketStatusBadge.test.tsx | 31 +++- frontend/components/MarketStatusBadge.tsx | 30 +++- .../TransactionStatusModal.f105-f108.test.tsx | 37 ----- 8 files changed, 124 insertions(+), 322 deletions(-) delete mode 100644 frontend/components/ClaimButton.resolution.test.tsx delete mode 100644 frontend/components/DisputeModal.f105-f108.test.tsx delete mode 100644 frontend/components/MarketStatusBadge.f105-f108.test.tsx delete mode 100644 frontend/components/TransactionStatusModal.f105-f108.test.tsx diff --git a/frontend/components/ClaimButton.resolution.test.tsx b/frontend/components/ClaimButton.resolution.test.tsx deleted file mode 100644 index 807ab8d5c..000000000 --- a/frontend/components/ClaimButton.resolution.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { ClaimButton } from "./ClaimButton"; -import { Bet, Market, MarketStatus, Outcome } from "@/lib/api"; - -jest.mock("@/hooks/useClaimWinnings", () => ({ - useClaimWinnings: () => ({ claim: jest.fn() }), -})); -jest.mock("@/hooks/useClaimRefund", () => ({ - useClaimRefund: () => ({ claimRefund: jest.fn() }), -})); - -const RESOLVED_AT = "2026-10-01T12:00:00Z"; -const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime(); -const DAY_MS = 24 * 60 * 60 * 1000; - -function makeMarket(status: MarketStatus, outcome: Outcome | null): Market { - return { - id: "m-1", - contractAddress: "C1", - fighterA: { name: "Maya Chen", record: "10-0", nationality: "US", weightClass: "Welter" }, - fighterB: { name: "Rico Alvarez", record: "9-1", nationality: "MX", weightClass: "Welter" }, - scheduledAt: "2026-09-30T00:00:00Z", - bettingEndsAt: "2026-09-29T23:00:00Z", - status, - outcome, - poolA: "0", - poolB: "0", - totalPool: "0", - oracleAddress: "G1", - createdBy: "G2", - resolvedAt: RESOLVED_AT, - }; -} - -const winningBet: Bet = { - id: "b-1", - marketId: "m-1", - bettor: "GBETTOR", - side: "FighterA", - amount: "100", - placedAt: "2026-09-29T00:00:00Z", - claimed: false, - payout: null, -}; - -describe("ClaimButton", () => { - it("renders nothing for a losing bet on a resolved market", () => { - const { container } = render( - - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("shows a disabled claim with the reason during the dispute window", () => { - jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000); - render(); - - expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); - expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument(); - jest.restoreAllMocks(); - }); - - it("shows a disabled claim with the reason while the result is disputed", () => { - render(); - - expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); - expect(screen.getByText(/decided by an admin/)).toBeInTheDocument(); - }); - - it("enables the claim once the dispute window has passed", () => { - jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1); - render(); - - expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled(); - expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument(); - jest.restoreAllMocks(); - }); - - it("allows refunds on a cancelled market straight away", () => { - render(); - - expect(screen.getByRole("button", { name: "Claim Refund" })).toBeEnabled(); - }); - - it("shows a claimed bet as claimed", () => { - render( - - ); - - expect(screen.getByRole("button", { name: "Claimed" })).toBeDisabled(); - }); -}); diff --git a/frontend/components/ClaimButton.test.tsx b/frontend/components/ClaimButton.test.tsx index d757f4e9f..6bb45edf2 100644 --- a/frontend/components/ClaimButton.test.tsx +++ b/frontend/components/ClaimButton.test.tsx @@ -13,7 +13,7 @@ const mockClaimRefund = jest.fn(); const fighter = (name: string) => ({ name, record: "10-0", nationality: "USA", weightClass: "Heavyweight" }); -const makeMarket = (overrides: Partial<{ status: string; outcome: string | null }> = {}) => +const makeMarket = (overrides: Partial<{ status: string; outcome: string | null; resolvedAt: string | null }> = {}) => ({ id: "market-1", contractAddress: "market-1", @@ -28,6 +28,7 @@ const makeMarket = (overrides: Partial<{ status: string; outcome: string | null totalPool: "0", oracleAddress: "GORACLE", createdBy: "GCREATOR", + resolvedAt: null as string | null, ...overrides, }) as never; @@ -136,4 +137,53 @@ describe("ClaimButton", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled()); expect(onClaimed).not.toHaveBeenCalled(); }); + + // ── Dispute window blocking (merged from resolution test) ──────────────── + + const RESOLVED_AT = "2026-10-01T12:00:00Z"; + const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime(); + const DAY_MS = 24 * 60 * 60 * 1000; + + it("shows a disabled claim with the reason during the dispute window", () => { + jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000); + render( + + ); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); + expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument(); + jest.restoreAllMocks(); + }); + + it("shows a disabled claim with the reason while the result is disputed", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); + expect(screen.getByText(/decided by an admin/)).toBeInTheDocument(); + }); + + it("enables the claim once the dispute window has passed", () => { + jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1); + render( + + ); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled(); + expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument(); + jest.restoreAllMocks(); + }); }); diff --git a/frontend/components/DisputeModal.f105-f108.test.tsx b/frontend/components/DisputeModal.f105-f108.test.tsx deleted file mode 100644 index 42629319a..000000000 --- a/frontend/components/DisputeModal.f105-f108.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { DisputeModal } from "./DisputeModal"; -import { Bet, Market } from "@/lib/api"; -import * as stellar from "@/lib/stellar"; - -jest.mock("@/hooks/useWallet", () => ({ - useWallet: () => ({ - address: "GDISPUTER", - isNetworkMismatched: false, - signTransaction: jest.fn().mockResolvedValue("signed-xdr"), - }), -})); -jest.mock("@/lib/stellar"); -jest.mock("@/components/ToastProvider", () => ({ - useToast: () => ({ addToast: jest.fn() }), -})); - -const now = Date.now(); - -const userBets: Bet[] = [{ id: "bet-1" } as Bet]; - -const buildMarket = (overrides: Partial = {}): Market => ({ - id: "m1", - contractAddress: "CA1", - fighterA: { name: "Alice", record: "10-0", nationality: "USA", weightClass: "LW" }, - fighterB: { name: "Bob", record: "9-1", nationality: "UK", weightClass: "LW" }, - // scheduledAt acts as resolvedAt proxy; 1 hour ago = within 24h window - scheduledAt: new Date(now - 1 * 60 * 60 * 1000).toISOString(), - bettingEndsAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(), - status: "Resolved", - outcome: "FighterA", - poolA: "5000000000", - poolB: "3000000000", - totalPool: "8000000000", - oracleAddress: "ORA1", - createdBy: "0xabc", - ...overrides, -}); - -describe("DisputeModal", () => { - it("renders nothing when market is not Resolved", () => { - const { container } = render( - - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing when outside dispute window (resolved > 24h ago)", () => { - const { container } = render( - - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing when isOpen is false", () => { - const { container } = render( - - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders modal title and textarea when open and within window", () => { - render( - - ); - expect(screen.getByText("Dispute This Result")).toBeInTheDocument(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - }); - - it("renders in document.body portal", () => { - render( - - ); - const dialog = document.body.querySelector('[role="dialog"]'); - expect(dialog).toBeTruthy(); - }); - - it("shows validation error when reason is too short", async () => { - const user = userEvent.setup(); - render( - - ); - await user.type(screen.getByRole("textbox"), "Too short"); - await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); - expect(screen.getByText(/at least 20 characters/i)).toBeInTheDocument(); - }); - - it("calls onDisputed when reason is valid and form submitted", async () => { - const user = userEvent.setup(); - const onDisputed = jest.fn(); - render( - - ); - await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters."); - await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); - await waitFor(() => expect(onDisputed).toHaveBeenCalledTimes(1)); - }); - - it("builds dispute_resolution against the market contract address", async () => { - const user = userEvent.setup(); - render( - - ); - await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters."); - await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); - await waitFor(() => - expect(stellar.buildSorobanInvocation).toHaveBeenCalledWith( - expect.objectContaining({ contractId: "CA1", method: "dispute_resolution" }) - ) - ); - }); - - it("closes on backdrop click", async () => { - const user = userEvent.setup(); - const onClose = jest.fn(); - render(); - // Click the backdrop overlay (the outermost fixed div) - const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement; - await user.click(backdrop); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("closes on Escape key press", () => { - const onClose = jest.fn(); - render(); - fireEvent.keyDown(document, { key: "Escape" }); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("matches snapshot when open within window", () => { - const market = buildMarket(); - const { baseElement } = render( - - ); - expect(document.body.querySelector('[role="dialog"]')).toMatchSnapshot(); - }); - - it("matches snapshot when not visible (outside window)", () => { - const { container } = render( - - ); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/frontend/components/DisputeModal.test.tsx b/frontend/components/DisputeModal.test.tsx index d094745f3..96d60ac6b 100644 --- a/frontend/components/DisputeModal.test.tsx +++ b/frontend/components/DisputeModal.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DisputeModal } from "./DisputeModal"; import { Market } from "@/lib/api"; +import * as stellar from "@/lib/stellar"; jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn(() => ({ @@ -132,6 +133,20 @@ describe("DisputeModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("builds dispute_resolution against the market contract address", async () => { + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters."); + await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); + await waitFor(() => + expect(stellar.buildSorobanInvocation).toHaveBeenCalledWith( + expect.objectContaining({ contractId: "CA1", method: "dispute_resolution" }) + ) + ); + }); + it("matches snapshot when open within window", () => { jest.useFakeTimers(); jest.setSystemTime(now); diff --git a/frontend/components/MarketStatusBadge.f105-f108.test.tsx b/frontend/components/MarketStatusBadge.f105-f108.test.tsx deleted file mode 100644 index a198830b6..000000000 --- a/frontend/components/MarketStatusBadge.f105-f108.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { MarketStatusBadge } from "./MarketStatusBadge"; - -describe("MarketStatusBadge outcome labels (F-105)", () => { - it("shows a distinct Draw badge with refund copy", () => { - render(); - - expect(screen.getByText("Draw")).toBeInTheDocument(); - expect(screen.getByText(/full refund/i)).toBeInTheDocument(); - expect(screen.queryByText("Cancelled")).not.toBeInTheDocument(); - }); - - it("shows a distinct No Contest badge with refund copy", () => { - render(); - - expect(screen.getByText("No Contest")).toBeInTheDocument(); - expect(screen.getByText(/full refund/i)).toBeInTheDocument(); - }); - - it("shows a distinct Admin cancelled badge", () => { - render(); - - expect(screen.getByText("Admin cancelled")).toBeInTheDocument(); - }); - - it("keeps the generic Cancelled label when no reason is given", () => { - render(); - - expect(screen.getByText("Cancelled")).toBeInTheDocument(); - }); -}); diff --git a/frontend/components/MarketStatusBadge.test.tsx b/frontend/components/MarketStatusBadge.test.tsx index 7190d63d6..a8e2822ad 100644 --- a/frontend/components/MarketStatusBadge.test.tsx +++ b/frontend/components/MarketStatusBadge.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MarketStatusBadge } from "./MarketStatusBadge"; import { MarketStatus } from "@/lib/api"; @@ -10,4 +10,33 @@ describe("MarketStatusBadge", () => { const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); + + // ── Cancel reason badges (merged from f105-f108) ────────────────────────── + + it("shows a distinct Draw badge with refund copy", () => { + render(); + + expect(screen.getByText("Draw")).toBeInTheDocument(); + expect(screen.getByText(/full refund/i)).toBeInTheDocument(); + expect(screen.queryByText("Cancelled")).not.toBeInTheDocument(); + }); + + it("shows a distinct No Contest badge with refund copy", () => { + render(); + + expect(screen.getByText("No Contest")).toBeInTheDocument(); + expect(screen.getByText(/full refund/i)).toBeInTheDocument(); + }); + + it("shows a distinct Admin cancelled badge", () => { + render(); + + expect(screen.getByText("Admin cancelled")).toBeInTheDocument(); + }); + + it("keeps the generic Cancelled label when no reason is given", () => { + render(); + + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + }); }); diff --git a/frontend/components/MarketStatusBadge.tsx b/frontend/components/MarketStatusBadge.tsx index 8eeb5a25d..c79627569 100644 --- a/frontend/components/MarketStatusBadge.tsx +++ b/frontend/components/MarketStatusBadge.tsx @@ -1,7 +1,12 @@ import { MarketStatus } from "@/lib/api"; +/** Additional reason when a market is cancelled, producing a distinct badge. */ +export type CancelReason = "Draw" | "NoContest" | "AdminCancelled"; + export interface MarketStatusBadgeProps { status: MarketStatus; + /** Optional reason for a Cancelled status, showing a more specific badge. */ + cancelReason?: CancelReason; } const COLORS: Record = { @@ -12,10 +17,31 @@ const COLORS: Record = { Cancelled: "bg-gray-600 text-gray-100", }; -export function MarketStatusBadge({ status }: MarketStatusBadgeProps): JSX.Element { +/** Label shown for each cancel reason, or null to keep the generic "Cancelled". */ +const CANCEL_REASON_LABELS: Record = { + Draw: "Draw", + NoContest: "No Contest", + AdminCancelled: "Admin cancelled", +}; + +export function MarketStatusBadge({ status, cancelReason }: MarketStatusBadgeProps): JSX.Element { + const label = + status === "Cancelled" && cancelReason && CANCEL_REASON_LABELS[cancelReason] + ? CANCEL_REASON_LABELS[cancelReason] + : status; + return ( - {status} + {status === "Cancelled" && cancelReason ? ( + <> + {label} + {cancelReason !== "AdminCancelled" && ( + (full refund) + )} + + ) : ( + label + )} ); } diff --git a/frontend/components/TransactionStatusModal.f105-f108.test.tsx b/frontend/components/TransactionStatusModal.f105-f108.test.tsx deleted file mode 100644 index d8f64bc82..000000000 --- a/frontend/components/TransactionStatusModal.f105-f108.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { TransactionStatusModal } from "./TransactionStatusModal"; - -describe("TransactionStatusModal accessibility (F-106)", () => { - it("renders as a labelled modal dialog", () => { - render(); - - const dialog = screen.getByRole("dialog", { name: /transaction/i }); - expect(dialog).toHaveAttribute("aria-modal", "true"); - }); - - it("ignores Escape while a transaction is in flight and announces it", () => { - const onClose = jest.fn(); - render(); - - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - - expect(onClose).not.toHaveBeenCalled(); - expect(screen.getByRole("status")).toHaveTextContent(/in progress/i); - }); - - it("closes on Escape once the transaction has settled", () => { - const onClose = jest.fn(); - render(); - - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("announces status changes through an aria-live region", () => { - render(); - - expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); - }); -});