diff --git a/frontend/src/app/hooks/useConfirmedMutation.test.tsx b/frontend/src/app/hooks/useConfirmedMutation.test.tsx new file mode 100644 index 00000000..9534616c --- /dev/null +++ b/frontend/src/app/hooks/useConfirmedMutation.test.tsx @@ -0,0 +1,98 @@ +/** + * hooks/useConfirmedMutation.test.tsx + * + * Tests that useConfirmedMutation surfaces errors when the wrapped action rejects + * instead of silently closing the confirmation dialog and leaving an unhandled + * promise rejection. + */ + +import { renderHook, act } from "@testing-library/react"; +import { useConfirmedMutation } from "./useConfirmedMutation"; +import { useToastStore } from "../stores/useToastStore"; + +const createDeferred = (): { + promise: Promise; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +} => { + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe("useConfirmedMutation", () => { + beforeEach(() => { + useToastStore.getState().clearToasts(); + }); + + it("keeps the dialog open and surfaces an error toast when the action rejects", async () => { + const deferred = createDeferred(); + const action = jest.fn(() => deferred.promise); + + const { result } = renderHook(() => useConfirmedMutation(action, { title: "Confirm" })); + + act(() => { + result.current.trigger({ foo: "bar" } as never); + }); + + expect(result.current.dialogProps.isOpen).toBe(true); + expect(result.current.isLoading).toBe(false); + + act(() => { + result.current.dialogProps.onConfirm(); + }); + + expect(result.current.isLoading).toBe(true); + + deferred.reject(new Error("boom")); + + await act(async () => { + await deferred.promise.catch(() => {}); + }); + + // Dialog stays open on failure + expect(result.current.dialogProps.isOpen).toBe(true); + // Loading is cleared + expect(result.current.isLoading).toBe(false); + + // An error toast is surfaced + const errorToasts = useToastStore.getState().toasts.filter((t) => t.type === "error"); + expect(errorToasts).toHaveLength(1); + expect(errorToasts[0].title).toBe("Transaction failed"); + expect(errorToasts[0].description).toContain("boom"); + }); + + it("closes the dialog and surfaces no error toast when the action resolves", async () => { + const deferred = createDeferred(); + const action = jest.fn(() => deferred.promise); + + const { result } = renderHook(() => useConfirmedMutation(action, { title: "Confirm" })); + + act(() => { + result.current.trigger({ foo: "bar" } as never); + }); + + act(() => { + result.current.dialogProps.onConfirm(); + }); + + expect(result.current.dialogProps.isOpen).toBe(true); + expect(result.current.isLoading).toBe(true); + + deferred.resolve(undefined); + + await act(async () => { + await deferred.promise; + }); + + expect(result.current.dialogProps.isOpen).toBe(false); + expect(result.current.isLoading).toBe(false); + + const errorToasts = useToastStore.getState().toasts.filter((t) => t.type === "error"); + expect(errorToasts).toHaveLength(0); + }); +}); diff --git a/frontend/src/app/hooks/useConfirmedMutation.ts b/frontend/src/app/hooks/useConfirmedMutation.ts index e793154e..7e07499e 100644 --- a/frontend/src/app/hooks/useConfirmedMutation.ts +++ b/frontend/src/app/hooks/useConfirmedMutation.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useToastStore } from "../stores/useToastStore"; import type { TransactionSummaryItem } from "../components/ui/ConfirmTransactionDialog"; interface ConfirmedMutationOptions { @@ -58,10 +59,20 @@ export function useConfirmedMutation( setIsLoading(true); try { await action(pendingVariables); - } finally { setIsLoading(false); setIsOpen(false); setPendingVariables(null); + } catch (error) { + setIsLoading(false); + const addToast = useToastStore.getState().addToast; + addToast({ + type: "error", + title: "Transaction failed", + description: + error instanceof Error ? error.message : "An unexpected error occurred while confirming.", + duration: 10000, + }); + // Keep the modal open so the user can retry or cancel } }, [action, pendingVariables]);