feat: enable user to update his name and data (#332)

* feat: enable user to update his name and data

issue #307

* docs: add JSDocs

* test: fix sidebar tests

* test: fix test cases

* test: add account test cases

* test: add user controller and auth service tests
This commit is contained in:
2025-03-05 12:05:14 -03:00
committed by GitHub
parent 6e6994de46
commit 381a25d92f
34 changed files with 875 additions and 238 deletions
+31
View File
@@ -14,6 +14,7 @@
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"bootstrap": "^5.3.3",
"dompurify": "^3.2.4",
"i18next": "^24.2.2",
"react": "^19.0.0",
"react-bootstrap": "^2.10.9",
@@ -2315,6 +2316,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
@@ -3648,6 +3656,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -9923,6 +9940,12 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"dev": true
},
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
@@ -10801,6 +10824,14 @@
"csstype": "^3.0.2"
}
},
"dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"requires": {
"@types/trusted-types": "^2.0.7"
}
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"bootstrap": "^5.3.3",
"dompurify": "^3.2.4",
"i18next": "^24.2.2",
"react": "^19.0.0",
"react-bootstrap": "^2.10.9",
@@ -9,12 +9,19 @@ import i18n from '../../i18n';
const authContextMock = {
signed: true,
user: undefined,
user: {
userId: 1,
name: 'Ricardo',
email: 'ricardo@campos.com',
admin: false,
createdAt: new Date()
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false
isAdmin: false,
updateUser: vi.fn(),
};
describe('Sidebar Component', () => {
@@ -32,7 +39,7 @@ describe('Sidebar Component', () => {
it('should render the Sidebar component', () => {
const { getByText } = renderSidebar();
expect(getByText('Ricardo Campos')).toBeDefined();
expect(getByText('Ricardo')).toBeDefined();
expect(getByText('Main Menu')).toBeDefined();
expect(getByText('Preferences')).toBeDefined();
});
+43 -13
View File
@@ -11,10 +11,12 @@ import ApiConfig from '../../api-service/apiConfig';
vi.mock('../../api-service/api');
const changeLanguageMock = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
changeLanguage: vi.fn(),
changeLanguage: changeLanguageMock,
},
t: (key: string) => key,
}),
@@ -27,12 +29,19 @@ vi.mock('react-i18next', () => ({
const authContextMock = {
signed: true,
user: { email: 'test@example.com' },
user: {
userId: 1,
name: 'Ricardo',
email: 'test@example.com',
admin: false,
createdAt: new Date()
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false
isAdmin: false,
updateUser: vi.fn()
};
describe('Account Component', () => {
@@ -50,18 +59,15 @@ describe('Account Component', () => {
it('should render the Account component', () => {
const { getByText } = renderAccount();
expect(getByText('account_my_account_tittle')).toBeDefined();
expect(getByText('account_my_account_hello')).toBeDefined();
expect(getByText('account_my_account_logged')).toBeDefined();
expect(getByText('test@example.com')).toBeDefined();
expect(getByText('Change your info')).toBeDefined();
});
// it('should change language when a language button is clicked', () => {
// const { getByText } = renderAccount();
// const languageButton = getByText('account_app_lang_tittle');
// fireEvent.click(languageButton);
// expect(changeLanguageMock).toHaveBeenCalled();
// });
it('should change language when a language button is clicked', () => {
const { getByTestId } = renderAccount();
const languageButton = getByTestId('language-button-pt_br');
fireEvent.click(languageButton);
expect(changeLanguageMock).toHaveBeenCalled();
});
it('should show alert when delete button is clicked', () => {
const { getByText } = renderAccount();
@@ -82,4 +88,28 @@ describe('Account Component', () => {
expect(authContextMock.signOut).toHaveBeenCalled();
});
});
it('should submit the form with correct patchPayload', async () => {
const mockPatchJSON = vi.spyOn(api, 'patchJSON').mockResolvedValue(authContextMock.user);
const { getByLabelText, getByText, getByTestId } = renderAccount();
fireEvent.change(getByLabelText(/First name/i), { target: { value: 'Jane' } });
fireEvent.change(getByLabelText(/Email/i), { target: { value: 'jane.doe@example.com' } });
fireEvent.change(getByTestId('account-password-one'), { target: { value: 'password123' } });
fireEvent.change(getByLabelText(/Repeat password/i), { target: { value: 'password123' } });
fireEvent.click(getByText(/Save profile information/i));
await waitFor(() => {
expect(mockPatchJSON).toHaveBeenCalledWith(expect.any(String), {
name: 'Jane',
email: 'jane.doe@example.com',
password: 'password123',
passwordAgain: 'password123',
});
});
mockPatchJSON.mockRestore();
});
});
+9 -2
View File
@@ -28,12 +28,19 @@ vi.mock('react-i18next', () => ({
const authContextMock = {
signed: true,
user: { email: 'test@example.com' },
user: {
userId: 1,
name: 'Ricardo',
email: 'test@example.com',
admin: false,
createdAt: new Date()
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false
isAdmin: false,
updateUser: vi.fn(),
};
describe('TaskAdd Component', () => {
+2 -1
View File
@@ -16,8 +16,9 @@ const ApiConfig = {
homeUrl: `${server}/rest/home`,
notesUrl: `${server}/rest/notes`
notesUrl: `${server}/rest/notes`,
userUrl: `${server}/rest/users`
};
export default ApiConfig;
@@ -13,7 +13,7 @@ type Series = {
};
const data: Series[] = [
{
label: 'Commpleted tasks',
label: 'Completed tasks',
data: [
{
date: 'S', // Sunday
+88
View File
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import { Col, Form, InputGroup, Row } from 'react-bootstrap';
import * as Icons from 'react-bootstrap-icons';
type IconName = keyof typeof Icons;
interface Props {
labelText: string;
iconName: IconName;
required: boolean;
type?: string;
name: string;
placeholder?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
data_testid?: string;
}
/**
* FormInput component renders a form input with a label, icon, and optional password toggle.
*
* @param {Props} props - The properties for the FormInput component.
* @param {string} props.labelText - The text for the form label.
* @param {IconName} props.iconName - The name of the icon to display.
* @param {boolean} props.required - Whether the input is required.
* @param {string} [props.type] - The type of the input (e.g., 'text', 'password').
* @param {string} props.name - The name of the input.
* @param {string} [props.placeholder] - The placeholder text for the input.
* @param {string} props.value - The value of the input.
* @param {(e: React.ChangeEvent<HTMLInputElement>) void} props.onChange - The change event handler for the input.
* @returns {React.ReactNode} The rendered FormInput component.
*/
function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
const [showingPwd, setShowingPwd] = useState<boolean>(false);
const [formType, setFormType] = useState<string>(props.type ? props.type : 'text');
const Icon = Icons[props.iconName];
/**
* Toggle the password visibility.
*
* @param {React.MouseEvent<Element, MouseEvent>} e the click event.
*/
const toggleShowPassword = (e: React.MouseEvent<Element, MouseEvent>): void => {
e.preventDefault();
e.stopPropagation();
console.log('clicked to show password');
setShowingPwd((prevValue: boolean) => !prevValue);
setFormType((previous: string) => previous === 'text' ? 'password' : 'text');
};
useEffect(() => {}, [showingPwd, formType]);
return (
<Row>
<Col>
<Form.Group className="mb-3" controlId={`form-input-${props.name}`}>
<Form.Label>
{props.labelText}
{props.type && props.type === 'password' && (
<small>
<a href="#" onClick={toggleShowPassword}>
{showingPwd ? ' (Hide)' : ' (Show)'}
</a>
</small>
)}
</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<Icon />
</InputGroup.Text>
<Form.Control
required={props.required}
type={formType}
name={props.name}
placeholder={props.placeholder ? props.placeholder : ''}
value={props.value}
onChange={props.onChange}
data-testid={props.data_testid}
/>
</InputGroup>
</Form.Group>
</Col>
</Row>
);
}
export default FormInput;
+25 -4
View File
@@ -1,4 +1,4 @@
import React, { useContext, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { Nav } from 'react-bootstrap';
import { NavLink } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -8,18 +8,32 @@ import NavButton from '../NavButton';
import { env } from '../../env';
import './style.css';
/**
* Sidebar component renders the sidebar navigation menu.
*
* @returns {React.ReactNode} The rendered Sidebar component.
*/
function Sidebar(): React.ReactNode {
const { signOut } = useContext(AuthContext);
const { signOut, user } = useContext(AuthContext);
const { t } = useTranslation();
const build = `Build: ${env.VITE_BUILD}`;
const [current, setCurrent] = useState<string>('/home');
// Note: when selected, change class to plus-jakarta-sans-thin and add background
const goOut = () => {
/**
* Handles the sign-out action.
*/
const goOut = (): void => {
signOut();
};
/**
* Gets the color for the selected navigation link.
*
* @param {string} path - The path of the navigation link.
* @returns {string} The color for the selected navigation link.
*/
const getSelectedColor = (path: string): string => {
if (path === current) {
return '#4CD964';
@@ -27,15 +41,22 @@ function Sidebar(): React.ReactNode {
return '#6A8996';
};
/**
* Handles the navigation link click event.
*
* @param {string} menu - The menu path.
*/
const navLinkClicked = (menu: string): void => {
setCurrent(menu);
};
useEffect(() => {}, [user]);
return (
<div className="d-flex flex-column vh-100 bg-light sidebar">
<div className="sidebar-header plus-jakarta-sans-bold">
<img width="45" src={UserIcon} alt="User icon" />
<span>Ricardo Campos</span>
<span>{user?.name ? user?.name : 'User'}</span>
</div>
<div className="header-spacer"></div>
+3 -2
View File
@@ -1,14 +1,15 @@
import { createContext } from 'react';
import { User } from '../types/User';
import { UserResponse } from '../types/UserResponse';
export interface AuthContextData {
signed: boolean;
user: User | undefined;
user: UserResponse | undefined;
checkCurrentAuthUser: (pathname: string) => Promise<void>;
signIn: (email: string, password: string) => Promise<string>;
signOut: () => void;
register: (email: string, password: string) => Promise<string>;
isAdmin: boolean;
updateUser: (userUpdated: UserResponse) => void;
}
const AuthContext = createContext<AuthContextData>({} as AuthContextData);
+30 -12
View File
@@ -1,10 +1,10 @@
import React, { useMemo, useState } from 'react';
import { User } from '../types/User';
import AuthContext, { AuthContextData } from './AuthContext';
import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../app-constants/app-constants';
import { SigninResponse } from '../types/SigninResponse';
import api from '../api-service/api';
import ApiConfig from '../api-service/apiConfig';
import { UserResponse } from '../types/UserResponse';
interface Props {
children: React.ReactNode;
@@ -12,7 +12,7 @@ interface Props {
const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Props) => {
const [signed, setSigned] = useState<boolean>(false);
const [user, setUser] = useState<User | undefined>();
const [user, setUser] = useState<UserResponse | undefined>();
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [intervalInstance, setIntervalInstance] = useState<NodeJS.Timeout | null>(null);
@@ -44,7 +44,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
return undefined;
};
const updateUserSession = (userPriv: User | null, bearerToken: string): User => {
const updateUserSession = (userPriv: UserResponse | null, bearerToken: string): UserResponse | null => {
if (userPriv) {
localStorage.setItem(USER_DATA, JSON.stringify(userPriv));
}
@@ -59,14 +59,16 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
return JSON.parse(savedUser);
}
return { email: 'undefined' };
return null;
};
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
const bearerToken: SigninResponse | undefined = await fetchCurrentSession(pathname);
if (bearerToken && bearerToken.token) {
const userLocal = updateUserSession(null, bearerToken.token);
setUser(userLocal);
if (userLocal) {
setUser(userLocal);
}
}
};
@@ -74,8 +76,12 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
try {
const payload = { email, password };
const registerResponse: SigninResponse = await api.putJSON(ApiConfig.registerUrl, payload);
const currentUser: User = {
email
const currentUser: UserResponse = {
userId: registerResponse.userId,
name: registerResponse.name,
email: registerResponse.email,
admin: registerResponse.admin,
createdAt: new Date(registerResponse.createdAt)
};
setSigned(true);
@@ -95,8 +101,12 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
try {
const payload = { email, password };
const registerResponse: SigninResponse = await api.postJSON(ApiConfig.signInUrl, payload);
const currentUser: User = {
email
const currentUser: UserResponse = {
userId: registerResponse.userId,
name: registerResponse.name,
email: registerResponse.email,
admin: registerResponse.admin,
createdAt: new Date(registerResponse.createdAt)
};
setSigned(true);
@@ -126,7 +136,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
const bearerToken: SigninResponse | undefined = await fetchCurrentSession('/');
if (bearerToken) {
const userLocal = updateUserSession(null, bearerToken.token);
setUser(userLocal);
if (userLocal) {
setUser(userLocal);
}
}
return Promise.resolve();
};
@@ -148,6 +160,11 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
setIntervalInstance(instance);
}
const updateUser = (userUpdated: UserResponse): void => {
setUser(userUpdated);
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
};
const contextValue: AuthContextData = useMemo(() => ({
signed,
user,
@@ -155,8 +172,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
signIn,
signOut,
register,
isAdmin
}), [signed, user, checkCurrentAuthUser, signIn, signOut, register, isAdmin]);
isAdmin,
updateUser
}), [signed, user, checkCurrentAuthUser, signIn, signOut, register, isAdmin, updateUser]);
return (
<AuthContext.Provider value={contextValue}>
+16 -1
View File
@@ -176,4 +176,19 @@ a:hover, .btn-link:hover {
.about-title {
font-size: 20px;
}
}
.home-hello {
font-size: 28px;
color: #233C46;
}
.home-subtitle {
color: #A1A1A1;
font-size: 14px;
line-height: 21px;
}
.home-productive {
color: #4CD964;
}
+5
View File
@@ -1,3 +1,8 @@
export type SigninResponse = {
userId: number;
name: string;
email: string;
admin: boolean;
createdAt: Date;
token: string;
};
-3
View File
@@ -1,3 +0,0 @@
export type User = {
email: string;
};
+6
View File
@@ -0,0 +1,6 @@
export type UserPatchRequest = {
name: string | null;
email: string | null;
password: string | null;
passwordAgain: string | null;
};
+7
View File
@@ -0,0 +1,7 @@
export type UserResponse = {
userId: number;
name: string | null;
email: string;
admin: boolean;
createdAt: Date;
};
+226 -39
View File
@@ -1,12 +1,18 @@
import React, { useContext, useState } from 'react';
import { Alert, Button, Card, Col, Container, Row } from 'react-bootstrap';
import React, { useContext, useEffect, useState } from 'react';
import { Alert, Button, Col, Container, Form, Row } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { FileText, Person, ShieldCheck } from 'react-bootstrap-icons';
import { FileText, Person } from 'react-bootstrap-icons';
import DOMPurify from 'dompurify';
import { clearStorage, setDefaultLang } from '../../storage-service/storage';
import AuthContext from '../../context/AuthContext';
import { LangAvailable, languages } from '../../constants/languages_available';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import FormInput from '../../components/FormInput';
import { translateServerResponse } from '../../utils/TranslatorUtils';
import { UserPatchRequest } from '../../types/UserPatchRequest';
import { UserResponse } from '../../types/UserResponse';
import './styles.css';
/**
* Account page component.
@@ -16,15 +22,30 @@ import ApiConfig from '../../api-service/apiConfig';
* @returns {React.ReactNode} The Account page component.
*/
function Account(): React.ReactNode {
const { signOut, user } = useContext(AuthContext);
const { signOut, user, updateUser } = useContext(AuthContext);
const { i18n, t } = useTranslation();
const [showAlert, setShowAlert] = useState<boolean>(false);
const [validated, setValidated] = useState<boolean>(false);
const [formInvalid, setFormInvalid] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [userName, setUserName] = useState<string>('');
const [userEmail, setUserEmail] = useState<string>('');
const [userPassword, setUserPassword] = useState<string>('');
const [userPasswordAgain, setUserPasswordAgain] = useState<string>('');
const handleLanguage = (lang: string) => {
/**
* Handle the language change.
*
* @param {string} lang the language to change to.
*/
const handleLanguage = (lang: string): void => {
i18n.changeLanguage(lang);
setDefaultLang(lang);
};
/**
* Deletes the user account
*/
const deleteAccount = async (): Promise<void> => {
setShowAlert(false);
await api.postJSON(ApiConfig.deleteAccountUrl, {});
@@ -32,38 +53,189 @@ function Account(): React.ReactNode {
clearStorage();
};
/**
* Handles errors by setting the error message and form invalid state.
*
* @param {unknown} e - The error to handle.
*/
const handleError = (e: unknown): void => {
if (typeof e === 'string') {
setErrorMessage(translateServerResponse(e, i18n.language));
setFormInvalid(true);
}
else if (e instanceof Error) {
setErrorMessage(translateServerResponse(e.message, i18n.language));
setFormInvalid(true);
}
};
/**
* Patches a user into.
*
* @param {UserPatchRequest} payload - The user data to patch.
* @returns {Promise<boolean>} True if the task was added successfully, false otherwise.
*/
const patchUserInfo = async (payload: UserPatchRequest): Promise<UserResponse | undefined> => {
try {
return await api.patchJSON(ApiConfig.userUrl, payload) as UserResponse;
}
catch (e) {
handleError(e);
}
};
/**
* Resets the input fields to their default values.
*/
const resetInputs = (userUpdated: UserResponse): void => {
setUserName(userUpdated.name ? userUpdated.name : '');
setUserEmail(userUpdated.email);
updateUser(userUpdated);
setValidated(false);
setFormInvalid(false);
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
setValidated(true);
const form = event.currentTarget;
const patchPayload: UserPatchRequest = {
name: userName ? DOMPurify.sanitize(userName) : null,
email: userEmail,
password: userPassword,
passwordAgain: userPasswordAgain
};
const updated: UserResponse | undefined = await patchUserInfo(patchPayload);
if (updated) {
form.reset();
resetInputs(updated);
}
};
useEffect(() => {}, [user]);
return (
<Container className="mb-5 main-margin">
<Row className="justify-content-center mb-4">
<Container>
<h1 className="poppins-regular home-hello main-margin">
My
{' '}
<b>Account</b>
</h1>
<p className="poppins-regular home-subtitle">
{t('account_my_account_hello')}
</p>
<Row className="mb-3">
<Col xs={12}>
<Card className="p-4 shadow-sm">
<h2 className="mb-4">
<Person />
{' '}
{t('account_my_account_tittle')}
</h2>
<p>{t('account_my_account_hello')}</p>
<p data-testid="logged-in-as">
{t('account_my_account_logged')}
{' '}
<b>
{user?.email}
</b>
</p>
</Card>
<h2 className="poppins-regular">Update and Manage, Your</h2>
<h2 className="poppins-bold home-productive">Information</h2>
</Col>
</Row>
<Row className="justify-content-center mb-4">
<Col xs={12}>
<Card className="p-4 shadow-sm">
<h2 className="mb-4">
<Row>
<Col xs={6}>
<div className="user-info-card">
<div className="title">
<Person />
{' '}
Change your info
</div>
<span className="description">
Update only what you need. Blank fields will not be updated
</span>
{formInvalid
? (
<Alert variant="danger" data-testid="add-task-error-message">
{ errorMessage }
</Alert>
)
: null}
<Form noValidate validated={validated} onSubmit={handleSubmit} className="mt-4">
{/* User name */}
<FormInput
labelText="First name"
iconName="Person"
required={false}
name="name"
placeholder={user?.name ? user.name : ''}
value={userName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserName(e.target.value);
}}
/>
{/* User email */}
<FormInput
labelText="Email"
iconName="At"
required={false}
name="email"
placeholder={user?.email}
value={userEmail}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserEmail(e.target.value);
}}
/>
{/* User password */}
<FormInput
labelText="Password"
iconName="Lock"
required={false}
type="password"
name="password"
value={userPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserPassword(e.target.value);
}}
data_testid="account-password-one"
/>
{/* User password again */}
<FormInput
labelText="Repeat password"
iconName="Lock"
required={false}
type="password"
name="passwordAgain"
value={userPasswordAgain}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserPasswordAgain(e.target.value);
}}
/>
<Button variant="primary" type="submit">
Save profile information
</Button>
</Form>
<hr />
<p>
If you want to display your picture, we support Gravatar.
Please head to
{' '}
<a href="#">Gravatar</a>
{' '}
to register or update your profile picture.
</p>
</div>
</Col>
<Col xs={6}>
<div className="user-info-card">
<div className="title">
<FileText />
{' '}
{t('account_app_lang_tittle')}
</h2>
<p>{t('account_app_lang_description')}</p>
<div className="my-3">
Change the app language
</div>
<span className="description">{t('account_app_lang_description')}</span>
<div className="mt-4 mb-2">Available languages</div>
<div>
{languages.map((lang: LangAvailable) => (
<Button
key={lang.key}
@@ -71,23 +243,38 @@ function Account(): React.ReactNode {
variant="outline-primary"
className="btn-sm me-3"
onClick={() => handleLanguage(lang.lang)}
data-testid={`language-button-${lang.lang}`}
>
{lang.lang === 'pt_br' && (
<span>🇧🇷 </span>
)}
{lang.lang === 'en' && (
<span>🇺🇸 </span>
)}
{lang.lang === 'es' && (
<span>🇪🇸 </span>
)}
{lang.lang === 'ru' && (
<span>🇷🇺 </span>
)}
{t(lang.key)}
</Button>
))}
</div>
</Card>
</div>
</Col>
</Row>
<Row className="justify-content-center mb-4">
<Row className="my-4">
<Col xs={12}>
<Card className="p-4 shadow-sm">
<h2 className="mb-4">
<ShieldCheck />
{' '}
{t('account_privacy_little')}
</h2>
<h3 className="poppins-regular">Your Privacy</h3>
<h4 className="poppins-bold home-productive">Matters</h4>
</Col>
</Row>
<Row>
<Col>
<div className="user-info-card">
<p>{t('account_privacy_text')}</p>
<Button
variant="danger"
@@ -106,7 +293,7 @@ function Account(): React.ReactNode {
</Button>
</Alert>
)}
</Card>
</div>
</Col>
</Row>
</Container>
+14
View File
@@ -0,0 +1,14 @@
.user-info-card {
background-color: #fff;
padding: 2rem;
border-radius: 10px;
.title {
font-size: 16px;
}
.description {
font-size: 12px;
color: #6a8996;
}
}
+23 -5
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { NavLink } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
@@ -22,6 +22,7 @@ import { handleDefaultLang } from '../../lang-service/LangHandler';
import { translateServerResponse } from '../../utils/TranslatorUtils';
import CompletedTasks from '../../components/CompletedTasks';
import TaskProgress from '../../components/TaskProgress';
import AuthContext from '../../context/AuthContext';
import './style.css';
/**
@@ -32,13 +33,19 @@ import './style.css';
* @returns {React.ReactNode} The Home page component.
*/
function Home(): React.ReactNode {
const { user } = useContext(AuthContext);
const { i18n, t } = useTranslation();
const [errorMessage, setErrorMessage] = useState<string>('');
const [validated, setValidated] = useState<boolean>(false);
const [formInvalid, setFormInvalid] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<HomeSearchResponse | null>(null);
const [name, setName] = useState<string>('Ricardo');
const { i18n, t } = useTranslation();
const [name, setName] = useState<string>(user?.name ? user?.name : 'User');
/**
* Handles the error by setting the error message.
*
* @param {unknown} e - The error to handle.
*/
const handleError = (e: unknown): void => {
if (typeof e === 'string') {
setErrorMessage(translateServerResponse(e, i18n.language));
@@ -48,6 +55,12 @@ function Home(): React.ReactNode {
}
};
/**
* Searches for a term.
*
* @param {string}
* @returns {Promise<boolean>} Whether the search was successful.
*/
const searchTerm = async (term: string): Promise<boolean> => {
try {
const response: HomeSearchResponse = await api.getJSON(`${ApiConfig.homeUrl}/search?term=${term}`);
@@ -60,6 +73,11 @@ function Home(): React.ReactNode {
return false;
};
/**
* Handles the search form submission.
*
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSearch = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
@@ -81,8 +99,8 @@ function Home(): React.ReactNode {
useEffect(() => {
handleDefaultLang();
setName('Ricardo');
}, []);
setName(user?.name ? user?.name : 'User');
}, [user]);
return (
<Container>
-20
View File
@@ -1,23 +1,3 @@
.home-hello {
margin-top: 2rem;
font-size: 28px;
color: #233C46;
}
.home-subtitle {
color: #A1A1A1;
font-size: 14px;
line-height: 21px;
}
.home-productive {
color: #4CD964;
}
.home-card {
padding-top: 100px;
}
.home-new-item {
border: none;
border-radius: 15px !important;
+54 -88
View File
@@ -6,10 +6,8 @@ import {
Col,
Container,
Form,
InputGroup,
Row
} from 'react-bootstrap';
import { CalendarCheck, Hash, PencilFill } from 'react-bootstrap-icons';
import { useNavigate, useParams } from 'react-router';
import TaskNoteRequest from '../../types/TaskNoteRequest';
import { TaskResponse } from '../../types/TaskResponse';
@@ -17,6 +15,7 @@ import { useTranslation } from 'react-i18next';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import { translateServerResponse } from '../../utils/TranslatorUtils';
import FormInput from '../../components/FormInput';
type TaskAction = 'add' | 'edit';
@@ -214,94 +213,61 @@ function TaskAdd(): React.ReactNode {
: null}
<Form noValidate validated={validated} onSubmit={handleSubmit}>
<Row>
<Col xs={9}>
<Form.Group className="mb-3" controlId="task-form-description">
<Form.Label>{t('task_form_desc_label')}</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<PencilFill />
</InputGroup.Text>
<Form.Control
required
type="text"
name="description"
placeholder={t('task_form_desc_placeholder')}
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
}}
/>
</InputGroup>
</Form.Group>
</Col>
</Row>
{/* Description */}
<FormInput
labelText={t('task_form_desc_label')}
iconName="PencilFill"
required={true}
type="text"
name="description"
placeholder={t('task_form_desc_placeholder')}
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
}}
/>
<Row>
<Col xs={9}>
<Form.Group className="mb-3" controlId="task-form-url">
<Form.Label>{t('task_form_url_label')}</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>@</InputGroup.Text>
<Form.Control
required={false}
type="text"
name="url"
placeholder={t('task_form_url_placeholder')}
value={taskUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskUrl(e.target.value);
}}
/>
</InputGroup>
</Form.Group>
</Col>
</Row>
{/* Task URL */}
<FormInput
labelText={t('task_form_url_label')}
iconName="At"
required={false}
type="text"
name="url"
placeholder={t('task_form_url_placeholder')}
value={taskUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskUrl(e.target.value);
}}
/>
<Row>
<Col xs={6}>
<Form.Group className="mb-3" controlId="task-form-due-date">
<Form.Label>{t('task_form_duedate_label')}</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<CalendarCheck />
</InputGroup.Text>
<Form.Control
required={false}
type="text"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
value={dueDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDueDate(e.target.value);
}}
/>
</InputGroup>
</Form.Group>
</Col>
</Row>
<Row>
<Col sm={6}>
<Form.Group className="mb-3" controlId="task-form-tag">
<Form.Label>Tag</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<Hash />
</InputGroup.Text>
<Form.Control
required={false}
type="text"
name="tag"
placeholder="my-tag"
value={tag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.target.value);
}}
/>
</InputGroup>
</Form.Group>
</Col>
</Row>
{/* Due date */}
<FormInput
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="text"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
value={dueDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDueDate(e.target.value);
}}
/>
{/* Tag */}
<FormInput
labelText="Tag"
iconName="Hash"
required={false}
type="text"
name="tag"
placeholder="my-tag"
value={tag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.target.value);
}}
/>
<Form.Check
type="switch"
@@ -4,7 +4,7 @@ import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.response.JwtAuthenticationResponse;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@@ -55,10 +55,10 @@ public class AuthenticationController {
description = "Email already in use",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ResponseEntity<JwtAuthenticationResponse> signUp(
public ResponseEntity<UserResponseWithToken> signUp(
@RequestBody @Valid LoginRequest loginRequest) {
String token = authService.signUpNewUser(loginRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(new JwtAuthenticationResponse(token));
UserResponseWithToken response = authService.signUpNewUser(loginRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
/**
@@ -88,11 +88,11 @@ public class AuthenticationController {
description = "User not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public JwtAuthenticationResponse signIn(@RequestBody @Valid LoginRequest loginRequest) {
String token = authService.signInUser(loginRequest);
if (Objects.isNull(token)) {
public ResponseEntity<UserResponseWithToken> signIn(@RequestBody @Valid LoginRequest loginRequest) {
UserResponseWithToken response = authService.signInUser(loginRequest);
if (Objects.isNull(response)) {
throw new InvalidCredentialsException();
}
return new JwtAuthenticationResponse(token);
return ResponseEntity.ok().body(response);
}
}
@@ -1,5 +1,6 @@
package br.com.tasknoteapp.server.controller;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
@@ -7,9 +8,13 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -47,4 +52,36 @@ public class UserController {
public List<UserResponse> getAllUsers() {
return authService.getAllUsers();
}
@PatchMapping
@Operation(
summary = "Patch the user data",
description = "Patch all user information. Empty fields will not be updated",
responses = {
@ApiResponse(
responseCode = "200",
description = "Task successfully patched",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "Task not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ResponseEntity<UserResponse> patchUserInfo(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "User data to be patched.",
required = true)
@RequestBody
@Valid
UserPatchRequest taskRequest) {
UserResponse patched = authService.patchUserInfo(taskRequest);
return ResponseEntity.ok().body(patched);
}
}
@@ -39,6 +39,9 @@ public class UserEntity implements UserDetails {
@Column(name = "inactivated_at", nullable = true)
private LocalDateTime inactivatedAt;
@Column(name = "name", length = 20)
private String name;
@OneToMany(mappedBy = "user")
private List<TaskEntity> tasks;
@@ -0,0 +1,11 @@
package br.com.tasknoteapp.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
/** This record represents a user patch payload. */
@Schema(description = "User patch payload.")
public record UserPatchRequest(
@Schema(description = "User first name. Optional.") String name,
@Schema(description = "User email. Optional.") String email,
@Schema(description = "User password. Optional.") String password,
@Schema(description = "User password again. Optional.") String passwordAgain) {}
@@ -1,14 +1,14 @@
package br.com.tasknoteapp.server.response;
import br.com.tasknoteapp.server.entity.UserEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import br.com.tasknoteapp.server.entity.UserEntity;
/** This record represents a User Response object. */
@Schema(description = "This record represents a User Response object.")
public record UserResponse(
@Schema(description = "The id of the user", example = "1") Long userId,
@Schema(description = "The name of the user", example = "John") String name,
@Schema(description = "The email of the user", example = "user@domain.com") String email,
@Schema(description = "The admin status of the user", example = "false") Boolean admin,
@Schema(description = "The created date and time of the user", example = "2023-01-01T00:00:00")
@@ -18,7 +18,6 @@ public record UserResponse(
example = "2023-01-01T00:00:00")
LocalDateTime inactivatedAt) {
/**
* Create a {@link UserResponse} instance from a {@link UserEntity}.
*
@@ -28,6 +27,7 @@ public record UserResponse(
public static UserResponse fromEntity(UserEntity user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getAdmin(),
user.getCreatedAt(),
@@ -0,0 +1,39 @@
package br.com.tasknoteapp.server.response;
import br.com.tasknoteapp.server.entity.UserEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/** This record represents a User Response object. */
@Schema(description = "This record represents a User Response with token object.")
public record UserResponseWithToken(
@Schema(description = "The id of the user", example = "1") Long userId,
@Schema(description = "The name of the user", example = "John") String name,
@Schema(description = "The email of the user", example = "user@domain.com") String email,
@Schema(description = "The admin status of the user", example = "false") Boolean admin,
@Schema(description = "The created date and time of the user", example = "2023-01-01T00:00:00")
LocalDateTime createdAt,
@Schema(
description = "The inactivated date and time of the user",
example = "2023-01-01T00:00:00")
LocalDateTime inactivatedAt,
@Schema(description = "The token created upon login") String token) {
/**
* Create a {@link UserResponseWithToken} instance from a {@link UserEntity}.
*
* @param user The user entity instance with user info to be used as source.
* @param token The token created upon registration or login.
* @return UserResponse instance.
*/
public static UserResponseWithToken fromEntity(UserEntity user, String token) {
return new UserResponseWithToken(
user.getId(),
user.getName(),
user.getEmail(),
user.getAdmin(),
user.getCreatedAt(),
user.getInactivatedAt(),
token);
}
}
@@ -11,13 +11,16 @@ import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
import br.com.tasknoteapp.server.repository.UserRepository;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.util.AuthUtil;
import jakarta.transaction.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -54,7 +57,7 @@ public class AuthService {
* @param login User details with email and password.
* @return Token
*/
public String signUpNewUser(LoginRequest login) {
public UserResponseWithToken signUpNewUser(LoginRequest login) {
log.info("Signing up new user! {}", login.email());
if (findByEmail(login.email()).isPresent()) {
@@ -76,7 +79,7 @@ public class AuthService {
String token = jwtService.generateToken(user.getEmail());
log.info("User created! Token {}", token);
return token;
return UserResponseWithToken.fromEntity(user, token);
}
/**
@@ -111,7 +114,7 @@ public class AuthService {
* @return Token
*/
@Transactional
public String signInUser(LoginRequest login) {
public UserResponseWithToken signInUser(LoginRequest login) {
log.info("Signing in user! {}", login.email());
Optional<UserEntity> user = findByEmail(login.email());
@@ -130,7 +133,7 @@ public class AuthService {
log.info("User authenticated! Token {}", token);
userPwdLimitRepository.deleteAllForUser(user.get().getId());
return token;
return UserResponseWithToken.fromEntity(user.get(), token);
} catch (BadCredentialsException e) {
log.error("BadCredentialsException when logging in user {}", user.get().getId());
@@ -213,12 +216,46 @@ public class AuthService {
userPwdLimitRepository.deleteAllForUser(currentUser.getId());
userRepository.delete(currentUser);
return new UserResponse(
currentUser.getId(),
currentUser.getEmail(),
currentUser.getAdmin(),
currentUser.getCreatedAt(),
currentUser.getInactivatedAt());
return UserResponse.fromEntity(currentUser);
}
@Transactional
public UserResponse patchUserInfo(UserPatchRequest patchRequest) {
Optional<String> currentUserEmail = authUtil.getCurrentUserEmail();
String email = currentUserEmail.orElseThrow();
UserEntity currentUser = findByEmail(email).orElseThrow();
boolean shouldUpdate = false;
if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) {
currentUser.setName(patchRequest.name().trim());
shouldUpdate = true;
}
if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) {
currentUser.setEmail(patchRequest.email().trim());
shouldUpdate = true;
}
boolean updatePassword =
!Objects.isNull(patchRequest.password())
&& !patchRequest.password().isBlank()
&& !Objects.isNull(patchRequest.passwordAgain())
&& !patchRequest.passwordAgain().isBlank();
if (updatePassword) {
Optional<String> passwordValidation = authUtil.validatePassword(patchRequest.password());
if (passwordValidation.isPresent()) {
throw new BadPasswordException(passwordValidation.get());
}
currentUser.setPassword(passwordEncoder.encode(patchRequest.password()));
shouldUpdate = true;
}
if (shouldUpdate) {
userRepository.save(currentUser);
}
return UserResponse.fromEntity(currentUser);
}
private void checkLoginAttemptLimit(Long userId) {
@@ -122,7 +122,7 @@ public class TaskService {
TaskEntity taskEntity = task.get();
if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) {
taskEntity.setDescription(patch.description());
taskEntity.setDescription(patch.description().trim());
}
if (!Objects.isNull(patch.done())) {
taskEntity.setDone(patch.done());
@@ -0,0 +1,2 @@
alter table tasknote.users
add name varchar(20) null;
@@ -9,7 +9,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.service.AuthService;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -33,7 +35,10 @@ class AuthenticationControllerTest {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
final String token = "xaxbxcxdx1x2x3A@";
when(authService.signUpNewUser(request)).thenReturn(token);
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, token);
when(authService.signUpNewUser(request)).thenReturn(response);
String jsonString =
"""
@@ -51,6 +56,9 @@ class AuthenticationControllerTest {
.accept(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.userId").value(response.userId()))
.andExpect(jsonPath("$.email").value(response.email()))
.andExpect(jsonPath("$.admin").value(response.admin()))
.andExpect(jsonPath("$.token").value(token))
.andReturn();
}
@@ -61,7 +69,10 @@ class AuthenticationControllerTest {
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456");
final String token = "xaxbxcxdx1x2x3@A";
when(authService.signUpNewUser(request)).thenReturn(token);
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, token);
when(authService.signUpNewUser(request)).thenReturn(response);
String jsonString =
"""
@@ -114,7 +125,10 @@ class AuthenticationControllerTest {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
final String token = "xaxbxcxdx1x2x3A@";
when(authService.signInUser(request)).thenReturn(token);
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, token);
when(authService.signInUser(request)).thenReturn(response);
String jsonString =
"""
@@ -132,6 +146,9 @@ class AuthenticationControllerTest {
.accept(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(response.userId()))
.andExpect(jsonPath("$.email").value(response.email()))
.andExpect(jsonPath("$.admin").value(response.admin()))
.andExpect(jsonPath("$.token").value(token))
.andReturn();
}
@@ -3,9 +3,11 @@ package br.com.tasknoteapp.server.controller;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.service.AuthService;
import java.util.List;
@@ -31,7 +33,7 @@ class UserControllerTest {
@DisplayName("Get all users happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getAllUsers_happyPath_shouldSucceed() throws Exception {
UserResponse userResponse = new UserResponse(1L, "email@test.com", false, null, null);
UserResponse userResponse = new UserResponse(1L, "John", "email@test.com", false, null, null);
when(authService.getAllUsers()).thenReturn(List.of(userResponse));
mockMvc
@@ -59,4 +61,34 @@ class UserControllerTest {
.andExpect(status().isForbidden())
.andReturn();
}
@Test
@DisplayName("Patch user info happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
UserResponse response = new UserResponse(1L, "John", "email@example.com", false, null, null);
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null);
when(authService.patchUserInfo(request)).thenReturn(response);
String jsonString =
"""
{
"name": "John Doe",
"email": "email@example.com"
}
""";
mockMvc
.perform(
patch("/rest/users")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(response.userId()))
.andExpect(jsonPath("$.email").value(response.email()))
.andExpect(jsonPath("$.admin").value(response.admin()))
.andReturn();
}
}
@@ -63,7 +63,7 @@ class UserSessionControllerTest {
@DisplayName("Delete account happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void deleteAccount_happyPath_shouldSucceed() throws Exception {
UserResponse response = new UserResponse(1L, "email@test.com", false, null, null);
UserResponse response = new UserResponse(1L, "John", "email@test.com", false, null, null);
when(userSessionService.deleteCurrentUserAccount()).thenReturn(response);
mockMvc
@@ -9,15 +9,17 @@ import static org.mockito.Mockito.when;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
import br.com.tasknoteapp.server.exception.BadPasswordException;
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
import br.com.tasknoteapp.server.exception.UserForbiddenException;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
import br.com.tasknoteapp.server.repository.UserRepository;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.util.AuthUtil;
import java.time.LocalDateTime;
import java.util.List;
@@ -80,11 +82,12 @@ class AuthServiceTest {
when(userRepository.save(any())).thenReturn(entity);
when(jwtService.generateToken(request.email())).thenReturn("a1b2c3");
String token = authService.signUpNewUser(request);
UserResponseWithToken token = authService.signUpNewUser(request);
Assertions.assertNotNull(token);
Assertions.assertFalse(token.isBlank());
Assertions.assertEquals("a1b2c3", token);
Assertions.assertFalse(token.token().isBlank());
Assertions.assertEquals("a1b2c3", token.token());
Assertions.assertEquals(entity.getEmail(), token.email());
}
@Test
@@ -191,10 +194,10 @@ class AuthServiceTest {
doNothing().when(userPwdLimitRepository).deleteAllForUser(existing.getId());
String token = authService.signInUser(request);
UserResponseWithToken token = authService.signInUser(request);
Assertions.assertNotNull(token);
Assertions.assertEquals("a1b2c3", token);
Assertions.assertEquals("a1b2c3", token.token());
}
@Test
@@ -248,7 +251,7 @@ class AuthServiceTest {
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of());
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("Wrong"));
String token = authService.signInUser(request);
UserResponseWithToken token = authService.signInUser(request);
Assertions.assertNull(token);
verify(userPwdLimitRepository, times(1)).save(any());
@@ -285,9 +288,11 @@ class AuthServiceTest {
void getAllUsers_noCurrentUser_shouldFail() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.empty());
Assertions.assertThrows(UserForbiddenException.class, () -> {
authService.getAllUsers();
});
Assertions.assertThrows(
UserForbiddenException.class,
() -> {
authService.getAllUsers();
});
}
@Test
@@ -297,9 +302,11 @@ class AuthServiceTest {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email));
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
Assertions.assertThrows(UserForbiddenException.class, () -> {
authService.getAllUsers();
});
Assertions.assertThrows(
UserForbiddenException.class,
() -> {
authService.getAllUsers();
});
}
@Test
@@ -314,9 +321,11 @@ class AuthServiceTest {
existing.setAdmin(false);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
Assertions.assertThrows(UserForbiddenException.class, () -> {
authService.getAllUsers();
});
Assertions.assertThrows(
UserForbiddenException.class,
() -> {
authService.getAllUsers();
});
}
@Test
@@ -356,4 +365,54 @@ class AuthServiceTest {
Assertions.assertNotNull(response);
}
@Test
@DisplayName("Patch user info patch user name and email should succeed")
void pathUserInfo_nameAndEmail_shouldSucceed() {
String email = "user@domain.com";
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email));
UserEntity existing = new UserEntity();
existing.setId(919L);
existing.setName(null);
existing.setEmail(email);
existing.setAdmin(false);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
when(userRepository.save(any())).thenReturn(existing);
UserPatchRequest patchRequest = new UserPatchRequest("Kong", "newemail@domain.com", null, null);
UserResponse response = authService.patchUserInfo(patchRequest);
Assertions.assertNotNull(response);
Assertions.assertEquals("Kong", response.name());
Assertions.assertEquals("newemail@domain.com", response.email());
}
@Test
@DisplayName("Patch user info patch user password should succeed")
void pathUserInfo_password_shouldSucceed() {
String email = "user@domain.com";
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email));
UserEntity existing = new UserEntity();
existing.setId(919L);
existing.setName(null);
existing.setEmail(email);
existing.setAdmin(false);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
when(userRepository.save(any())).thenReturn(existing);
String newPassword = "TestHackedPw@difficult!#:)";
UserPatchRequest patchRequest = new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword);
when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty());
UserResponse response = authService.patchUserInfo(patchRequest);
Assertions.assertNotNull(response);
Assertions.assertEquals("Kong", response.name());
Assertions.assertEquals("newemail@domain.com", response.email());
}
}