diff --git a/apps/s03-indexer/tests/mappingHandlers.test.ts b/apps/s03-indexer/tests/mappingHandlers.test.ts index 9a7cc01..1108457 100644 --- a/apps/s03-indexer/tests/mappingHandlers.test.ts +++ b/apps/s03-indexer/tests/mappingHandlers.test.ts @@ -368,6 +368,43 @@ describe("SO4 event dispatch", () => { expect(withdrawal.cancellationReason).toBe("expired"); }); + test("indexes withdrawal create event with all key fields", async () => { + await dispatchEvent(so4Event("wth_crt", lifecyclePayload("wth-2"))); + + const [withdrawal] = records("Withdrawal"); + expect(records("Withdrawal")).toHaveLength(1); + expect(withdrawal.id).toBe("withdrawal:wth-2"); + expect(withdrawal.status).toBe("CREATED"); + expect(withdrawal.account).toBe(account); + expect(withdrawal.market).toBe(marketToken); + expect(withdrawal.createdLedger).toBe(100); + }); + + test("indexes withdrawal execute event", async () => { + await dispatchEvent(so4Event("wth_crt", lifecyclePayload("wth-3"))); + await dispatchEvent(so4Event("wth_exe", lifecyclePayload("wth-3"))); + + const [withdrawal] = records("Withdrawal"); + expect(records("Withdrawal")).toHaveLength(1); + expect(withdrawal.id).toBe("withdrawal:wth-3"); + expect(withdrawal.status).toBe("EXECUTED"); + expect(withdrawal.executedLedger).toBe(100); + }); + + test("withdrawal lifecycle events are idempotent on rerun", async () => { + const event1 = so4Event("wth_crt", lifecyclePayload("wth-4")); + const event2 = so4Event("wth_exe", lifecyclePayload("wth-4")); + + await dispatchEvent(event1); + await dispatchEvent(event2); + await dispatchEvent(event1); + await dispatchEvent(event2); + + expect(records("Withdrawal")).toHaveLength(1); + const [withdrawal] = records("Withdrawal"); + expect(withdrawal.status).toBe("EXECUTED"); + }); + test("indexes order lifecycle updates", async () => { await dispatchEvent( so4Event("ord_crt", lifecyclePayload("ord-1", { order_type: "MARKET", is_long: true })), @@ -384,6 +421,63 @@ describe("SO4 event dispatch", () => { expect(order.acceptablePrice).toBe("2000"); }); + test("indexes order freeze event", async () => { + await dispatchEvent( + so4Event("ord_crt", lifecyclePayload("ord-2", { order_type: "LIMIT", is_long: false })), + ); + await dispatchEvent( + so4Event("ord_frz", lifecyclePayload("ord-2")), + ); + + const [order] = records("Order"); + expect(records("Order")).toHaveLength(1); + expect(order.id).toBe("order:ord-2"); + expect(order.status).toBe("FROZEN"); + expect(order.isLong).toBe(false); + }); + + test("indexes order execute event", async () => { + await dispatchEvent( + so4Event("ord_crt", lifecyclePayload("ord-3", { order_type: "MARKET", is_long: true })), + ); + await dispatchEvent( + so4Event("ord_exe", lifecyclePayload("ord-3")), + ); + + const [order] = records("Order"); + expect(records("Order")).toHaveLength(1); + expect(order.id).toBe("order:ord-3"); + expect(order.status).toBe("EXECUTED"); + }); + + test("indexes order cancel event", async () => { + await dispatchEvent( + so4Event("ord_crt", lifecyclePayload("ord-4", { order_type: "LIMIT" })), + ); + await dispatchEvent( + so4Event("ord_can", lifecyclePayload("ord-4", { reason: "user_request" })), + ); + + const [order] = records("Order"); + expect(records("Order")).toHaveLength(1); + expect(order.id).toBe("order:ord-4"); + expect(order.status).toBe("CANCELLED"); + }); + + test("order lifecycle events are idempotent on rerun", async () => { + const event1 = so4Event("ord_crt", lifecyclePayload("ord-5", { order_type: "MARKET" })); + const event2 = so4Event("ord_exe", lifecyclePayload("ord-5")); + + await dispatchEvent(event1); + await dispatchEvent(event2); + await dispatchEvent(event1); + await dispatchEvent(event2); + + expect(records("Order")).toHaveLength(1); + const [order] = records("Order"); + expect(order.status).toBe("EXECUTED"); + }); + test("indexes position changes and current position state", async () => { await dispatchEvent( so4Event("pos_inc", { diff --git a/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx b/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx new file mode 100644 index 0000000..025332d --- /dev/null +++ b/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx @@ -0,0 +1,171 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { CircuitBreakerBanner } from "./CircuitBreakerBanner" +import * as circuitBreakerHook from "../hooks/useCircuitBreaker" + +vi.mock("../hooks/useCircuitBreaker") + +describe("CircuitBreakerBanner", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders nothing when status is not active", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { active: false, message: "" }, + isLoading: false, + error: null, + } as never) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when status is undefined", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: undefined, + isLoading: false, + error: null, + } as never) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("renders banner when status is active", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Market frozen due to circuit breaker", + }, + isLoading: false, + error: null, + } as never) + + render() + expect(screen.getByText("Market frozen due to circuit breaker")).toBeInTheDocument() + }) + + it("has alert role for accessibility", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Execution frozen", + }, + isLoading: false, + error: null, + } as never) + + render() + expect(screen.getByRole("alert")).toBeInTheDocument() + }) + + it("displays amber styling for warning severity", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Caution: market freeze active", + }, + isLoading: false, + error: null, + } as never) + + const { container } = render() + const banner = container.querySelector("[role='alert']") + expect(banner?.className).toContain("bg-amber-500/10") + expect(banner?.className).toContain("border-amber-500/40") + expect(banner?.className).toContain("text-amber-200") + }) + + it("shows dismiss button with correct styling", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Circuit breaker engaged", + }, + isLoading: false, + error: null, + } as never) + + const { container } = render() + const dismissBtn = container.querySelector("button") + expect(dismissBtn).toHaveTextContent("Dismiss") + expect(dismissBtn?.className).toContain("text-amber-300") + }) + + it("dismisses banner when dismiss button clicked", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Execution frozen", + }, + isLoading: false, + error: null, + } as never) + + const { container, rerender } = render() + expect(screen.getByText("Execution frozen")).toBeInTheDocument() + + const dismissBtn = screen.getByRole("button", { name: /Dismiss/ }) + fireEvent.click(dismissBtn) + + expect(container.querySelector("[role='alert']")).not.toBeInTheDocument() + }) + + it("hides banner after dismissal even if status remains active", () => { + const useCircuitBreakerMock = vi.mocked(circuitBreakerHook.useCircuitBreaker) + useCircuitBreakerMock.mockReturnValueOnce({ + data: { + active: true, + message: "Market frozen", + }, + isLoading: false, + error: null, + } as never) + + const { container } = render() + expect(screen.getByText("Market frozen")).toBeInTheDocument() + + const dismissBtn = screen.getByRole("button", { name: /Dismiss/ }) + fireEvent.click(dismissBtn) + + expect(container.querySelector("[role='alert']")).not.toBeInTheDocument() + }) + + it("uses symbol from props in hook", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { active: false, message: "" }, + isLoading: false, + error: null, + } as never) + + render() + expect(circuitBreakerHook.useCircuitBreaker).toHaveBeenCalledWith("TSLA") + }) + + it("handles undefined symbol", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { active: false, message: "" }, + isLoading: false, + error: null, + } as never) + + render() + expect(circuitBreakerHook.useCircuitBreaker).toHaveBeenCalledWith(undefined) + }) + + it("displays explanatory copy with accessible text", () => { + vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({ + data: { + active: true, + message: "Trading is frozen: volatility limit exceeded", + }, + isLoading: false, + error: null, + } as never) + + render() + const banner = screen.getByRole("alert") + expect(banner).toHaveTextContent("Trading is frozen: volatility limit exceeded") + }) +}) diff --git a/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx new file mode 100644 index 0000000..67bb01e --- /dev/null +++ b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ApplyReferralCodePrompt } from "./ApplyReferralCodePrompt" +import * as referralLib from "@/features/referrals/lib/referrals" +import * as contractLib from "@/lib/contracts" + +vi.mock("@/features/referrals/lib/referrals") +vi.mock("@/lib/contracts") + +describe("ApplyReferralCodePrompt", () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + vi.clearAllMocks() + }) + + afterEach(() => { + queryClient.clear() + }) + + const renderComponent = (account: string | null = "GTEST") => { + return render( + + + , + ) + } + + it("renders nothing when no account provided", () => { + const { container } = renderComponent(null) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when account is loading", () => { + vi.mocked(contractLib.getTraderReferralCode).mockImplementationOnce(() => new Promise(() => {})) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + const { container } = renderComponent("GTEST") + expect(container.querySelector("input")).not.toBeInTheDocument() + }) + + it("renders prompt when account exists and no referral applied", () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + renderComponent("GTEST") + expect(screen.getByText(/I have a referral code/)).toBeInTheDocument() + expect(screen.getByPlaceholderText("e.g. MYCODE123")).toBeInTheDocument() + }) + + it("renders nothing when referral already applied", () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce("EXISTING") + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + const { container } = renderComponent("GTEST") + expect(container.querySelector("input")).not.toBeInTheDocument() + }) + + it("renders nothing when prompt already completed", () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(true) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + const { container } = renderComponent("GTEST") + expect(container.querySelector("input")).not.toBeInTheDocument() + }) + + it("normalizes code to uppercase on input", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement + fireEvent.change(input, { target: { value: "mycode" } }) + expect(input.value).toBe("MYCODE") + }) + + it("submits normalized code and dismisses on success", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.applyReferralCode).mockResolvedValueOnce(undefined) + vi.mocked(referralLib.markReferralPromptComplete).mockReturnValueOnce(undefined) + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement + const button = screen.getByRole("button", { name: /Apply/ }) + + fireEvent.change(input, { target: { value: " mycode " } }) + fireEvent.click(button) + + await waitFor(() => { + expect(referralLib.applyReferralCode).toHaveBeenCalledWith("GTEST", "MYCODE") + }) + expect(referralLib.markReferralPromptComplete).toHaveBeenCalledWith("GTEST") + }) + + it("shows validation error without submitting", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce("Invalid code format") + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") + const button = screen.getByRole("button", { name: /Apply/ }) + + fireEvent.change(input, { target: { value: "BAD" } }) + fireEvent.click(button) + + expect(screen.getByText("Invalid code format")).toBeInTheDocument() + expect(referralLib.applyReferralCode).not.toHaveBeenCalled() + }) + + it("shows error message on apply failure", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.applyReferralCode).mockRejectedValueOnce(new Error("Code not found")) + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") + const button = screen.getByRole("button", { name: /Apply/ }) + + fireEvent.change(input, { target: { value: "BADCODE" } }) + fireEvent.click(button) + + await waitFor(() => { + expect(screen.getByText("Code not found")).toBeInTheDocument() + }) + }) + + it("disables apply button when pending", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.applyReferralCode).mockImplementationOnce(() => new Promise(() => {})) + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") + const button = screen.getByRole("button", { name: /Apply/ }) + + fireEvent.change(input, { target: { value: "CODE" } }) + fireEvent.click(button) + + await waitFor(() => { + expect(button).toHaveTextContent("…") + }) + }) + + it("disables apply button when code is empty", () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + + renderComponent("GTEST") + const button = screen.getByRole("button", { name: /Apply/ }) + expect(button).toBeDisabled() + }) + + it("clears error when user edits code", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce("Invalid format") + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") + const button = screen.getByRole("button", { name: /Apply/ }) + + fireEvent.change(input, { target: { value: "BAD" } }) + fireEvent.click(button) + + expect(screen.getByText("Invalid format")).toBeInTheDocument() + + fireEvent.change(input, { target: { value: "GOOD" } }) + expect(screen.queryByText("Invalid format")).not.toBeInTheDocument() + }) + + it("skips prompt and dismisses", async () => { + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null) + vi.mocked(referralLib.markReferralPromptComplete).mockReturnValueOnce(undefined) + + renderComponent("GTEST") + const skipButton = screen.getByRole("button", { name: /Skip for now/ }) + fireEvent.click(skipButton) + + expect(referralLib.markReferralPromptComplete).toHaveBeenCalledWith("GTEST") + await waitFor(() => { + expect(screen.queryByText(/I have a referral code/)).not.toBeInTheDocument() + }) + }) + + it("reads stored referral code on mount", () => { + vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce("STORED") + vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null) + vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false) + + renderComponent("GTEST") + const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement + expect(input.value).toBe("STORED") + }) +})