diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index 717bdbb9..eb8be198 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -2,7 +2,7 @@ // useEnableIndexedDbPersistenceMutation // useDisableNetworkMutation // useEnableNetworkMutation -// useRunTransactionMutation +export { useRunTransactionMutation } from "./useRunTransactionMutation"; // useWaitForPendingWritesQuery // useWriteBatchCommitMutation (WriteBatch) export { useDocumentQuery } from "./useDocumentQuery"; diff --git a/packages/react/src/firestore/useRunTransactionMutation.test.tsx b/packages/react/src/firestore/useRunTransactionMutation.test.tsx new file mode 100644 index 00000000..a4b70f0c --- /dev/null +++ b/packages/react/src/firestore/useRunTransactionMutation.test.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { describe, expect, test, beforeEach, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { firestore, wipeFirestore } from "~/testing-utils"; +import { useRunTransactionMutation } from "./useRunTransactionMutation"; +import { doc, getDoc, setDoc, type Transaction } from "firebase/firestore"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useRunTransactionMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeFirestore(); + }); + + test("should successfully perform a transaction and update a Firestore document", async () => { + const docRef = doc(firestore, "tests", "transactionDoc"); + await setDoc(docRef, { foo: "bar" }); + + const updateFunction = async (transaction: Transaction) => { + transaction.set(docRef, { foo: "updatedDoc" }); + }; + + const { result } = renderHook( + () => useRunTransactionMutation(firestore, updateFunction), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Verify the document was actually updated + const docSnapshot = await getDoc(docRef); + expect(docSnapshot.exists()).toBe(true); + expect(docSnapshot.data()).toEqual({ foo: "updatedDoc" }); + }); + + test("should perform a transaction with options and update a Firestore document", async () => { + const docRef = doc(firestore, "tests", "transactionDoc"); + + await setDoc(docRef, { foo: "bar" }); + + const updateFunction = async (transaction: Transaction) => { + transaction.set(docRef, { foo: "updatedWithOptions" }); + }; + + const { result } = renderHook( + () => + useRunTransactionMutation(firestore, updateFunction, { + firestore: { maxAttempts: 1 }, + }), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const docSnapshot = await getDoc(docRef); + expect(docSnapshot.exists()).toBe(true); + expect(docSnapshot.data()).toEqual({ foo: "updatedWithOptions" }); + }); + + test("should handle transaction errors correctly", async () => { + const updateFunction = async () => { + throw new Error("Transaction failed"); + }; + + const { result } = renderHook( + () => useRunTransactionMutation(firestore, updateFunction), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.isError).toBe(true); + expect(result.current.error?.message).toBe("Transaction failed"); + }); + + test("should call onSuccess callback when transaction is successful", async () => { + const updateFunction = async (transaction: Transaction) => { + const docRef = doc(firestore, "tests", "transactionDoc"); + transaction.set(docRef, { foo: "onSuccessTest" }); + return "Success"; + }; + + const onSuccessMock = vi.fn(); + + const { result } = renderHook( + () => + useRunTransactionMutation(firestore, updateFunction, { + onSuccess: onSuccessMock, + }), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(onSuccessMock).toHaveBeenCalled(); + }); + + test("should call onError callback when transaction fails", async () => { + const updateFunction = async () => { + throw new Error("Transaction failed"); + }; + + const onErrorMock = vi.fn(); + + const { result } = renderHook( + () => + useRunTransactionMutation(firestore, updateFunction, { + onError: onErrorMock, + }), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(onErrorMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/firestore/useRunTransactionMutation.ts b/packages/react/src/firestore/useRunTransactionMutation.ts new file mode 100644 index 00000000..365ff86b --- /dev/null +++ b/packages/react/src/firestore/useRunTransactionMutation.ts @@ -0,0 +1,31 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { + Firestore, + FirestoreError, + Transaction, + runTransaction, + TransactionOptions, +} from "firebase/firestore"; + +type RunTransactionFunction = (transaction: Transaction) => Promise; + +type FirestoreUseMutationOptions = Omit< + UseMutationOptions, + "mutationFn" +> & { + firestore?: TransactionOptions; +}; + +export function useRunTransactionMutation( + firestore: Firestore, + updateFunction: RunTransactionFunction, + options?: FirestoreUseMutationOptions +) { + const { firestore: firestoreOptions, ...queryOptions } = options ?? {}; + + return useMutation({ + ...queryOptions, + mutationFn: () => + runTransaction(firestore, updateFunction, firestoreOptions), + }); +}