diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index ce2008f1..bd3827bf 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -8,6 +8,7 @@ export { useDocumentQuery } from "./useDocumentQuery"; export { useCollectionQuery } from "./useCollectionQuery"; export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery"; export { useGetCountFromServerQuery } from "./useGetCountFromServerQuery"; +export { useUpdateDocumentMutation } from "./useUpdateDocumentMutation"; export { useSetDocumentMutation } from "./useSetDocumentMutation"; export { useNamedQuery } from "./useNamedQuery"; export { useDeleteDocumentMutation } from "./useDeleteDocumentMutation"; diff --git a/packages/react/src/firestore/useUpdateDocumentMutation.test.tsx b/packages/react/src/firestore/useUpdateDocumentMutation.test.tsx new file mode 100644 index 00000000..cb737dd9 --- /dev/null +++ b/packages/react/src/firestore/useUpdateDocumentMutation.test.tsx @@ -0,0 +1,197 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { + doc, + type DocumentReference, + getDoc, + setDoc, +} from "firebase/firestore"; +import { beforeEach, describe, expect, test } from "vitest"; +import { useUpdateDocumentMutation } from "./useUpdateDocumentMutation"; + +import { + expectFirestoreError, + firestore, + wipeFirestore, +} from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; + +describe("useUpdateDocumentMutation", () => { + beforeEach(async () => { + await wipeFirestore(); + queryClient.clear(); + }); + + test("successfully updates an existing document", async () => { + const docRef = doc(firestore, "tests", "updateTest"); + + await setDoc(docRef, { foo: "initial", num: 1, unchanged: "same" }); + + const updateData = { foo: "updated", num: 2 }; + + const { result } = renderHook(() => useUpdateDocumentMutation(docRef), { + wrapper, + }); + + expect(result.current.isPending).toBe(false); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + expect(snapshot.data()).toEqual({ + foo: "updated", + num: 2, + unchanged: "same", + }); + }); + + test("handles nested field updates", async () => { + const docRef = doc(firestore, "tests", "nestedTest"); + + await setDoc(docRef, { + nested: { field1: "old", field2: "keep" }, + top: "unchanged", + }); + + const updateData = { + "nested.field1": "new", + }; + + const { result } = renderHook(() => useUpdateDocumentMutation(docRef), { + wrapper, + }); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + const data = snapshot.data(); + expect(data?.nested.field1).toBe("new"); + expect(data?.nested.field2).toBe("keep"); + expect(data?.top).toBe("unchanged"); + }); + + test("handles type-safe document updates", async () => { + interface TestDoc { + foo: string; + num: number; + optional?: string; + } + + const docRef = doc( + firestore, + "tests", + "typedDoc" + ) as DocumentReference; + + await setDoc(docRef, { foo: "initial", num: 1 }); + + const updateData = { num: 42 }; + + const { result } = renderHook(() => useUpdateDocumentMutation(docRef), { + wrapper, + }); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + const data = snapshot.data(); + expect(data?.foo).toBe("initial"); // unchanged + expect(data?.num).toBe(42); // updated + }); + + test("fails when updating non-existent document", async () => { + const nonExistentDocRef = doc(firestore, "tests", "doesNotExist"); + const updateData = { foo: "bar" }; + + const { result } = renderHook( + () => useUpdateDocumentMutation(nonExistentDocRef), + { wrapper } + ); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expectFirestoreError(result.current.error, "not-found"); + }); + + test("handles errors when updating restricted collection", async () => { + const restrictedDocRef = doc(firestore, "restrictedCollection", "someDoc"); + const updateData = { foo: "bar" }; + + const { result } = renderHook( + () => useUpdateDocumentMutation(restrictedDocRef), + { wrapper } + ); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expectFirestoreError(result.current.error, "permission-denied"); + }); + + test("calls onSuccess callback after update", async () => { + const docRef = doc(firestore, "tests", "callbackTest"); + await setDoc(docRef, { foo: "initial" }); + + let callbackCalled = false; + const updateData = { foo: "updated" }; + + const { result } = renderHook( + () => + useUpdateDocumentMutation(docRef, { + onSuccess: () => { + callbackCalled = true; + }, + }), + { wrapper } + ); + + await act(() => result.current.mutate(updateData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(callbackCalled).toBe(true); + const snapshot = await getDoc(docRef); + expect(snapshot.data()?.foo).toBe("updated"); + }); + + test("handles empty update object", async () => { + const docRef = doc(firestore, "tests", "emptyUpdateTest"); + await setDoc(docRef, { foo: "initial" }); + + const emptyUpdate = {}; + + const { result } = renderHook(() => useUpdateDocumentMutation(docRef), { + wrapper, + }); + + await act(() => result.current.mutate(emptyUpdate)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + expect(snapshot.data()).toEqual({ foo: "initial" }); + }); +}); diff --git a/packages/react/src/firestore/useUpdateDocumentMutation.ts b/packages/react/src/firestore/useUpdateDocumentMutation.ts new file mode 100644 index 00000000..319c07fd --- /dev/null +++ b/packages/react/src/firestore/useUpdateDocumentMutation.ts @@ -0,0 +1,30 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { + type DocumentReference, + type FirestoreError, + type DocumentData, + updateDoc, + type UpdateData, +} from "firebase/firestore"; + +type FirestoreUseMutationOptions< + TData = unknown, + TError = Error, + DbModelType extends DocumentData = DocumentData +> = Omit< + UseMutationOptions>, + "mutationFn" +>; + +export function useUpdateDocumentMutation< + AppModelType extends DocumentData = DocumentData, + DbModelType extends DocumentData = DocumentData +>( + documentRef: DocumentReference, + options?: FirestoreUseMutationOptions +) { + return useMutation>({ + ...options, + mutationFn: (data) => updateDoc(documentRef, data), + }); +}