From 710793af9099818b72331821b8448c18b813eaba Mon Sep 17 00:00:00 2001 From: "Gisa M. Caleb Pacifique" Date: Fri, 21 Jun 2024 13:55:23 +0200 Subject: [PATCH] feat-OTP-2FA --- src/Routes/Router.tsx | 26 +++- src/assets/iconoir_xmark-circle (2).svg | 3 + src/components/Menu/DesktopMenu.tsx | 7 +- .../SuspendedAccount/SuspendedAccount.tsx | 20 +++ .../Authentication/GoogleLoginSuccess.tsx | 27 +++- src/pages/Authentication/Login.tsx | 29 +++- src/pages/Authentication/OtpPage.tsx | 131 ++++++++++++++++++ src/redux/reducers/authReducer.ts | 3 +- src/redux/reducers/rootReducer.ts | 2 + src/redux/reducers/userReducer.ts | 21 +++ .../components/Suspendend/Supended.test.tsx | 26 ++++ .../GoogleLoginSuccess.test.tsx | 45 +++++- .../pages/Authentication/LoginAction.test.tsx | 104 ++++++++++++++ .../Authentication/verifyEmailAction.test.tsx | 102 ++++++++++++++ src/test/pages/otp.test.tsx | 105 ++++++++++++++ src/types/registerType.ts | 1 + vite.config.ts | 2 +- 17 files changed, 632 insertions(+), 22 deletions(-) create mode 100644 src/assets/iconoir_xmark-circle (2).svg create mode 100644 src/components/SuspendedAccount/SuspendedAccount.tsx create mode 100644 src/pages/Authentication/OtpPage.tsx create mode 100644 src/redux/reducers/userReducer.ts create mode 100644 src/test/components/Suspendend/Supended.test.tsx create mode 100644 src/test/pages/Authentication/LoginAction.test.tsx create mode 100644 src/test/pages/Authentication/verifyEmailAction.test.tsx create mode 100644 src/test/pages/otp.test.tsx diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index b08b434..6f9e5dc 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -10,6 +10,8 @@ import { useSelector } from 'react-redux'; import { RootState } from '../redux/store'; import GoogleLoginSuccess from '../pages/Authentication/GoogleLoginSuccess'; import { useJwt } from 'react-jwt'; +import OtpPage from '../pages/Authentication/OtpPage'; +import SuspendedAccount from '../components/SuspendedAccount/SuspendedAccount'; const Router = () => { const { userToken } = useSelector((state: RootState) => state.auth); @@ -65,7 +67,7 @@ const Router = () => { } /> @@ -76,6 +78,28 @@ const Router = () => { } /> + + + {userToken && } + + + } + /> + + + + {userToken && isAdmin && } + {userToken && isVendor && } + {userToken && isBuyer && } + + } + /> ); }; diff --git a/src/assets/iconoir_xmark-circle (2).svg b/src/assets/iconoir_xmark-circle (2).svg new file mode 100644 index 0000000..bb4d5ff --- /dev/null +++ b/src/assets/iconoir_xmark-circle (2).svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Menu/DesktopMenu.tsx b/src/components/Menu/DesktopMenu.tsx index 1819e2b..98ec267 100644 --- a/src/components/Menu/DesktopMenu.tsx +++ b/src/components/Menu/DesktopMenu.tsx @@ -2,12 +2,17 @@ import React from 'react'; import { AppDispatch } from '../../redux/store'; import { clearCredentials } from '../../redux/reducers/authReducer'; import { useDispatch } from 'react-redux'; +import { clearUser } from '../../redux/reducers/userReducer'; +import { useNavigate } from 'react-router-dom'; function DesktopMenu() { + const navigate = useNavigate(); const dispatch = useDispatch(); const logoutHandler = () => { - dispatch(clearCredentials('')); + dispatch(clearCredentials()); + dispatch(clearUser()); + navigate('/'); }; return ( diff --git a/src/components/SuspendedAccount/SuspendedAccount.tsx b/src/components/SuspendedAccount/SuspendedAccount.tsx new file mode 100644 index 0000000..7f539a2 --- /dev/null +++ b/src/components/SuspendedAccount/SuspendedAccount.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import iconXMark from '../../assets/iconoir_xmark-circle (2).svg'; + +function SuspendedAccount() { + return ( +
+ +

Your account has been suspended!

+

+ Please contact our support team at{' '} + + knights@andela.com + {' '} + for more information. +

+
+ ); +} + +export default SuspendedAccount; diff --git a/src/pages/Authentication/GoogleLoginSuccess.tsx b/src/pages/Authentication/GoogleLoginSuccess.tsx index 24a270d..0ed7b37 100644 --- a/src/pages/Authentication/GoogleLoginSuccess.tsx +++ b/src/pages/Authentication/GoogleLoginSuccess.tsx @@ -4,27 +4,44 @@ import { useDispatch } from 'react-redux'; import { AppDispatch } from '../../redux/store'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { clearCredentials, setCredentials } from '../../redux/reducers/authReducer'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { setUser } from '../../redux/reducers/userReducer'; const GoogleLoginSuccess = () => { const dispatch = useDispatch(); + const navigate = useNavigate(); + useEffect(() => { axios .get(`${import.meta.env.VITE_APP_API_URL}/user/login/success`, { withCredentials: true }) .then((response) => { - dispatch(setCredentials(response.data.data.token)); + if (response.data?.data.message === 'Please provide the OTP sent to your email or phone') { + dispatch(setUser(response.data.data!.email)); + navigate('/otp-verficaton'); + } else { + dispatch(setCredentials(response.data.data.token)); + } }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars .catch((error) => { - dispatch(clearCredentials('')); + if (error.response?.data?.message === 'Your account has been suspended') { + navigate('/suspended-account'); + } else { + toast.error(error.response?.data?.message || 'Unexpected error', { + duration: 4000 + }); + navigate('/'); + } + dispatch(clearCredentials()); }); - }, [dispatch]); + }, [dispatch, navigate]); return (
- Signing in with Google + Signing in with Google Please wait a moment.
); diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index cd44cd2..e909d0e 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -7,9 +7,10 @@ import { RootState, AppDispatch } from '../../redux/store'; import toast from 'react-hot-toast'; import { loginUser } from '../../redux/actions/loginAction'; import google from '../../images/google.png'; -import { setCredentials } from '../../redux/reducers/authReducer'; -import { Link } from 'react-router-dom'; +import { clearCredentials, setCredentials } from '../../redux/reducers/authReducer'; +import { Link, useNavigate } from 'react-router-dom'; import { resetState } from '../../redux/reducers/loginReducer'; +import { setUser } from '../../redux/reducers/userReducer'; export interface DecodedToken { id: string; @@ -29,6 +30,7 @@ function Login() { reset } = useForm(); + const navigate = useNavigate(); const dispatch = useDispatch(); const { login: loginResponse, loading, error } = useSelector((state: RootState) => state.login); @@ -38,16 +40,31 @@ function Login() { useEffect(() => { if (error) { - toast.error(error); + toast.error(error, { + duration: 4000 + }); + if (error === 'Your account has been suspended') { + navigate('/suspended-account'); + } else { + navigate('/'); + } + dispatch(clearCredentials()); dispatch(resetState()); } if (loginResponse) { - toast.success(loginResponse.data!.message); + toast.success(loginResponse.data!.message, { + duration: 4000 + }); + if (loginResponse.data!.message === 'Please provide the OTP sent to your email or phone') { + dispatch(setUser(loginResponse.data!.email)); + navigate('/otp-verficaton'); + } else { + dispatch(setCredentials(loginResponse.data?.token)); + } reset(); - dispatch(setCredentials(loginResponse.data?.token)); dispatch(resetState()); } - }, [error, loginResponse, dispatch, reset]); + }, [error, navigate, loginResponse, dispatch, reset]); const googleAuth = () => { window.open(`${import.meta.env.VITE_APP_API_URL}/user/google-auth`, '_self'); diff --git a/src/pages/Authentication/OtpPage.tsx b/src/pages/Authentication/OtpPage.tsx new file mode 100644 index 0000000..3979b29 --- /dev/null +++ b/src/pages/Authentication/OtpPage.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { useState, ChangeEvent, FormEvent } from 'react'; +import { toast } from 'react-hot-toast'; +import axios from 'axios'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppDispatch, RootState } from '../../redux/store'; +import { setCredentials } from '../../redux/reducers/authReducer'; + +const OtpPage = () => { + interface OtpForm { + first: string; + second: string; + third: string; + fourth: string; + fifth: string; + sixth: string; + } + + const initialForm = { + first: '', + second: '', + third: '', + fourth: '', + fifth: '', + sixth: '' + }; + + const [otpForm, setOtpForm] = useState(initialForm); + const [loading, setLoading] = useState(false); + const { email } = useSelector((state: RootState) => state.currentUser); + + const dispatch = useDispatch(); + const fields = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'] as const; + + const handleChange = (event: ChangeEvent) => { + const { name, value } = event.target; + if (/^\d?$/.test(value)) { + setOtpForm((prevFormData) => ({ ...prevFormData, [name]: value })); + } + }; + + const handleError = (error: any) => { + axios.isAxiosError(error) + ? toast.error(error.response?.data?.message || 'Server Down. Try again later!') + : toast.error((error as Error).message); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + try { + const allFieldsFilled = fields.every((field) => otpForm[field]); + if (!allFieldsFilled) { + toast.error('Fill all the OTP fields.'); + return; + } + + const otpCode = Object.values(otpForm).join(''); + if (!email) throw Error('Something went wrong, Login again'); + const reqData = { email, otp: otpCode }; + setLoading(true); + const response = await axios.post(`${import.meta.env.VITE_APP_API_URL}/user/verify-otp`, reqData); + + if (response.status === 200) { + dispatch(setCredentials(response.data.data.token)); + } + } catch (error) { + handleError(error); + } finally { + setOtpForm(initialForm); + setLoading(false); + } + }; + + const resendOTP = async () => { + try { + if (!email) throw Error('Something went wrong, Login again'); + const reqData = { email }; + setLoading(true); + const response = await axios.post(`${import.meta.env.VITE_APP_API_URL}/user/resend-otp`, reqData); + + if (response.data.data.message === 'OTP sent successfully') toast.success('OTP resent successfully'); + } catch (error) { + handleError(error); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+
+

Verify Your Identity

+

+ Protecting your account is our priority. Please confirm your identity by providing the code sent to your + email/phone. +

+ +
+
+ {fields.map((field) => ( + + ))} +
+ +
+

+ It may take a minute to receive verification message. Haven’t received it yet? + + Resend + +

+
+
+ + ); +}; + +export default OtpPage; diff --git a/src/redux/reducers/authReducer.ts b/src/redux/reducers/authReducer.ts index 6fda22b..08fffc4 100644 --- a/src/redux/reducers/authReducer.ts +++ b/src/redux/reducers/authReducer.ts @@ -34,8 +34,7 @@ const authSlice = createSlice({ localStorage.removeItem('userToken'); } }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - clearCredentials: (state, action) => { + clearCredentials: (state) => { state.userToken = null; localStorage.removeItem('userToken'); } diff --git a/src/redux/reducers/rootReducer.ts b/src/redux/reducers/rootReducer.ts index 98385d0..59cf93f 100644 --- a/src/redux/reducers/rootReducer.ts +++ b/src/redux/reducers/rootReducer.ts @@ -2,11 +2,13 @@ import { combineReducers } from '@reduxjs/toolkit'; import productReducer from './productReducer'; import registerReducer from './registerReducer'; import verifyEmailReducer from './verifyEmailReducer'; +import userReducer from './userReducer'; import loginReducer from './loginReducer'; import authReducer from './authReducer'; const rootReducer = combineReducers({ auth: authReducer, + currentUser: userReducer, register: registerReducer, login: loginReducer, verifyEmail: verifyEmailReducer, diff --git a/src/redux/reducers/userReducer.ts b/src/redux/reducers/userReducer.ts new file mode 100644 index 0000000..e23cacf --- /dev/null +++ b/src/redux/reducers/userReducer.ts @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + email: '' +}; + +const currentUserSlice = createSlice({ + name: 'currentUser', + initialState: initialState, + reducers: { + setUser: (state, action) => { + state.email = action.payload; + }, + clearUser: (state) => { + state.email = ''; + } + } +}); + +export const { setUser, clearUser } = currentUserSlice.actions; +export default currentUserSlice.reducer; diff --git a/src/test/components/Suspendend/Supended.test.tsx b/src/test/components/Suspendend/Supended.test.tsx new file mode 100644 index 0000000..27f9116 --- /dev/null +++ b/src/test/components/Suspendend/Supended.test.tsx @@ -0,0 +1,26 @@ +// SuspendedAccount.test.jsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import SuspendedAccount from '../../../components/SuspendedAccount/SuspendedAccount'; + +describe('SuspendedAccount', () => { + it('renders the SuspendedAccount component', () => { + render(); + + const imgElement = screen.getByAltText(''); + expect(imgElement).toBeInTheDocument(); + + const suspendedTextElement = screen.getByText('Your account has been suspended!', { + selector: 'p' + }); + expect(suspendedTextElement).toBeInTheDocument(); + + const supportTextElement = screen.getByText(/Please contact our support team at/i); + expect(supportTextElement).toBeInTheDocument(); + + const supportLinkElement = screen.getByText('knights@andela.com'); + expect(supportLinkElement).toBeInTheDocument(); + expect(supportLinkElement).toHaveAttribute('href', 'mailto:knights@andela.com'); + }); +}); diff --git a/src/test/pages/Authentication/GoogleLoginSuccess.test.tsx b/src/test/pages/Authentication/GoogleLoginSuccess.test.tsx index 59785d7..a204975 100644 --- a/src/test/pages/Authentication/GoogleLoginSuccess.test.tsx +++ b/src/test/pages/Authentication/GoogleLoginSuccess.test.tsx @@ -1,13 +1,44 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, Mocked } from 'vitest'; import { Provider } from 'react-redux'; import store from '../../../redux/store'; import { MemoryRouter } from 'react-router-dom'; import GoogleLoginSuccess from '../../../pages/Authentication/GoogleLoginSuccess'; +import axios from 'axios'; + +vi.mock('axios'); +const mockedAxios = axios as Mocked; + +describe('GoogleLoginSuccess', () => { + it('renders the GoogleLogin component', async () => { + mockedAxios.get.mockResolvedValue({ + data: { data: { message: 'Please provide the OTP sent to your email or phone' } } + }); + + render( + + + + + + ); + + await waitFor(() => { + const spanElement = screen.getByText('Signing in with Google'); + expect(spanElement).toBeInTheDocument(); + }); + }); + + it('handles error from axios', async () => { + mockedAxios.get.mockRejectedValue({ + response: { + data: { + message: 'Your account has been suspended' + } + } + }); -describe('GoogleLogin', () => { - it('renders the GoogleLogin component', () => { render( @@ -15,7 +46,9 @@ describe('GoogleLogin', () => { ); - const spanElement = screen.getByText('Signing in with Google'); - expect(spanElement).toBeInTheDocument(); + + await waitFor(() => { + expect(mockedAxios.get).toHaveBeenCalled(); + }); }); }); diff --git a/src/test/pages/Authentication/LoginAction.test.tsx b/src/test/pages/Authentication/LoginAction.test.tsx new file mode 100644 index 0000000..dc0a1e1 --- /dev/null +++ b/src/test/pages/Authentication/LoginAction.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, afterEach, Mocked } from 'vitest'; +import axios from 'axios'; +import { loginUser } from '../../../redux/actions/loginAction'; +import { LoginData, LoginResponse } from '../../../types/registerType'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; + +vi.mock('axios'); +const mockAxios = axios as Mocked; + +const mockLoginData: LoginData = { + email: 'test@example.com', + password: 'password123' +}; + +const createTestStore = (extraReducers: any) => { + const slice = createSlice({ + name: 'login', + initialState: { + login: null, + loading: false, + error: null + }, + reducers: {}, + extraReducers + }); + + return configureStore({ + reducer: slice.reducer + }); +}; + +describe('loginUser async thunk', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('dispatches the correct actions on successful login', async () => { + const mockResponse: LoginResponse = { status: '' }; + mockAxios.post.mockResolvedValueOnce({ data: mockResponse }); + + const store = createTestStore((builder: any) => { + builder + .addCase(loginUser.pending, (state: any) => { + state.loading = true; + }) + .addCase(loginUser.fulfilled, (state: any, action: any) => { + state.loading = false; + state.login = action.payload; + }) + .addCase(loginUser.rejected, (state: any, action: any) => { + state.loading = false; + state.error = action.payload.message; + }); + }); + + const dispatch = store.dispatch as ThunkDispatch; + await dispatch(loginUser(mockLoginData)); + + const state = store.getState(); + + expect(mockAxios.post).toHaveBeenCalledWith(`${import.meta.env.VITE_APP_API_URL}/user/login`, mockLoginData); + expect(state.loading).toBe(false); + expect(state.login).toEqual(mockResponse); + expect(state.error).toBeNull(); + }); + + it('dispatches the correct actions on login failure', async () => { + const mockError = { + response: { + data: { + message: 'Invalid credentials' + } + } + }; + mockAxios.post.mockRejectedValueOnce(mockError); + + const store = createTestStore((builder: any) => { + builder + .addCase(loginUser.pending, (state: any) => { + state.loading = true; + }) + .addCase(loginUser.fulfilled, (state: any, action: any) => { + state.loading = false; + state.login = action.payload; + }) + .addCase(loginUser.rejected, (state: any, action: any) => { + state.loading = false; + state.error = action.payload.message; + }); + }); + + const dispatch = store.dispatch as ThunkDispatch; + await dispatch(loginUser(mockLoginData)); + + const state = store.getState(); + + expect(mockAxios.post).toHaveBeenCalledWith(`${import.meta.env.VITE_APP_API_URL}/user/login`, mockLoginData); + expect(state.loading).toBe(false); + expect(state.login).toBeNull(); + expect(state.error).toBe('Invalid credentials'); + }); +}); diff --git a/src/test/pages/Authentication/verifyEmailAction.test.tsx b/src/test/pages/Authentication/verifyEmailAction.test.tsx new file mode 100644 index 0000000..c979a85 --- /dev/null +++ b/src/test/pages/Authentication/verifyEmailAction.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach, Mocked } from 'vitest'; +import axios from 'axios'; +import { verifyUser } from '../../../redux/actions/verifyingEmailAction'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { VerifyEmailResponse } from '../../../types/verifyEmailType'; + +vi.mock('axios'); +const mockAxios = axios as Mocked; + +const createTestStore = (extraReducers: any) => { + const slice = createSlice({ + name: 'verifyEmail', + initialState: { + verify: null, + loading: false, + error: null + }, + reducers: {}, + extraReducers + }); + + return configureStore({ + reducer: slice.reducer + }); +}; + +// Tests +describe('verifyUser async thunk', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockToken = 'mock-token'; + + it('dispatches the correct actions on successful email verification', async () => { + const mockResponse: VerifyEmailResponse = { message: 'Email verified successfully' }; + mockAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + const store = createTestStore((builder: any) => { + builder + .addCase(verifyUser.pending, (state: any) => { + state.loading = true; + }) + .addCase(verifyUser.fulfilled, (state: any, action: any) => { + state.loading = false; + state.verify = action.payload; + }) + .addCase(verifyUser.rejected, (state: any, action: any) => { + state.loading = false; + state.error = action.payload.error; + }); + }); + + const dispatch = store.dispatch as ThunkDispatch; + await dispatch(verifyUser(mockToken)); + + const state = store.getState(); + + expect(mockAxios.get).toHaveBeenCalledWith(`${import.meta.env.VITE_APP_API_URL}/user/verify/${mockToken}`); + expect(state.loading).toBe(false); + expect(state.verify).toEqual(mockResponse); + expect(state.error).toBeNull(); + }); + + it('dispatches the correct actions on verification failure', async () => { + const mockError = { + response: { + data: { + error: 'Invalid or expired token' + } + } + }; + mockAxios.get.mockRejectedValueOnce(mockError); + + const store = createTestStore((builder: any) => { + builder + .addCase(verifyUser.pending, (state: any) => { + state.loading = true; + }) + .addCase(verifyUser.fulfilled, (state: any, action: any) => { + state.loading = false; + state.verify = action.payload; + }) + .addCase(verifyUser.rejected, (state: any, action: any) => { + state.loading = false; + state.error = action.payload.error; + }); + }); + + const dispatch = store.dispatch as ThunkDispatch; + await dispatch(verifyUser(mockToken)); + + const state = store.getState(); + + expect(mockAxios.get).toHaveBeenCalledWith(`${import.meta.env.VITE_APP_API_URL}/user/verify/${mockToken}`); + expect(state.loading).toBe(false); + expect(state.verify).toBeNull(); + expect(state.error).toBe('Invalid or expired token'); + }); +}); diff --git a/src/test/pages/otp.test.tsx b/src/test/pages/otp.test.tsx new file mode 100644 index 0000000..5c63abb --- /dev/null +++ b/src/test/pages/otp.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Provider } from 'react-redux'; +import store from '../../redux/store'; +import Otp from '../../pages/Authentication/OtpPage'; + +describe('OtpPage', () => { + it('Renders the OtpPage component with all expected elements', () => { + render( + + + + ); + + const headingElement = screen.getByText('Verify Your Identity', { selector: 'h2' }); + expect(headingElement).toBeInTheDocument(); + + const descriptionElement = screen.getByText(/Protecting your account is our priority/i, { + selector: 'p' + }); + expect(descriptionElement).toBeInTheDocument(); + + // Verify OTP input fields + const otpInputs = screen.getAllByRole('textbox'); + expect(otpInputs).toHaveLength(6); + otpInputs.forEach((input) => expect(input).toHaveAttribute('maxLength', '1')); + + const verifyButton = screen.getByRole('button', { name: /Verify/i }); + expect(verifyButton).toBeInTheDocument(); + + const resendLink = screen.getByText(/Resend/i, { selector: 'span' }); + expect(resendLink).toBeInTheDocument(); + }); + + it('Shows an error if not all OTP fields are filled when submitting', () => { + render( + + + + ); + + const verifyButton = screen.getByRole('button', { name: 'Verify' }); + + fireEvent.click(verifyButton); + }); +}); + +describe('OtpPage handleChange', () => { + it('Updates state correctly when valid digit is entered', () => { + render( + + + + ); + const otpInputs = screen.getAllByRole('textbox'); + + otpInputs.forEach((input) => { + expect(input).toHaveValue(''); + }); + + // Simulate entering a digit in each OTP input + otpInputs.forEach((input, index) => { + fireEvent.change(input, { target: { value: `${index + 1}` } }); + expect(input).toHaveValue(`${index + 1}`); + }); + }); + + it('Does not update state when non-digit is entered', () => { + render( + + + + ); + + const otpInputs = screen.getAllByRole('textbox'); + + // Ensure the inputs are initially empty + otpInputs.forEach((input) => { + expect(input).toHaveValue(''); + }); + + // Simulate entering a non-digit character + otpInputs.forEach((input) => { + fireEvent.change(input, { target: { value: 'a' } }); + expect(input).toHaveValue(''); + }); + }); + + it('Does not update state when more than one digit is entered', () => { + render( + + + + ); + + const otpInput = screen.getAllByRole('textbox')[0]; + + expect(otpInput).toHaveValue(''); + + // Simulate entering more than one digit + fireEvent.change(otpInput, { target: { value: '12' } }); + expect(otpInput).toHaveValue(''); // Expect value to remain empty + }); +}); diff --git a/src/types/registerType.ts b/src/types/registerType.ts index 0f81e73..5632401 100644 --- a/src/types/registerType.ts +++ b/src/types/registerType.ts @@ -26,5 +26,6 @@ export type LoginResponse = { data?: { message: string; token: string; + email?: string; }; }; diff --git a/vite.config.ts b/vite.config.ts index d303877..9e46a26 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: './src/test/setup.ts', coverage: { - exclude: [...configDefaults.exclude, '**.**js'] + exclude: [...configDefaults.exclude, '**.**js', '**/**.d.ts'] } }, assetsInclude: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.svg']