From 8143f40427e8f7bf64380c2a3f0501c1d63a9811 Mon Sep 17 00:00:00 2001 From: afeez Date: Sun, 30 Aug 2026 08:01:39 +0100 Subject: [PATCH 1/2] test: cover stream creation wizard flow --- .../components/dashboard/dashboard-view.tsx | 3 +- .../stream-creation/StreamCreationWizard.tsx | 7 +- .../__tests__/StreamCreationWizard.test.tsx | 670 ++++++++++++++++++ 3 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/stream-creation/__tests__/StreamCreationWizard.test.tsx diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 6a0c252c..cf24bc42 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -819,7 +819,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const handleCreateStream = async (data: StreamFormData) => { const toastId = toast.loading("Creating stream…"); try { - await sorobanCreateStream(session, { + const result = await sorobanCreateStream(session, { recipient: data.recipient, tokenAddress: getTokenAddress(data.token), amount: toBaseUnits(data.amount), @@ -828,6 +828,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { addStreamLocally(data); // We don't call setShowWizard(false) here anymore, the wizard handles its own flow toast.success("Transaction confirmed on-chain!", { id: toastId }); + return result; } catch (err) { toast.error(toSorobanErrorMessage(err), { id: toastId }); throw err; diff --git a/frontend/src/components/stream-creation/StreamCreationWizard.tsx b/frontend/src/components/stream-creation/StreamCreationWizard.tsx index 50aee5b6..e3e5685a 100644 --- a/frontend/src/components/stream-creation/StreamCreationWizard.tsx +++ b/frontend/src/components/stream-creation/StreamCreationWizard.tsx @@ -27,7 +27,7 @@ export interface StreamFormData { interface StreamCreationWizardProps { onClose: () => void; - onSubmit: (data: StreamFormData) => Promise; + onSubmit: (data: StreamFormData) => Promise<{ txHash: string }>; walletPublicKey?: string; } @@ -381,9 +381,8 @@ export const StreamCreationWizard: React.FC = ({ setIsSubmitting(true); try { // Step 1: Submit transaction - const result = (await onSubmit(formData)) as unknown as { txHash: string }; - const hash = result?.txHash; - setTxHash(hash); + const result = await onSubmit(formData); + setTxHash(result.txHash); // Step 2: Start Polling for Indexer setIsPolling(true); diff --git a/frontend/src/components/stream-creation/__tests__/StreamCreationWizard.test.tsx b/frontend/src/components/stream-creation/__tests__/StreamCreationWizard.test.tsx new file mode 100644 index 00000000..9a9e4030 --- /dev/null +++ b/frontend/src/components/stream-creation/__tests__/StreamCreationWizard.test.tsx @@ -0,0 +1,670 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import React from "react"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +vi.mock("react-hot-toast", () => ({ + default: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/soroban", () => ({ + fetchTokenBalanceDisplay: vi.fn().mockResolvedValue("1000"), +})); + +vi.mock("@/lib/stellar", () => ({ + isValidStellarPublicKey: vi.fn((val: string) => /^G[A-Z2-7]{55}$/.test(val)), +})); + +vi.mock("@/utils/amount", () => ({ + hasValidPrecision: vi.fn((val: string, decimals: number) => { + if (!val || val.trim() === "") return true; + if (val.includes(".")) { + const frac = val.split(".")[1]; + return frac ? frac.length <= decimals : true; + } + return true; + }), +})); + +vi.mock("@/hooks/useModalDialog", () => ({ + useModalDialog: vi.fn(() => ({ current: null })), +})); + +vi.mock("@/lib/api/_shared", () => ({ + getApiBaseUrl: vi.fn(() => "http://localhost:3001"), +})); + +vi.mock("../TemplateStep", () => ({ + TemplateStep: ({ onSelectTemplate, templates }: { onSelectTemplate: (id: string) => void; templates: Array<{ id: string; name: string }> }) => ( +
+ {templates.map((t) => ( + + ))} +
+ ), +})); + +vi.mock("../RecipientStep", () => ({ + RecipientStep: ({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) => ( +
+ onChange(e.target.value)} + /> + {error && {error}} +
+ ), +})); + +vi.mock("../TokenStep", () => ({ + TokenStep: ({ value, error }: { value: string; onChange: (v: string) => void; error?: string }) => ( +
+ Token: {value} + {error && {error}} +
+ ), +})); + +vi.mock("../AmountStep", () => ({ + AmountStep: ({ + value, + onChange, + error, + }: { + value: string; + onChange: (v: string) => void; + error?: string; + token?: string; + }) => ( +
+ onChange(e.target.value)} + /> + {error && {error}} +
+ ), +})); + +vi.mock("../ScheduleStep", () => ({ + ScheduleStep: ({ + duration, + onDurationChange, + error, + }: { + duration: string; + onDurationChange: (v: string) => void; + error?: string; + durationUnit?: string; + amount?: string; + token?: string; + onUnitChange?: (v: string) => void; + }) => ( +
+ onDurationChange(e.target.value)} + /> + {error && {error}} +
+ ), +})); + +vi.mock("../../ui/Stepper", () => ({ + Stepper: ({ steps, currentStep }: { steps: string[]; currentStep: number }) => ( + + ), +})); + +vi.mock("../../ui/Button", () => ({ + Button: ({ + children, + onClick, + disabled, + loading, + variant, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + loading?: boolean; + variant?: string; + }) => ( + + ), +})); + +import { StreamCreationWizard } from "../StreamCreationWizard"; +import { useRouter } from "next/navigation"; +import { getApiBaseUrl } from "@/lib/api/_shared"; + +// Valid Stellar Ed25519 public key: G + 55 base32 chars (A-Z, 2-7) +const VALID_KEY = "GABCDEFGHJKLMNPQRSTUVWXYZ234567ABCDEFGHJKLMNPQRSTUVWXYZ2"; + +function renderWizard(overrides: Partial> = {}) { + const onClose = vi.fn(); + const onSubmit = vi.fn().mockResolvedValue({ txHash: "abc123hash" }); + const push = vi.fn(); + (useRouter as ReturnType).mockReturnValue({ push }); + + const result = render( + + ); + + return { onClose, onSubmit, push, ...result }; +} + +function clickNext() { + fireEvent.click(screen.getByText("Next")); +} + +function clickBack() { + fireEvent.click(screen.getByText("Back")); +} + +function clickCreate() { + fireEvent.click(screen.getByText("Create Stream")); +} + +function clickCancel() { + fireEvent.click(screen.getByText("Cancel")); +} + +function advanceToStep5() { + clickNext(); // 1→2 + fireEvent.change(screen.getByLabelText("Recipient Address"), { target: { value: VALID_KEY } }); + clickNext(); // 2→3 + clickNext(); // 3→4 (token step, USDC is default) + clickNext(); // 4→5 (amount step, 5000 is default) +} + +describe("StreamCreationWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getApiBaseUrl).mockReturnValue("http://localhost:3001"); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + it("renders the wizard dialog with title and template step", () => { + renderWizard(); + expect(screen.getByText("Create Payment Stream")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Next")).toBeInTheDocument(); + }); + + it("calls onClose when the close button is clicked", () => { + const { onClose } = renderWizard(); + fireEvent.click(screen.getByLabelText("Close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when Cancel is clicked", () => { + const { onClose } = renderWizard(); + clickCancel(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when clicking the backdrop", () => { + const { onClose } = renderWizard(); + const backdrop = screen.getByRole("dialog"); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // ── Step navigation ──────────────────────────────────────────────────────── + + it("starts on step 1 (Template)", () => { + renderWizard(); + expect(screen.getByText("Step 1 of 5")).toBeInTheDocument(); + expect(screen.getByText("Template", { selector: "[aria-current='step']" })).toBeInTheDocument(); + }); + + it("advances to step 2 when Next is clicked on Template step", () => { + renderWizard(); + clickNext(); + expect(screen.getByText("Step 2 of 5")).toBeInTheDocument(); + expect(screen.getByTestId("recipient-step")).toBeInTheDocument(); + }); + + it("goes back to step 1 when Back is clicked on step 2", () => { + renderWizard(); + clickNext(); + clickBack(); + expect(screen.getByText("Step 1 of 5")).toBeInTheDocument(); + }); + + it("does not go back from step 1", () => { + renderWizard(); + expect(screen.queryByText("Back")).not.toBeInTheDocument(); + }); + + // ── Step-by-step validation gating ──────────────────────────────────────── + + it("shows validation error for empty recipient on step 2", () => { + renderWizard(); + clickNext(); // go to step 2 + fireEvent.change(screen.getByLabelText("Recipient Address"), { target: { value: "" } }); + clickNext(); // try to advance + expect(screen.getByRole("alert")).toHaveTextContent("Recipient address is required"); + expect(screen.getByText("Step 2 of 5")).toBeInTheDocument(); + }); + + it("shows validation error for invalid Stellar public key", () => { + renderWizard(); + clickNext(); + fireEvent.change(screen.getByLabelText("Recipient Address"), { target: { value: "not-a-key" } }); + clickNext(); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid Stellar public key format"); + }); + + it("advances past step 2 with a valid recipient", () => { + renderWizard(); + clickNext(); + fireEvent.change(screen.getByLabelText("Recipient Address"), { target: { value: VALID_KEY } }); + clickNext(); + expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); + }); + + it("advances past step 4 with a valid amount", () => { + renderWizard(); + advanceToStep5(); + expect(screen.getByText("Step 5 of 5")).toBeInTheDocument(); + }); + + // ── Submit flow ──────────────────────────────────────────────────────────── + + it("does not submit when on an invalid step", () => { + const { onSubmit } = renderWizard(); + advanceToStep5(); + // Clear the duration to make step 5 invalid + fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "" } }); + clickCreate(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("calls onSubmit with form data", async () => { + const { onSubmit } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "123" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: VALID_KEY, + token: "USDC", + amount: "5000", + duration: "1", + durationUnit: "months", + }) + ); + }); + + it("redirects to stream page after polling finds the stream", async () => { + const { push } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "123" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/streams/123"); + }); + }); + + it("submits and shows polling UI on valid form", async () => { + const { onSubmit } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "123" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.getByText("Waiting for confirmation...")).toBeInTheDocument(); + }); + + // ── Polling behavior ────────────────────────────────────────────────────── + + it("shows the three-step confirmation UI during polling", async () => { + renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "123" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + expect(screen.getByText("Sign Transaction")).toBeInTheDocument(); + expect(screen.getByText("Network Confirmation")).toBeInTheDocument(); + expect(screen.getByText("Indexer Synchronization")).toBeInTheDocument(); + }); + + it("hides navigation buttons during polling", async () => { + renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "123" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + expect(screen.queryByText("Back")).not.toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + expect(screen.queryByText("Create Stream")).not.toBeInTheDocument(); + }); + + it("handles flat array response from indexer", async () => { + const { push } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify([{ streamId: "789" }])) + )); + + await act(async () => { + clickCreate(); + }); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/streams/789"); + }); + }); + + it("handles nested data response from indexer", async () => { + const { push } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ streamId: "999" }] })) + )); + + await act(async () => { + clickCreate(); + }); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/streams/999"); + }); + }); + + it("retries on fetch error during polling", async () => { + vi.useFakeTimers(); + const { push } = renderWizard(); + advanceToStep5(); + + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ streamId: "retry-ok" }] }))); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + clickCreate(); + }); + + // First poll fails, then startPolling waits POLL_INTERVAL (2s) before retrying + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(push).toHaveBeenCalledWith("/streams/retry-ok"); + vi.useRealTimers(); + }); + + // ── Timeout handling ────────────────────────────────────────────────────── + + it("shows timeout error after polling timeout (30s)", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + ); + vi.stubGlobal("fetch", fetchMock); + + renderWizard(); + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + // Advance past the 30s timeout + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + expect(screen.getByText("Confirmation Timeout")).toBeInTheDocument(); + expect(screen.getByText(/couldn't detect your stream yet/)).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("shows txHash in the timeout error UI", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + )); + + renderWizard(); + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + expect(screen.getByText("abc123hash")).toBeInTheDocument(); + vi.useRealTimers(); + }); + + it("shows a link to Stellar Expert in timeout error UI", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + )); + + renderWizard(); + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + const link = screen.getByText("View on Stellar Expert"); + expect(link).toBeInTheDocument(); + expect(link.closest("a")).toHaveAttribute( + "href", + "https://stellar.expert/explorer/testnet/tx/abc123hash" + ); + vi.useRealTimers(); + }); + + it("shows Go to Dashboard button on timeout", async () => { + vi.useFakeTimers(); + const { onClose } = renderWizard(); + advanceToStep5(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + )); + + await act(async () => { + clickCreate(); + }); + + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + fireEvent.click(screen.getByText("Go to Dashboard")); + expect(onClose).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + // ── txHash propagation (regression) ─────────────────────────────────────── + + it("surfaces txHash from onSubmit in the polling/timeout UI", async () => { + vi.useFakeTimers(); + const onSubmit = vi.fn().mockResolvedValue({ txHash: "specificTxHash123" }); + const push = vi.fn(); + (useRouter as ReturnType).mockReturnValue({ push }); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + )); + + render( + + ); + + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + expect(screen.getByText("specificTxHash123")).toBeInTheDocument(); + expect( + screen.getByText("View on Stellar Expert").closest("a") + ).toHaveAttribute("href", expect.stringContaining("specificTxHash123")); + vi.useRealTimers(); + }); + + it("txHash is NOT undefined when onSubmit resolves with a hash", async () => { + vi.useFakeTimers(); + const onSubmit = vi.fn().mockResolvedValue({ txHash: "def456" }); + const push = vi.fn(); + (useRouter as ReturnType).mockReturnValue({ push }); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] })) + )); + + render( + + ); + + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + await act(async () => { + vi.advanceTimersByTime(30000); + }); + + const codeElements = screen.getAllByText("def456"); + expect(codeElements.length).toBeGreaterThan(0); + expect(screen.queryByText("undefined")).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + + // ── Error handling ──────────────────────────────────────────────────────── + + it("catches onSubmit errors and stops submitting", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("wallet rejected")); + const push = vi.fn(); + (useRouter as ReturnType).mockReturnValue({ push }); + + render( + + ); + + advanceToStep5(); + + await act(async () => { + clickCreate(); + }); + + // Should not show polling UI since the error was caught + expect(screen.queryByText("Waiting for confirmation...")).not.toBeInTheDocument(); + // Should still be on step 5 + expect(screen.getByText("Step 5 of 5")).toBeInTheDocument(); + }); + + // ── Progress indicator ──────────────────────────────────────────────────── + + it("displays correct percentage during steps", () => { + renderWizard(); + expect(screen.getByText("20% complete")).toBeInTheDocument(); + + clickNext(); + expect(screen.getByText("40% complete")).toBeInTheDocument(); + }); + + // ── Description tag badge ────────────────────────────────────────────────── + + it("shows description tag badge when a tag is set", () => { + renderWizard(); + expect(screen.getByText("Tag: salary")).toBeInTheDocument(); + }); +}); From dff9987aaa4b213b59243428737d10ecf0b0f88e Mon Sep 17 00:00:00 2001 From: afeez Date: Sun, 30 Aug 2026 08:50:03 +0100 Subject: [PATCH 2/2] ci: skip backend CI for frontend-only PRs --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaaac563..9c3fd77f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,24 @@ on: branches: [main, develop] jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check changed files + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + backend: + - 'backend/**' + - 'package-lock.json' + frontend: name: Frontend CI runs-on: ubuntu-latest @@ -49,6 +67,10 @@ jobs: backend: name: Backend CI runs-on: ubuntu-latest + needs: changes + if: >- + github.event_name == 'push' || + needs.changes.outputs.backend == 'true' services: postgres: image: postgres:16-alpine@sha256:e013e867e712fec275706a6c51c966f0bb0c93cfa8f51000f85a15f9865a28cb