From 612355bcecfc09fae3dba5ce8d777675af00b838 Mon Sep 17 00:00:00 2001 From: David Arranz Date: Wed, 16 Sep 2026 16:34:03 +0200 Subject: [PATCH] Solucionados los problemas al hacer login. --- client/jest.config.ts | 5 +- .../ProtectedRoute/ProtectedRoute.test.tsx | 100 ++++++++++++++++++ .../ProtectedRoute/ProtectedRoute.tsx | 3 +- .../lib/axios/createAxiosAuthActions.test.ts | 16 +++ .../src/lib/axios/createAxiosAuthActions.ts | 2 +- .../src/lib/hooks/useAuth/useLogin.test.tsx | 42 ++++++++ client/src/lib/hooks/useAuth/useLogin.tsx | 16 ++- 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 client/src/components/ProtectedRoute/ProtectedRoute.test.tsx create mode 100644 client/src/lib/axios/createAxiosAuthActions.test.ts create mode 100644 client/src/lib/hooks/useAuth/useLogin.test.tsx diff --git a/client/jest.config.ts b/client/jest.config.ts index 3b083db..a0b0d48 100644 --- a/client/jest.config.ts +++ b/client/jest.config.ts @@ -4,10 +4,13 @@ export default { preset: "ts-jest", testEnvironment: "jest-environment-jsdom", transform: { - "^.+\\.tsx?$": "ts-jest", + "^.+\\.tsx?$": ["ts-jest", { tsconfig: { esModuleInterop: true } }], // process `*.tsx` files with `ts-jest` }, moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "^@shared/(.*)$": "/../shared/lib/$1", + "\\.(css|less|scss|sass)$": "identity-obj-proxy", "\\.(gif|ttf|eot|svg|png)$": "/test/__ mocks __/fileMock.js", }, }; diff --git a/client/src/components/ProtectedRoute/ProtectedRoute.test.tsx b/client/src/components/ProtectedRoute/ProtectedRoute.test.tsx new file mode 100644 index 0000000..1247156 --- /dev/null +++ b/client/src/components/ProtectedRoute/ProtectedRoute.test.tsx @@ -0,0 +1,100 @@ +import { act } from "react-dom/test-utils"; +import { render as renderDom } from "react-dom"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { ProtectedRoute } from "./ProtectedRoute"; + +const useIsLoggedIn = jest.fn(); +const useGetProfile = jest.fn(); + +jest.mock("@/lib/hooks", () => ({ + useIsLoggedIn: () => useIsLoggedIn(), + useGetProfile: () => useGetProfile(), +})); + +jest.mock("@/components", () => ({ + ErrorOverlay: ({ subtitle }: { subtitle: string }) =>
{subtitle}
, + LoadingOverlay: () =>
, +})); + +const renderProtectedRoute = () => { + const container = document.createElement("div"); + document.body.appendChild(container); + + act(() => { + renderDom( + + + +
Quotes
+ + } + /> + Login
} /> + + , + container + ); + }); + + return container; +}; + +describe("ProtectedRoute", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + beforeEach(() => { + useIsLoggedIn.mockReturnValue({ + isSuccess: true, + isLoading: false, + data: { authenticated: true }, + }); + }); + + it("waits for a refetch when the only cached profile is null", () => { + useGetProfile.mockReturnValue({ + isSuccess: true, + isLoading: false, + isFetching: true, + data: null, + }); + + const container = renderProtectedRoute(); + + expect(container.querySelector("[data-testid='loading']")).not.toBeNull(); + expect(container.querySelector("[data-testid='login']")).toBeNull(); + }); + + it("renders the protected route after resolving a valid profile", () => { + useGetProfile.mockReturnValue({ + isSuccess: true, + isLoading: false, + isFetching: false, + data: { id: "current-user", lang_code: "es" }, + }); + + const container = renderProtectedRoute(); + + expect(container.querySelector("[data-testid='quotes']")).not.toBeNull(); + }); + + it("renders an error instead of treating a profile request failure as a logout", () => { + useGetProfile.mockReturnValue({ + isSuccess: false, + isLoading: false, + isFetching: false, + error: new Error("Profile unavailable"), + }); + + const container = renderProtectedRoute(); + + expect(container.querySelector("[data-testid='error']")?.textContent).toContain( + "Profile unavailable" + ); + expect(container.querySelector("[data-testid='login']")).toBeNull(); + }); +}); diff --git a/client/src/components/ProtectedRoute/ProtectedRoute.tsx b/client/src/components/ProtectedRoute/ProtectedRoute.tsx index 488f1fa..eeeaffe 100644 --- a/client/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/client/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -21,6 +21,7 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => { const { isSuccess: isProfileSuccess, isLoading: isProfileLoading, + isFetching: isProfileFetching, error: profileError, data: profile, } = useGetProfile({ @@ -34,7 +35,7 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => { } }, [isProfileSuccess, profile?.lang_code, i18n]);*/ - if (isLoggedInLoading || isProfileLoading) { + if (isLoggedInLoading || isProfileLoading || (isProfileSuccess && profile === null && isProfileFetching)) { return ; } diff --git a/client/src/lib/axios/createAxiosAuthActions.test.ts b/client/src/lib/axios/createAxiosAuthActions.test.ts new file mode 100644 index 0000000..2cb1554 --- /dev/null +++ b/client/src/lib/axios/createAxiosAuthActions.test.ts @@ -0,0 +1,16 @@ +import { createAxiosAuthActions } from "./createAxiosAuthActions"; + +describe("createAxiosAuthActions.getProfile", () => { + it("propagates an HTTP failure instead of resolving it as a null profile", async () => { + const httpClient = { + request: jest.fn().mockRejectedValue(new Error("Profile unavailable")), + } as never; + const authActions = createAxiosAuthActions("/api", httpClient); + + if (!authActions.getProfile) { + throw new Error("getProfile action is not configured"); + } + + await expect(authActions.getProfile()).rejects.toThrow("Profile unavailable"); + }); +}); diff --git a/client/src/lib/axios/createAxiosAuthActions.ts b/client/src/lib/axios/createAxiosAuthActions.ts index f0f9053..138a27b 100644 --- a/client/src/lib/axios/createAxiosAuthActions.ts +++ b/client/src/lib/axios/createAxiosAuthActions.ts @@ -89,7 +89,7 @@ export const createAxiosAuthActions = ( } return Promise.resolve(null); } catch (error) { - return Promise.resolve(null); + throw error; } }, diff --git a/client/src/lib/hooks/useAuth/useLogin.test.tsx b/client/src/lib/hooks/useAuth/useLogin.test.tsx new file mode 100644 index 0000000..a54552e --- /dev/null +++ b/client/src/lib/hooks/useAuth/useLogin.test.tsx @@ -0,0 +1,42 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react-hooks"; +import { PropsWithChildren } from "react"; +import { AuthActionResponse } from "./AuthActions"; +import { AuthContext } from "./AuthContext"; +import { useLogin } from "./useLogin"; + +const createWrapper = (queryClient: QueryClient, login: () => Promise) => { + return ({ children }: PropsWithChildren) => ( + + {children} + + ); +}; + +describe("useLogin", () => { + it.each(["fresh", "stale"])( + "removes %s identity cache before running the successful login callback", + async (cacheState) => { + const queryClient = new QueryClient(); + const login = jest.fn().mockResolvedValue({ success: true }); + const onSuccess = jest.fn(); + + queryClient.setQueryData(["auth", "profile"], null, { + updatedAt: Date.now() - (cacheState === "stale" ? 20_000 : 0), + }); + queryClient.setQueryData(["auth", "check"], { authenticated: false }); + + const { result } = renderHook(() => useLogin({ onSuccess }), { + wrapper: createWrapper(queryClient, login), + }); + + await act(async () => { + await result.current.mutateAsync({ email: "user@example.com", password: "secret" }); + }); + + expect(onSuccess).toHaveBeenCalled(); + expect(queryClient.getQueryData(["auth", "profile"])).toBeUndefined(); + expect(queryClient.getQueryData(["auth", "check"])).toBeUndefined(); + } + ); +}); diff --git a/client/src/lib/hooks/useAuth/useLogin.tsx b/client/src/lib/hooks/useAuth/useLogin.tsx index 19c8fa7..846da03 100644 --- a/client/src/lib/hooks/useAuth/useLogin.tsx +++ b/client/src/lib/hooks/useAuth/useLogin.tsx @@ -1,16 +1,26 @@ -import { AuthActionResponse, useAuth } from "@/lib/hooks"; import { ILogin_DTO } from "@shared/contexts"; -import { UseMutationOptions, useMutation } from "@tanstack/react-query"; +import { UseMutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQueryKey } from "../useQueryKey"; +import { AuthActionResponse } from "./AuthActions"; +import { useAuth } from "./useAuth"; export const useLogin = (params?: UseMutationOptions) => { + const { onSuccess, ...restParams } = params || {}; const keys = useQueryKey(); const { login } = useAuth(); + const queryClient = useQueryClient(); return useMutation({ mutationKey: keys().auth().action("login").get(), mutationFn: login, - ...params, + onSuccess: async (data, variables, context) => { + if (data.success) { + queryClient.removeQueries({ queryKey: keys().auth().get() }); + } + + await onSuccess?.(data, variables, context); + }, + ...restParams, }); };