From f07a76feb691bfba4127a6bd1d2458a7ac939bf1 Mon Sep 17 00:00:00 2001 From: gamp Date: Thu, 22 Oct 2026 10:00:48 +0100 Subject: [PATCH 1/3] test: cover stream detail and top-up regressions --- .../__tests__/stream-details-content.test.tsx | 53 +++++++++++++++++++ .../__tests__/TopUpModal.test.tsx | 13 +++++ 2 files changed, 66 insertions(+) diff --git a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx index c15b56e1..4aac87b4 100644 --- a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx +++ b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx @@ -352,6 +352,45 @@ const mockWalletForRecipient = (session = { origUseWallet.mockReturnValue({ session, isHydrated: true } as ReturnType); }; +describe("StreamDetailsContent action authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTracker.status = "idle"; + global.fetch = createFetchMock(); + }); + + it("shows only recipient actions to the stream recipient", async () => { + mockWalletForRecipient(); + await renderLoaded(); + + expect(screen.getByRole("button", { name: /withdraw/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^top up$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^pause$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^cancel$/i })).not.toBeInTheDocument(); + }); + + it("shows only sender actions to the stream sender", async () => { + origUseWallet.mockReturnValue({ session: mockSession, isHydrated: true } as ReturnType); + await renderLoaded(); + + expect(screen.queryByRole("button", { name: /withdraw/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^top up$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^pause$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^cancel$/i })).toBeInTheDocument(); + }); + + it("hides all stream actions from an unrelated wallet", async () => { + origUseWallet.mockReturnValue({ + session: { ...mockSession, publicKey: "GUNRELATED" }, + isHydrated: true, + } as ReturnType); + await renderLoaded(); + + expect(screen.queryByRole("heading", { name: "Actions" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /withdraw|top up|pause|resume|cancel/i })).not.toBeInTheDocument(); + }); +}); + describe("StreamDetailsContent handleWithdraw", () => { beforeEach(() => { vi.clearAllMocks(); @@ -405,6 +444,20 @@ describe("StreamDetailsContent handleWithdraw", () => { const withdrawBtn = screen.getByRole("button", { name: /withdraw/i }); expect(withdrawBtn).toBeDisabled(); }); + + it("renders zero rate and zero balance without a non-finite claimable value", async () => { + mockUseStreamingAmount.mockReturnValue(0); + await renderLoaded({ + depositedAmount: "0", + withdrawnAmount: "0", + ratePerSecond: "0", + endTime: Math.floor(Date.now() / 1000), + }); + + expect(screen.getByText("Claimable")).toBeInTheDocument(); + expect(screen.getAllByText(/0(?:\.0+)? XLM/).length).toBeGreaterThan(0); + expect(screen.queryByText(/NaN|Infinity/)).not.toBeInTheDocument(); + }); }); // ─── handleTopUp ────────────────────────────────────────────────────────── diff --git a/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx b/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx index 044fa792..37cead9e 100644 --- a/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx +++ b/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx @@ -74,6 +74,19 @@ describe("TopUpModal", () => { expect(getAmountInput().value).toBe("1.1234567"); }); + it("accepts the maximum supported precision at the amount boundary", async () => { + const user = userEvent.setup(); + render(); + await user.type(getAmountInput(), "1.1234567"); + + fireEvent.click(getConfirmButton()); + + await waitFor(() => { + expect(baseProps.onConfirm).toHaveBeenCalledWith("stream-42", "1.1234567"); + }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("submits a valid amount and shows a success toast", async () => { const user = userEvent.setup(); render(); From 607ce8f2c770d17bfab87ea33e69a9afdad7ba15 Mon Sep 17 00:00:00 2001 From: gamp Date: Thu, 22 Oct 2026 10:08:06 +0100 Subject: [PATCH 2/3] test: cover incoming stream wallet changes --- .../src/hooks/useIncomingStreams.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/frontend/src/hooks/useIncomingStreams.test.tsx b/frontend/src/hooks/useIncomingStreams.test.tsx index 72da19fc..091c36ac 100644 --- a/frontend/src/hooks/useIncomingStreams.test.tsx +++ b/frontend/src/hooks/useIncomingStreams.test.tsx @@ -97,6 +97,31 @@ describe("useIncomingStreams hooks", () => { expect(vi.mocked(fetchIncomingStreams).mock.calls.length).toBeGreaterThan(1); vi.useRealTimers(); }); + + it("does not expose a stale response after switching wallets", async () => { + let resolveFirst!: (streams: IncomingStreamRecord[]) => void; + vi.mocked(fetchIncomingStreams) + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) + .mockResolvedValueOnce([{ id: "new-wallet-stream" } as IncomingStreamRecord]); + + const { result, rerender } = renderHook( + ({ publicKey }: { publicKey: string }) => useIncomingStreams(publicKey), + { wrapper, initialProps: { publicKey: "old-wallet" } }, + ); + + rerender({ publicKey: "new-wallet" }); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.data?.[0]?.id).toBe("new-wallet-stream"); + + await act(async () => { + resolveFirst([{ id: "old-wallet-stream" } as IncomingStreamRecord]); + await Promise.resolve(); + }); + + expect(result.current.data?.[0]?.id).toBe("new-wallet-stream"); + }); }); describe("useWithdrawIncomingStream", () => { @@ -191,5 +216,43 @@ describe("useIncomingStreams hooks", () => { callsBeforeUnmount, ); }); + + it("aborts polling when the wallet changes mid-backoff", async () => { + vi.useFakeTimers(); + vi.mocked(withdrawFromStream).mockResolvedValue({ success: true, txHash: "tx-hash" }); + vi.mocked(fetchIncomingStreams).mockResolvedValue([]); + + const { result, rerender } = renderHook( + ({ publicKey }: { publicKey: string }) => + useWithdrawIncomingStream({} as unknown as WalletSession, publicKey), + { wrapper, initialProps: { publicKey: "old-wallet" } }, + ); + + await act(async () => { + await result.current.mutateAsync({ + id: "1", + streamId: 1, + withdrawn: 0, + deposited: 100, + ratePerSecond: 1, + isPaused: false, + lastUpdateTime: Date.now() / 1000, + } as unknown as IncomingStreamRecord); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + const callsBeforeWalletChange = vi.mocked(fetchIncomingStreams).mock.calls.length; + + rerender({ publicKey: "new-wallet" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(63_000); + }); + + expect(vi.mocked(fetchIncomingStreams).mock.calls.length).toBe(callsBeforeWalletChange); + expect(queryClient.getQueryData(incomingStreamsQueryKey("new-wallet"))).toBeUndefined(); + vi.useRealTimers(); + }); }); }); From 6db88cf845354ccf2b928e737f7572e7488a75ef Mon Sep 17 00:00:00 2001 From: gamp Date: Thu, 22 Oct 2026 10:19:33 +0100 Subject: [PATCH 3/3] test: wait for wallet query before stale response assertion --- frontend/src/hooks/useIncomingStreams.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useIncomingStreams.test.tsx b/frontend/src/hooks/useIncomingStreams.test.tsx index 091c36ac..7e9b1120 100644 --- a/frontend/src/hooks/useIncomingStreams.test.tsx +++ b/frontend/src/hooks/useIncomingStreams.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import React from "react"; @@ -109,11 +109,11 @@ describe("useIncomingStreams hooks", () => { { wrapper, initialProps: { publicKey: "old-wallet" } }, ); + await waitFor(() => expect(resolveFirst).toBeTypeOf("function")); rerender({ publicKey: "new-wallet" }); - await act(async () => { - await Promise.resolve(); + await waitFor(() => { + expect(result.current.data?.[0]?.id).toBe("new-wallet-stream"); }); - expect(result.current.data?.[0]?.id).toBe("new-wallet-stream"); await act(async () => { resolveFirst([{ id: "old-wallet-stream" } as IncomingStreamRecord]);