diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index fde77a59..9f9d0e26 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -9,6 +9,8 @@ // useMultiFactorUserUnenrollMutation (MultiFactorUser) // useMultiFactorUserGetSessionMutation (MultiFactorUser) // useMultiFactorResolverResolveSignInMutation (MultiFactorResolver) +// useApplyActionCodeMutation +export { useCheckActionCodeMutation } from "./useCheckActionCodeMutation"; export { useApplyActionCodeMutation } from "./useApplyActionCodeMutation"; // useCheckActionCodeMutation // useConfirmPasswordResetMutation diff --git a/packages/react/src/auth/useCheckActionCodeMutation.test.tsx b/packages/react/src/auth/useCheckActionCodeMutation.test.tsx new file mode 100644 index 00000000..e7c9ce47 --- /dev/null +++ b/packages/react/src/auth/useCheckActionCodeMutation.test.tsx @@ -0,0 +1,135 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { + createUserWithEmailAndPassword, + sendPasswordResetEmail, + type ActionCodeInfo, +} from "firebase/auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { auth, expectFirebaseError, wipeAuth } from "~/testing-utils"; +import { useCheckActionCodeMutation } from "./useCheckActionCodeMutation"; +import { waitForPasswordResetCode } from "./utils"; +import { queryClient, wrapper } from "../../utils"; + +describe("useCheckActionCodeMutation", () => { + const email = "tqf@invertase.io"; + const password = "TanstackQueryFirebase#123"; + + beforeEach(async () => { + queryClient.clear(); + await wipeAuth(); + await createUserWithEmailAndPassword(auth, email, password); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await auth.signOut(); + }); + + test("successfully checks password reset action code", async () => { + await sendPasswordResetEmail(auth, email); + const oobCode = await waitForPasswordResetCode(email); + + if (!oobCode) { + throw new Error("oobCode is null"); + } + + const { result } = renderHook(() => useCheckActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync(oobCode); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const actionCodeInfo = result.current.data as ActionCodeInfo; + expect(actionCodeInfo.operation).toBe("PASSWORD_RESET"); + expect(actionCodeInfo.data.email).toBe(email); + }); + + test("handles invalid action code", async () => { + const invalidCode = "invalid-action-code"; + + const { result } = renderHook(() => useCheckActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + try { + await result.current.mutateAsync(invalidCode); + } catch (error) { + expectFirebaseError(error, "auth/invalid-action-code"); + } + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + expectFirebaseError(result.current.error, "auth/invalid-action-code"); + }); + + test("handles empty action code", async () => { + const { result } = renderHook(() => useCheckActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + try { + await result.current.mutateAsync(""); + } catch (error) { + expectFirebaseError(error, "auth/internal-error"); + } + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + expectFirebaseError(result.current.error, "auth/internal-error"); + }); + + test("executes onSuccess callback", async () => { + await sendPasswordResetEmail(auth, email); + const oobCode = await waitForPasswordResetCode(email); + const onSuccess = vi.fn(); + + if (!oobCode) { + throw new Error("oobCode is null"); + } + + const { result } = renderHook( + () => useCheckActionCodeMutation(auth, { onSuccess }), + { wrapper } + ); + + await act(async () => { + await result.current.mutateAsync(oobCode); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + + const actionCodeInfo = onSuccess.mock.calls[0][0] as ActionCodeInfo; + expect(actionCodeInfo.operation).toBe("PASSWORD_RESET"); + expect(actionCodeInfo.data.email).toBe(email); + }); + + test("executes onError callback", async () => { + const invalidCode = "invalid-action-code"; + const onError = vi.fn(); + + const { result } = renderHook( + () => useCheckActionCodeMutation(auth, { onError }), + { wrapper } + ); + + await act(async () => { + try { + await result.current.mutateAsync(invalidCode); + } catch (error) { + expectFirebaseError(error, "auth/invalid-action-code"); + } + }); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0]).toBeDefined(); + expectFirebaseError(result.current.error, "auth/invalid-action-code"); + }); +}); diff --git a/packages/react/src/auth/useCheckActionCodeMutation.ts b/packages/react/src/auth/useCheckActionCodeMutation.ts new file mode 100644 index 00000000..b65ecd7a --- /dev/null +++ b/packages/react/src/auth/useCheckActionCodeMutation.ts @@ -0,0 +1,23 @@ +import { type UseMutationOptions, useMutation } from "@tanstack/react-query"; +import { + type Auth, + type ActionCodeInfo, + checkActionCode, + type AuthError, +} from "firebase/auth"; + +type AuthUseMutationOptions< + TData = unknown, + TError = Error, + TVariables = void +> = Omit, "mutationFn">; + +export function useCheckActionCodeMutation( + auth: Auth, + options?: AuthUseMutationOptions +) { + return useMutation({ + ...options, + mutationFn: (oobCode) => checkActionCode(auth, oobCode), + }); +}