diff --git a/src/features/auth/model/stores/authStore/authStore.spec.ts b/src/features/auth/model/stores/authStore/authStore.spec.ts index eba0cc9..19bcdc0 100644 --- a/src/features/auth/model/stores/authStore/authStore.spec.ts +++ b/src/features/auth/model/stores/authStore/authStore.spec.ts @@ -1,10 +1,5 @@ import { setupServer } from "msw/node"; -import { - loginMock, - registerMock, - logoutMock, - refreshMock, -} from "../../../api/calls"; +import * as apiCalls from "../../../api/calls"; import { defaultStoreState, useAuthStore } from "./authStore"; import { MOCK_EMAIL, @@ -12,7 +7,16 @@ import { MOCK_PASSWORD, } from "../../../api/calls/mocks"; -const server = setupServer(loginMock, registerMock, logoutMock, refreshMock); +const server = setupServer( + apiCalls.loginMock, + apiCalls.registerMock, + apiCalls.logoutMock, + apiCalls.refreshMock, +); + +const loginSpy = vi.spyOn(apiCalls, "login"); +const registerSpy = vi.spyOn(apiCalls, "register"); +const logoutSpy = vi.spyOn(apiCalls, "logout"); describe("authStore", () => { beforeAll(() => server.listen({ onUnhandledRequest: "error" })); @@ -25,14 +29,18 @@ describe("authStore", () => { describe("setEmail", () => { it("should set the email in formData", () => { useAuthStore.getState().setEmail(MOCK_NEW_EMAIL); - expect(useAuthStore.getState().formData.email).toBe(MOCK_NEW_EMAIL); + expect(useAuthStore.getState().formData.email).toMatchObject({ + value: MOCK_NEW_EMAIL, + }); }); }); describe("setPassword", () => { it("should set the password in formData", () => { useAuthStore.getState().setPassword(MOCK_PASSWORD); - expect(useAuthStore.getState().formData.password).toBe(MOCK_PASSWORD); + expect(useAuthStore.getState().formData.password).toMatchObject({ + value: MOCK_PASSWORD, + }); }); }); @@ -44,10 +52,33 @@ describe("authStore", () => { }); describe("login", () => { + it("should not do api call if status is loading", async () => { + useAuthStore.getState().setEmail(MOCK_EMAIL); + useAuthStore.getState().setPassword(MOCK_PASSWORD); + useAuthStore.setState({ status: "loading" }); + + await useAuthStore.getState().login(); + + expect(loginSpy).not.toHaveBeenCalled(); + }); + + it("should not do api call if form is invalid", async () => { + useAuthStore.getState().setEmail(""); + useAuthStore.getState().setPassword(""); + + await useAuthStore.getState().login(); + + const { status } = useAuthStore.getState(); + + expect(loginSpy).not.toHaveBeenCalled(); + expect(status).toBe("idle"); + }); + it("should set access token, user data, and update status after successful login", async () => { - await useAuthStore - .getState() - .login({ email: MOCK_EMAIL, password: MOCK_PASSWORD }); + useAuthStore.getState().setEmail(MOCK_EMAIL); + useAuthStore.getState().setPassword(MOCK_PASSWORD); + + await useAuthStore.getState().login(); const { user, status, error } = useAuthStore.getState(); @@ -57,9 +88,10 @@ describe("authStore", () => { }); it("should set error and update status if login fails", async () => { - await useAuthStore - .getState() - .login({ email: "wrong@test.com", password: "wrongPassword" }); + useAuthStore.getState().setEmail("wrong@test.com"); + useAuthStore.getState().setPassword("wrongPassword"); + + await useAuthStore.getState().login(); const { status, error } = useAuthStore.getState(); @@ -69,10 +101,33 @@ describe("authStore", () => { }); describe("register", () => { + it("should not do api call if status is loading", async () => { + useAuthStore.getState().setEmail(MOCK_EMAIL); + useAuthStore.getState().setPassword(MOCK_PASSWORD); + useAuthStore.setState({ status: "loading" }); + + await useAuthStore.getState().register(); + + expect(registerSpy).not.toHaveBeenCalled(); + }); + + it("should not do api call if form is invalid", async () => { + useAuthStore.getState().setEmail(""); + useAuthStore.getState().setPassword(""); + + await useAuthStore.getState().register(); + + const { status } = useAuthStore.getState(); + + expect(registerSpy).not.toHaveBeenCalled(); + expect(status).toBe("idle"); + }); + it("should set access token, user data, and update status after successful registration", async () => { - await useAuthStore - .getState() - .register({ email: MOCK_NEW_EMAIL, password: MOCK_PASSWORD }); + useAuthStore.getState().setEmail(MOCK_NEW_EMAIL); + useAuthStore.getState().setPassword(MOCK_PASSWORD); + + await useAuthStore.getState().register(); const { user, status, error } = useAuthStore.getState(); @@ -82,9 +137,10 @@ describe("authStore", () => { }); it("should set error and update status if registration fails", async () => { - await useAuthStore - .getState() - .register({ email: MOCK_EMAIL, password: MOCK_PASSWORD }); + useAuthStore.getState().setEmail(MOCK_EMAIL); + useAuthStore.getState().setPassword(MOCK_PASSWORD); + + await useAuthStore.getState().register(); const { status, error } = useAuthStore.getState(); @@ -94,6 +150,14 @@ describe("authStore", () => { }); describe("logout", () => { + it("should not do api call if status is loading", async () => { + useAuthStore.setState({ status: "loading" }); + + await useAuthStore.getState().logout(); + + expect(logoutSpy).not.toHaveBeenCalled(); + }); + it("should clear access token, user data, and update status after logout", async () => { await useAuthStore.getState().logout(); diff --git a/src/features/auth/model/stores/authStore/authStore.ts b/src/features/auth/model/stores/authStore/authStore.ts index ccda162..152ee4e 100644 --- a/src/features/auth/model/stores/authStore/authStore.ts +++ b/src/features/auth/model/stores/authStore/authStore.ts @@ -3,33 +3,66 @@ import type { AuthStore, AuthStoreState } from "../../types/store"; import { login, logout, register } from "../../../api"; import { callApi } from "shared/utils"; import { UNEXPECTED_ERROR_MESSAGE } from "shared/api"; +import { selectAuthData, selectFormValid } from "../../selectors"; export const defaultStoreState: Readonly = { formData: { - email: "", - password: "", + email: { value: "", valid: false }, + password: { value: "", valid: false }, }, user: undefined, status: "idle", error: null, }; -export const useAuthStore = create()((set) => ({ +function validateEmail(email: string): boolean { + return Boolean(email); +} + +function validatePassword(password: string): boolean { + return Boolean(password); +} + +export const useAuthStore = create()((set, get) => ({ ...defaultStoreState, setEmail: (email: string) => { - set((state) => ({ formData: { ...state.formData, email } })); + const isValid = validateEmail(email); + set((state) => ({ + formData: { ...state.formData, email: { value: email, valid: isValid } }, + })); }, setPassword: (password: string) => { - set((state) => ({ formData: { ...state.formData, password } })); + const isValid = validatePassword(password); + set((state) => ({ + formData: { + ...state.formData, + password: { value: password, valid: isValid }, + }, + })); }, reset: () => { set(defaultStoreState); }, - login: async (loginData) => { + login: async () => { + const { status } = get(); + + if (status === "loading") { + return; + } + set({ status: "loading" }); + + const formValid = selectFormValid(get()); + + if (!formValid) { + set({ status: "idle" }); + return; + } + try { + const loginData = selectAuthData(get()); const [responseData, loginError] = await callApi(() => login(loginData)); if (loginError) { @@ -42,8 +75,6 @@ export const useAuthStore = create()((set) => ({ user: responseData?.user, error: null, }); - - // useTokenStore.setState({ accessToken: responseData?.accessToken }); } catch (err) { console.error(err); set({ @@ -52,8 +83,24 @@ export const useAuthStore = create()((set) => ({ }); } }, - register: async (registerData) => { + register: async () => { + const { status } = get(); + + if (status === "loading") { + return; + } + + set({ status: "loading" }); + + const formValid = selectFormValid(get()); + + if (!formValid) { + set({ status: "idle" }); + return; + } + try { + const registerData = selectAuthData(get()); const [responseData, registerError] = await callApi(() => register(registerData), ); @@ -68,8 +115,6 @@ export const useAuthStore = create()((set) => ({ user: responseData?.user, error: null, }); - - // useTokenStore.setState({ accessToken: responseData?.accessToken }); } catch (err) { console.error(err); set({ @@ -79,12 +124,18 @@ export const useAuthStore = create()((set) => ({ } }, logout: async () => { + const prevStatus = get().status; + + if (prevStatus === "loading") { + return; + } + set({ status: "loading" }); try { const [, logoutError] = await callApi(() => logout()); if (logoutError) { - set({ error: logoutError }); + set({ error: logoutError, status: prevStatus }); return; } diff --git a/src/features/auth/model/types/store.ts b/src/features/auth/model/types/store.ts index 5dc9606..d84b8ba 100644 --- a/src/features/auth/model/types/store.ts +++ b/src/features/auth/model/types/store.ts @@ -2,13 +2,20 @@ import type { User } from "entities/User"; import type { AuthData, AuthStatus } from "./service"; import type { ApiError } from "shared/utils"; +export type AuthFormData = { + [K in keyof AuthData]: { + value: AuthData[K]; + valid: boolean; + }; +}; + export interface AuthStoreState { /** * Form data for login/register forms */ - formData: AuthData; + formData: AuthFormData; /** - * User's credentials + * Current user */ user?: User; /** @@ -26,8 +33,8 @@ export interface AuthStoreActions { setPassword: (password: string) => void; reset: () => void; - login: (data: AuthData) => Promise; - register: (data: AuthData) => Promise; + login: () => Promise; + register: () => Promise; logout: () => Promise; }