diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index 024d9a3..5efa2ed 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -15,7 +15,7 @@ // useCreateUserWithEmailAndPasswordMutation // useFetchSignInMethodsForEmailQuery // useGetRedirectResultQuery -// useRevokeAccessTokenMutation +export { useRevokeAccessTokenMutation } from "./useRevokeAccessTokenMutation"; // useSendPasswordResetEmailMutation export { useSendSignInLinkToEmailMutation } from "./useSendSignInLinkToEmailMutation"; export { useSignInAnonymouslyMutation } from "./useSignInAnonymouslyMutation"; diff --git a/packages/react/src/auth/types.ts b/packages/react/src/auth/types.ts new file mode 100644 index 0000000..cdc2ca0 --- /dev/null +++ b/packages/react/src/auth/types.ts @@ -0,0 +1,7 @@ +import { UseMutationOptions } from "@tanstack/react-query"; + +export type AuthMutationOptions< + TData = unknown, + TError = Error, + TVariables = void +> = Omit, "mutationFn">; diff --git a/packages/react/src/auth/useRevokeAccessTokenMutation.test.tsx b/packages/react/src/auth/useRevokeAccessTokenMutation.test.tsx new file mode 100644 index 0000000..3e86c87 --- /dev/null +++ b/packages/react/src/auth/useRevokeAccessTokenMutation.test.tsx @@ -0,0 +1,188 @@ +import { useRevokeAccessTokenMutation } from "./useRevokeAccessTokenMutation"; +import { Auth, revokeAccessToken } from "firebase/auth"; +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { wrapper, queryClient, expectInitialMutationState } from "../../utils"; + +vi.mock("firebase/auth", () => ({ + ...vi.importActual("firebase/auth"), + revokeAccessToken: vi.fn(), +})); + +describe("useRevokeAccessTokenMutation", () => { + const mockAuth = {} as Auth; + const mockToken = "mock-access-token"; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("should successfully revoke access token", async () => { + vi.mocked(revokeAccessToken).mockResolvedValueOnce(undefined); + + const { result } = renderHook( + () => useRevokeAccessTokenMutation(mockAuth), + { + wrapper, + } + ); + + act(() => { + result.current.mutate(mockToken); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(revokeAccessToken).toHaveBeenCalledTimes(1); + expect(revokeAccessToken).toHaveBeenCalledWith(mockAuth, mockToken); + }); + + test("should handle revocation failure", async () => { + const mockError = new Error("Failed to revoke token"); + vi.mocked(revokeAccessToken).mockRejectedValueOnce(mockError); + + const { result } = renderHook( + () => useRevokeAccessTokenMutation(mockAuth), + { + wrapper, + } + ); + + act(() => { + result.current.mutate(mockToken); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(mockError); + expect(revokeAccessToken).toHaveBeenCalledTimes(1); + expect(revokeAccessToken).toHaveBeenCalledWith(mockAuth, mockToken); + }); + + test("should accept and use custom mutation options", async () => { + vi.mocked(revokeAccessToken).mockResolvedValueOnce(undefined); + + const onSuccessMock = vi.fn(); + const onErrorMock = vi.fn(); + + const { result } = renderHook( + () => + useRevokeAccessTokenMutation(mockAuth, { + onSuccess: onSuccessMock, + onError: onErrorMock, + }), + { + wrapper, + } + ); + + act(() => { + result.current.mutate(mockToken); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).not.toHaveBeenCalled(); + }); + + test("should properly handle loading state throughout mutation lifecycle", async () => { + vi.mocked(revokeAccessToken).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + const { result } = renderHook( + () => useRevokeAccessTokenMutation(mockAuth), + { + wrapper, + } + ); + + expect(result.current.isPending).toBe(false); + expect(result.current.isIdle).toBe(true); + + await act(async () => { + await result.current.mutateAsync(mockToken); + }); + + expect(result.current.isPending).toBe(true); + expect(result.current.isIdle).toBe(false); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.isPending).toBe(false); + expect(result.current.isIdle).toBe(false); + }); + + test("should handle multiple sequential mutations correctly", async () => { + vi.mocked(revokeAccessToken).mockResolvedValueOnce(undefined); + + const { result } = renderHook( + () => useRevokeAccessTokenMutation(mockAuth), + { + wrapper, + } + ); + + act(() => { + result.current.mutate(mockToken); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Reset mock + vi.mocked(revokeAccessToken).mockResolvedValueOnce(undefined); + + act(() => { + result.current.mutate("different-token"); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(revokeAccessToken).toHaveBeenCalledTimes(2); + expect(revokeAccessToken).toHaveBeenLastCalledWith( + mockAuth, + "different-token" + ); + }); + + test("should reset mutation state correctly", async () => { + vi.mocked(revokeAccessToken).mockResolvedValueOnce(undefined); + + const { result } = renderHook( + () => useRevokeAccessTokenMutation(mockAuth), + { + wrapper, + } + ); + + act(() => { + result.current.mutate(mockToken); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + act(() => { + result.current.reset(); + }); + + await waitFor(() => { + expectInitialMutationState(result); + }); + }); +}); diff --git a/packages/react/src/auth/useRevokeAccessTokenMutation.ts b/packages/react/src/auth/useRevokeAccessTokenMutation.ts new file mode 100644 index 0000000..2eec1bf --- /dev/null +++ b/packages/react/src/auth/useRevokeAccessTokenMutation.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; +import { type Auth, type AuthError, revokeAccessToken } from "firebase/auth"; +import { type AuthMutationOptions } from "./types"; + +export function useRevokeAccessTokenMutation( + auth: Auth, + options?: AuthMutationOptions +) { + return useMutation({ + ...options, + mutationFn: (token: string) => revokeAccessToken(auth, token), + }); +} diff --git a/packages/react/utils.tsx b/packages/react/utils.tsx index 00c21ce..3ad78cf 100644 --- a/packages/react/utils.tsx +++ b/packages/react/utils.tsx @@ -1,22 +1,40 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + QueryClient, + QueryClientProvider, + UseMutationResult, +} from "@tanstack/react-query"; import React, { type ReactNode } from "react"; +import { expect } from "vitest"; const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, - }, - }, + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + {children} ); export { wrapper, queryClient }; // Helper type to make some properties of a type optional. export type PartialBy = Omit & Partial>; + +export function expectInitialMutationState(result: { + current: UseMutationResult; +}) { + expect(result.current.isSuccess).toBe(false); + expect(result.current.isPending).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.isIdle).toBe(true); + expect(result.current.failureCount).toBe(0); + expect(result.current.failureReason).toBeNull(); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeNull(); +}