diff --git a/client/Caddyfile b/client/Caddyfile index 35812ad..7280cbf 100644 --- a/client/Caddyfile +++ b/client/Caddyfile @@ -28,7 +28,8 @@ connect-src 'self' {$VITE_BACKEND_SERVER}; default-src 'self' data:; font-src 'self' https://fonts.gstatic.com/ https://cdn.jsdelivr.net/; - frame-src 'self' img-src 'self'; + frame-src 'self'; + img-src 'self' https://gravatar.com/; manifest-src 'self'; media-src 'self'; object-src 'none'; diff --git a/client/src/__test__/components/Sidebar.test.tsx b/client/src/__test__/components/Sidebar.test.tsx index f2112b8..65de1b6 100644 --- a/client/src/__test__/components/Sidebar.test.tsx +++ b/client/src/__test__/components/Sidebar.test.tsx @@ -14,7 +14,8 @@ const authContextMock = { name: 'Ricardo', email: 'ricardo@campos.com', admin: false, - createdAt: new Date() + createdAt: new Date(), + gravatarImageUrl: 'http://url.com' }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), diff --git a/client/src/__test__/context/AuthProvider.test.tsx b/client/src/__test__/context/AuthProvider.test.tsx new file mode 100644 index 0000000..481f79c --- /dev/null +++ b/client/src/__test__/context/AuthProvider.test.tsx @@ -0,0 +1,279 @@ +// AuthProvider.test.tsx +import React, { useContext } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AuthProvider from '../../context/AuthProvider'; +import AuthContext, { AuthContextData } from '../../context/AuthContext'; +import api from '../../api-service/api'; +import ApiConfig from '../../api-service/apiConfig'; +import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants'; + +// Mock the API service methods. +vi.mock('../../api-service/api'); + +// Create a helper component to consume AuthContext for testing. +const ConsumerComponent: React.FC = () => { + const { + signed, + user, + signIn, + register, + signOut, + updateUser, + checkCurrentAuthUser + } = useContext(AuthContext); + + return ( +
+
{signed ? 'true' : 'false'}
+
{user ? user.name : 'none'}
+ + + + + +
+ ); +}; + +describe('AuthProvider', () => { + // Reset DOM and mocks for each test. + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render the default context values', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('signed').textContent).toBe('false'); + expect(getByTestId('user').textContent).toBe('none'); + }); + + it('should sign in a user successfully', async () => { + // Create a fake sign-in response. + const fakeResponse = { + userId: '123', + name: 'Test User', + email: 'test@example.com', + admin: false, + createdAt: new Date(), + token: 'dummy-token', + gravatarImageUrl: 'http://dummyimage.com' + }; + + vi.spyOn(api, 'postJSON').mockResolvedValue(fakeResponse); + + const { getByTestId } = render( + + + + ); + + await act(async () => { + userEvent.click(getByTestId('signIn')); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('true') + ); + expect(getByTestId('user').textContent).toBe('Test User'); + // LocalStorage should have API_TOKEN and USER_DATA set. + expect(localStorage.getItem(API_TOKEN)).toBe('dummy-token'); + expect(localStorage.getItem(USER_DATA)).not.toBeNull(); + }); + + it('should register a new user successfully', async () => { + const fakeResponse = { + token: 'register-token', + userId: '456', + name: 'New User', + email: 'new@example.com', + admin: false, + createdAt: new Date(), + gravatarImageUrl: 'http://dummyimage.com' + }; + + vi.spyOn(api, 'putJSON').mockResolvedValue(fakeResponse); + + const { getByTestId } = render( + + + + ); + + await act(async () => { + userEvent.click(getByTestId('register')); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('true') + ); + expect(getByTestId('user').textContent).toBe('New User'); + expect(localStorage.getItem(API_TOKEN)).toBe('register-token'); + expect(localStorage.getItem(USER_DATA)).not.toBeNull(); + }); + + it('should sign out a user', async () => { + // Pre-populate localStorage to simulate a signed-in state. + localStorage.setItem(API_TOKEN, 'dummy-token'); + localStorage.setItem( + USER_DATA, + JSON.stringify({ + userId: '123', + name: 'Test User', + email: 'test@example.com', + admin: false, + createdAt: new Date(), + gravatarImageUrl: 'http://dummyimage.com' + }) + ); + + const fakeResponse = { + userId: '123', + name: 'Test User', + email: 'test@example.com', + admin: false, + createdAt: new Date(), + token: 'dummy-token', + gravatarImageUrl: 'http://dummyimage.com' + }; + + vi.spyOn(api, 'postJSON').mockResolvedValue(fakeResponse); + + const { getByTestId } = render( + + + + ); + + // Make sure that the context initially renders with the signed user by triggering a signIn. + await act(async () => { + userEvent.click(getByTestId('signIn')); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('true') + ); + + // Now sign out + await act(async () => { + userEvent.click(getByTestId('sign-out-btn')); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('false') + ); + expect(getByTestId('user').textContent).toBe('none'); + // LocalStorage items should be removed. + expect(localStorage.getItem(API_TOKEN)).toBeNull(); + expect(localStorage.getItem(USER_DATA)).toBeNull(); + }); + + it('should update user in context and localStorage', async () => { + const { getByTestId } = render( + + + + ); + + await act(async () => { + userEvent.click(getByTestId('updateUser')); + }); + + await waitFor(() => { + act(() => { + expect(getByTestId('user').textContent).toBe('Updated User') + }) + const savedUser = localStorage.getItem(USER_DATA); + expect(savedUser).not.toBeNull(); + + if (savedUser) { + const parsedUser = JSON.parse(savedUser); + expect(parsedUser.name).toBe('Updated User'); + } + }); + }); + + it('should call fetchCurrentSession when checking current auth user', async () => { + const fakeResponse = { + token: 'refresh-token', + userId: '789', + name: 'Refreshed User', + email: 'refreshed@example.com', + admin: false, + createdAt: new Date().toISOString(), + gravatarImageUrl: 'http://dummyimage.com' + }; + + vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse); + + // Store API_TOKEN so that fetchCurrentSession runs the refresh logic. + localStorage.setItem(API_TOKEN, 'dummy'); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + userEvent.click(getByTestId('checkCurrentAuthUser')); + }); + + await waitFor(() => + expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token') + ); + }); +}); diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index dec0d37..287b924 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -8,7 +8,6 @@ import AuthContext from '../../context/AuthContext'; import i18n from '../../i18n'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; -import TaskNoteRequest from '../../types/TaskNoteRequest'; import { NoteResponse } from '../../types/NoteResponse'; vi.mock('../../api-service/api'); @@ -35,7 +34,8 @@ const authContextMock = { name: 'Ricardo', email: 'test@example.com', admin: false, - createdAt: new Date() + createdAt: new Date(), + gravatarImageUrl: 'http://url.com' }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), diff --git a/client/src/__test__/views/TaskAdd.test.tsx b/client/src/__test__/views/TaskAdd.test.tsx index cf64bb0..fa61026 100644 --- a/client/src/__test__/views/TaskAdd.test.tsx +++ b/client/src/__test__/views/TaskAdd.test.tsx @@ -34,7 +34,8 @@ const authContextMock = { name: 'Ricardo', email: 'test@example.com', admin: false, - createdAt: new Date() + createdAt: new Date(), + gravatarImageUrl: 'http://url.com' }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), diff --git a/client/src/assets/user.png b/client/src/assets/user.png deleted file mode 100644 index e9c0246..0000000 Binary files a/client/src/assets/user.png and /dev/null differ diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 2f42a48..4b1da7d 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -3,7 +3,6 @@ import { Nav } from 'react-bootstrap'; import { NavLink } from 'react-router'; import { useTranslation } from 'react-i18next'; import AuthContext from '../../context/AuthContext'; -import UserIcon from '../../assets/user.png'; import NavButton from '../NavButton'; import SidebarIcon from '../SidebarIcon'; import { env } from '../../env'; @@ -43,7 +42,7 @@ function Sidebar(): React.ReactNode { return (
- User icon + User icon {user?.name ? user?.name : 'User'}
diff --git a/client/src/components/Sidebar/style.css b/client/src/components/Sidebar/style.css index 14397ea..26f0231 100644 --- a/client/src/components/Sidebar/style.css +++ b/client/src/components/Sidebar/style.css @@ -1,9 +1,12 @@ .sidebar { width: 276px; background-color: #fff; - position: absolute; + position: fixed; left: 0; top: 0; + bottom: 0; + height: 100vh; + overflow-y: auto; } .sidebar-header { margin-left: 28px; @@ -20,6 +23,8 @@ } .sidebar-header img { margin-right: 1rem; + border-radius: 50%; + object-fit: cover; } .sidebar-menu-header { diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx index cc6df25..407dbde 100644 --- a/client/src/context/AuthProvider.tsx +++ b/client/src/context/AuthProvider.tsx @@ -28,8 +28,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro } catch (e) { if (e instanceof Error) { - // FIXME here - if (e.message !== 'No saved token!' && e.message !== 'Forbidden! Access denied') { + if (e.message !== 'No saved token!') { console.warn(e.message); } } @@ -82,7 +81,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro name: registerResponse.name, email: registerResponse.email, admin: registerResponse.admin, - createdAt: new Date(registerResponse.createdAt) + createdAt: new Date(registerResponse.createdAt), + gravatarImageUrl: registerResponse.gravatarImageUrl }; setSigned(true); @@ -107,7 +107,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro name: registerResponse.name, email: registerResponse.email, admin: registerResponse.admin, - createdAt: new Date(registerResponse.createdAt) + createdAt: new Date(registerResponse.createdAt), + gravatarImageUrl: registerResponse.gravatarImageUrl }; setSigned(true); diff --git a/client/src/types/SigninResponse.ts b/client/src/types/SigninResponse.ts index d1de349..9e318dc 100644 --- a/client/src/types/SigninResponse.ts +++ b/client/src/types/SigninResponse.ts @@ -5,4 +5,5 @@ export type SigninResponse = { admin: boolean; createdAt: Date; token: string; + gravatarImageUrl: string; }; diff --git a/client/src/types/UserResponse.ts b/client/src/types/UserResponse.ts index 82d0fa6..1bfc1a4 100644 --- a/client/src/types/UserResponse.ts +++ b/client/src/types/UserResponse.ts @@ -4,4 +4,5 @@ export type UserResponse = { email: string; admin: boolean; createdAt: Date; + gravatarImageUrl: string; }; diff --git a/client/src/views/Account/index.tsx b/client/src/views/Account/index.tsx index dc1d631..703f30b 100644 --- a/client/src/views/Account/index.tsx +++ b/client/src/views/Account/index.tsx @@ -204,9 +204,10 @@ function Account(): React.ReactNode { If you want to display your picture, we support Gravatar. Please head to {' '} - Gravatar + Gravatar {' '} - to register or update your profile picture. + to register or update your profile picture. Once updated, please wait a few + minutes to see it here.

diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index 36ef5e7..2360869 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -267,10 +267,18 @@ function NoteAdd(): React.ReactNode { + diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index cb7ea24..5144efd 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -283,6 +283,14 @@ function TaskAdd(): React.ReactNode { + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a3b2567..30a10ae 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,7 +9,7 @@ services: environment: VITE_BACKEND_SERVER: http://localhost:8585 VITE_BUILD: snapshot - image: client:latest + image: ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate ports: - "5000:5000" @@ -28,7 +28,7 @@ services: SERVER_SERVLET_CONTEXT_PATH: / ports: - "8585:8585" - image: server:candidate + image: ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate db: container_name: db diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/LoginRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/LoginRequest.java index d95b786..63e5b4e 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/LoginRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/LoginRequest.java @@ -26,7 +26,7 @@ public class LoginRequest { String password; public String email() { - return email.toLowerCase(); + return email.trim().toLowerCase(); } public String password() { diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java index 0a7aace..a240964 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java @@ -3,6 +3,7 @@ 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 java.util.Optional; /** This record represents a User Response object. */ @Schema(description = "This record represents a User Response object.") @@ -16,7 +17,8 @@ public record UserResponse( @Schema( description = "The inactivated date and time of the user", example = "2023-01-01T00:00:00") - LocalDateTime inactivatedAt) { + LocalDateTime inactivatedAt, + @Schema(description = "The gravatar image URL, if any") String gravatarImageUrl) { /** * Create a {@link UserResponse} instance from a {@link UserEntity}. @@ -24,13 +26,14 @@ public record UserResponse( * @param user The user entity instance with user info to be used as source. * @return UserResponse instance. */ - public static UserResponse fromEntity(UserEntity user) { + public static UserResponse fromEntity(UserEntity user, Optional gravatarUrl) { return new UserResponse( user.getId(), user.getName(), user.getEmail(), user.getAdmin(), user.getCreatedAt(), - user.getInactivatedAt()); + user.getInactivatedAt(), + gravatarUrl.orElse(null)); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java index 96459b3..cd90d8c 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java @@ -3,6 +3,7 @@ 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 java.util.Optional; /** This record represents a User Response object. */ @Schema(description = "This record represents a User Response with token object.") @@ -17,6 +18,7 @@ public record UserResponseWithToken( description = "The inactivated date and time of the user", example = "2023-01-01T00:00:00") LocalDateTime inactivatedAt, + @Schema(description = "The gravatar image URL, if any") String gravatarImageUrl, @Schema(description = "The token created upon login") String token) { /** @@ -26,7 +28,7 @@ public record UserResponseWithToken( * @param token The token created upon registration or login. * @return UserResponse instance. */ - public static UserResponseWithToken fromEntity(UserEntity user, String token) { + public static UserResponseWithToken fromEntity(UserEntity user, String token, Optional gravatarUrl) { return new UserResponseWithToken( user.getId(), user.getName(), @@ -34,6 +36,7 @@ public record UserResponseWithToken( user.getAdmin(), user.getCreatedAt(), user.getInactivatedAt(), + gravatarUrl.orElse(null), token); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index dca1aca..23a6cc5 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -16,6 +16,9 @@ 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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -79,7 +82,7 @@ public class AuthService { String token = jwtService.generateToken(user.getEmail()); log.info("User created! Token {}", token); - return UserResponseWithToken.fromEntity(user, token); + return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email())); } /** @@ -133,7 +136,8 @@ public class AuthService { log.info("User authenticated! Token {}", token); userPwdLimitRepository.deleteAllForUser(user.get().getId()); - return UserResponseWithToken.fromEntity(user.get(), token); + return UserResponseWithToken.fromEntity( + user.get(), token, getGravatarImageUrl(login.email())); } catch (BadCredentialsException e) { log.error("BadCredentialsException when logging in user {}", user.get().getId()); @@ -176,7 +180,8 @@ public class AuthService { List users = userRepository.findAll(); List usersResponse = new ArrayList<>(users.size()); - users.forEach((u) -> usersResponse.add(UserResponse.fromEntity(u))); + users.forEach( + u -> usersResponse.add(UserResponse.fromEntity(u, getGravatarImageUrl(u.getEmail())))); log.info("{} user(s) found!", usersResponse.size()); return usersResponse; @@ -216,9 +221,15 @@ public class AuthService { userPwdLimitRepository.deleteAllForUser(currentUser.getId()); userRepository.delete(currentUser); - return UserResponse.fromEntity(currentUser); + return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email)); } + /** + * Patches a user allowing him to update his information. + * + * @param patchRequest An instance of {@link UserPatchRequest} with the user data. + * @return UserResponse containing the updated info. + */ @Transactional public UserResponse patchUserInfo(UserPatchRequest patchRequest) { Optional currentUserEmail = authUtil.getCurrentUserEmail(); @@ -255,7 +266,31 @@ public class AuthService { userRepository.save(currentUser); } - return UserResponse.fromEntity(currentUser); + return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email)); + } + + private Optional getGravatarImageUrl(String email) { + email = email.toLowerCase().trim(); + log.info("Current user email: {}", email); + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(email.toLowerCase().getBytes(StandardCharsets.UTF_8)); + + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + log.info("Email hashed: {}", hexString); + return Optional.of(hexString.toString()); + } catch (NoSuchAlgorithmException | NullPointerException e) { + log.error("NoSuchAlgorithmException or NullPointerException", e.getMessage()); + } + return Optional.empty(); } private void checkLoginAttemptLimit(Long userId) { diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index 7e5c10d..b0fa9f6 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -17,6 +17,7 @@ import br.com.tasknoteapp.server.util.AuthUtil; import jakarta.transaction.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -132,10 +133,9 @@ public class TaskService { if (!Objects.isNull(patch.done())) { taskEntity.setDone(patch.done()); } - taskEntity.setDueDate(null); - if (!Objects.isNull(patch.dueDate())) { - taskEntity.setDueDate(LocalDate.parse(patch.dueDate())); - } + + patchDueDate(taskEntity, patch); + taskEntity.setHighPriority(false); if (!Objects.isNull(patch.highPriority())) { taskEntity.setHighPriority(patch.highPriority()); @@ -147,21 +147,7 @@ public class TaskService { taskEntity.setLastUpdate(LocalDateTime.now()); - List urlsToDelete = taskUrlRepository.findAllById_taskId(taskId); - if (!urlsToDelete.isEmpty()) { - taskUrlRepository.deleteAllById_taskId(taskId); - log.info("Deleted {} urls from task {}", urlsToDelete.size(), taskId); - } else { - log.info("No urls to delete for task {}", taskId); - } - - if (!Objects.isNull(patch.urls())) { - List urlListToAdd = - patch.urls().stream().filter(u -> !u.isBlank()).map(String::trim).toList(); - saveUrls(taskEntity, urlListToAdd); - } else { - log.info("No urls to add for task {}", taskId); - } + patchTaskUrl(taskEntity, patch); TaskEntity patchedTask = taskRepository.save(taskEntity); @@ -258,4 +244,34 @@ public class TaskService { taskUrlRepository.saveAll(tasksUrl); log.info("Added {} urls from task {}", tasksUrl.size(), taskEntity.getId()); } + + private void patchDueDate(TaskEntity taskEntity, TaskPatchRequest patch) { + taskEntity.setDueDate(null); + if (!Objects.isNull(patch.dueDate()) && !patch.description().isBlank()) { + try { + taskEntity.setDueDate(LocalDate.parse(patch.dueDate())); + } catch (DateTimeParseException e) { + log.error("Unable to parse the provided date: {}: {}", patch.dueDate(), e.getMessage(), e); + } + } + } + + private void patchTaskUrl(TaskEntity taskEntity, TaskPatchRequest patch) { + Long taskId = taskEntity.getId(); + List urlsToDelete = taskUrlRepository.findAllById_taskId(taskId); + if (!urlsToDelete.isEmpty()) { + taskUrlRepository.deleteAllById_taskId(taskId); + log.info("Deleted {} urls from task {}", urlsToDelete.size(), taskId); + } else { + log.info("No urls to delete for task {}", taskId); + } + + if (!Objects.isNull(patch.urls())) { + List urlListToAdd = + patch.urls().stream().filter(u -> !u.isBlank()).map(String::trim).toList(); + saveUrls(taskEntity, urlListToAdd); + } else { + log.info("No urls to add for task {}", taskId); + } + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java index a9b648c..bf41b9f 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java @@ -37,7 +37,7 @@ class AuthenticationControllerTest { UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, token); + 123L, null, request.email(), false, LocalDateTime.now(), null, null, token); when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = @@ -71,7 +71,7 @@ class AuthenticationControllerTest { UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, token); + 123L, null, request.email(), false, LocalDateTime.now(), null, null, token); when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = @@ -127,7 +127,7 @@ class AuthenticationControllerTest { UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, token); + 123L, null, request.email(), false, LocalDateTime.now(), null, null, token); when(authService.signInUser(request)).thenReturn(response); String jsonString = diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java index f40a537..94f7025 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java @@ -33,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, "John", "email@test.com", false, null, null); + UserResponse userResponse = new UserResponse(1L, "John", "email@test.com", false, null, null, null); when(authService.getAllUsers()).thenReturn(List.of(userResponse)); mockMvc @@ -66,7 +66,7 @@ class UserControllerTest { @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); + UserResponse response = new UserResponse(1L, "John", "email@example.com", false, null, null, null); UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null); when(authService.patchUserInfo(request)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java index 0814c46..e74bc22 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java @@ -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, "John", "email@test.com", false, null, null); + UserResponse response = new UserResponse(1L, "John", "email@test.com", false, null, null, null); when(userSessionService.deleteCurrentUserAccount()).thenReturn(response); mockMvc diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java index 3c96ec2..039a807 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java @@ -1,5 +1,11 @@ package br.com.tasknoteapp.server.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.times; @@ -19,9 +25,10 @@ import br.com.tasknoteapp.server.request.TaskPatchRequest; import br.com.tasknoteapp.server.request.TaskRequest; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.util.AuthUtil; +import br.com.tasknoteapp.server.util.TimeAgoUtil; +import java.time.LocalDate; import java.util.List; import java.util.Optional; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -77,11 +84,11 @@ class TaskServiceTest { TaskResponse taskResponse = taskService.getTaskById(taskId); - Assertions.assertNotNull(taskResponse); - Assertions.assertEquals(taskEntity.getId(), taskResponse.id()); - Assertions.assertEquals(taskEntity.getDescription(), taskResponse.description()); - Assertions.assertEquals(taskEntity.getHighPriority(), taskResponse.highPriority()); - Assertions.assertEquals(taskEntity.getTag(), taskResponse.tag()); + assertNotNull(taskResponse); + assertEquals(taskEntity.getId(), taskResponse.id()); + assertEquals(taskEntity.getDescription(), taskResponse.description()); + assertEquals(taskEntity.getHighPriority(), taskResponse.highPriority()); + assertEquals(taskEntity.getTag(), taskResponse.tag()); } @Test @@ -98,11 +105,7 @@ class TaskServiceTest { when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); - Assertions.assertThrows( - TaskNotFoundException.class, - () -> { - taskService.getTaskById(taskId); - }); + assertThrows(TaskNotFoundException.class, () -> taskService.getTaskById(taskId)); } @Test @@ -125,8 +128,8 @@ class TaskServiceTest { taskService.createTask(request); - Assertions.assertNotNull(entity); - Assertions.assertEquals("development", entity.getTag()); + assertNotNull(entity); + assertEquals("development", entity.getTag()); } @Test @@ -149,8 +152,8 @@ class TaskServiceTest { taskService.createTask(request); - Assertions.assertNotNull(entity); - Assertions.assertEquals("development", entity.getTag()); + assertNotNull(entity); + assertEquals("development", entity.getTag()); } @Test @@ -174,8 +177,8 @@ class TaskServiceTest { taskService.createTask(request); - Assertions.assertNotNull(entity); - Assertions.assertEquals("development", entity.getTag()); + assertNotNull(entity); + assertEquals("development", entity.getTag()); } @Test @@ -200,8 +203,8 @@ class TaskServiceTest { TaskResponse response = taskService.createTask(request); - Assertions.assertNotNull(response); - Assertions.assertTrue(response.urls().isEmpty()); + assertNotNull(response); + assertTrue(response.urls().isEmpty()); } @Test @@ -226,8 +229,8 @@ class TaskServiceTest { TaskResponse response = taskService.createTask(request); - Assertions.assertNotNull(response); - Assertions.assertTrue(response.urls().isEmpty()); + assertNotNull(response); + assertTrue(response.urls().isEmpty()); } @Test @@ -257,9 +260,9 @@ class TaskServiceTest { TaskResponse response = taskService.createTask(request); - Assertions.assertNotNull(response); - Assertions.assertFalse(response.urls().isEmpty()); - Assertions.assertEquals("debian.org", response.urls().get(0)); + assertNotNull(response); + assertFalse(response.urls().isEmpty()); + assertEquals("debian.org", response.urls().get(0)); } @Test @@ -280,9 +283,9 @@ class TaskServiceTest { List responses = taskService.getAllTasks(); - Assertions.assertFalse(responses.isEmpty()); - Assertions.assertEquals(1, responses.size()); - Assertions.assertEquals(entity.getTag(), responses.get(0).tag()); + assertFalse(responses.isEmpty()); + assertEquals(1, responses.size()); + assertEquals(entity.getTag(), responses.get(0).tag()); } @Test @@ -328,11 +331,7 @@ class TaskServiceTest { when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); - Assertions.assertThrows( - TaskNotFoundException.class, - () -> { - taskService.deleteTask(taskId); - }); + assertThrows(TaskNotFoundException.class, () -> taskService.deleteTask(taskId)); } @Test @@ -358,22 +357,156 @@ class TaskServiceTest { when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of()); - TaskEntity entity = new TaskEntity(); - entity.setDescription("Updated description"); - entity.setHighPriority(false); - entity.setDone(false); - entity.setTag(taskEntity.getTag()); - when(taskRepository.save(any())).thenReturn(entity); + String dueDate = "2026-12-31"; + String timeLeft = TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)); + + TaskEntity savedTask = new TaskEntity(); + savedTask.setDescription("Test task updated"); + savedTask.setHighPriority(false); + savedTask.setDueDate(LocalDate.parse(dueDate)); + savedTask.setDone(true); + savedTask.setTag(taskEntity.getTag()); + when(taskRepository.save(any())).thenReturn(savedTask); UserTasksDonePk pk = new UserTasksDonePk(USER_ID, taskId); when(userTasksDoneRepository.findById(pk)).thenReturn(Optional.empty()); TaskPatchRequest patch = - new TaskPatchRequest("Updated description", null, null, null, false, null); + new TaskPatchRequest("Test task updated", true, null, dueDate, false, "test"); TaskResponse patched = taskService.patchTask(taskId, patch); - Assertions.assertNotNull(patched); - Assertions.assertEquals("Updated description", patched.description()); - Assertions.assertFalse(patched.highPriority()); + assertNotNull(patched); + assertEquals("Test task updated", patched.description()); + assertTrue(patched.done()); + assertEquals(timeLeft, patched.dueDateFmt()); + assertFalse(patched.highPriority()); + assertEquals("test", patched.tag()); + assertTrue(patched.urls().isEmpty()); + } + + @Test + @DisplayName("Patch a task with url it should succeed") + void patchTask_withUrl_shouldSucceed() { + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL)); + + UserEntity userEntity = new UserEntity(); + userEntity.setId(USER_ID); + userEntity.setEmail(USER_EMAIL); + when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + + Long taskId = 2525L; + + TaskEntity taskEntity = new TaskEntity(); + taskEntity.setId(taskId); + taskEntity.setDescription("Test task"); + taskEntity.setHighPriority(true); + taskEntity.setDone(false); + taskEntity.setTag("test"); + taskEntity.setUser(userEntity); + when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); + + TaskUrlEntity urlEntity = new TaskUrlEntity(); + urlEntity.setId(new TaskUrlEntityPk(taskId, "www.url.com")); + when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of(urlEntity)); + doNothing().when(taskUrlRepository).deleteAllById_taskId(taskId); + + String dueDate = "2026-12-31"; + String timeLeft = TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)); + + TaskEntity savedTask = new TaskEntity(); + savedTask.setDescription("Test task updated"); + savedTask.setHighPriority(false); + savedTask.setDueDate(LocalDate.parse(dueDate)); + savedTask.setDone(true); + savedTask.setTag(taskEntity.getTag()); + when(taskRepository.save(any())).thenReturn(savedTask); + + UserTasksDonePk pk = new UserTasksDonePk(USER_ID, taskId); + when(userTasksDoneRepository.findById(pk)).thenReturn(Optional.empty()); + + String url = "http://test.com"; + TaskPatchRequest patch = + new TaskPatchRequest("Test task updated", true, List.of(url), dueDate, false, "test"); + + when(taskUrlRepository.saveAll(any())).thenReturn(List.of()); + TaskResponse patched = taskService.patchTask(taskId, patch); + + assertNotNull(patched); + assertEquals("Test task updated", patched.description()); + assertTrue(patched.done()); + assertEquals(timeLeft, patched.dueDateFmt()); + assertFalse(patched.highPriority()); + assertEquals("test", patched.tag()); + assertFalse(patched.urls().isEmpty()); + } + + @Test + @DisplayName("Patch a task with a not found task should fail") + void patchTask_taskNotFound_shouldFail() { + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL)); + + UserEntity userEntity = new UserEntity(); + userEntity.setId(USER_ID); + userEntity.setEmail(USER_EMAIL); + when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + + Long taskId = 2525L; + + when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); + + TaskPatchRequest patch = + new TaskPatchRequest("Test task updated", true, null, "2025-12-31", false, "test"); + + assertThrows(TaskNotFoundException.class, () -> taskService.patchTask(taskId, patch)); + } + + @Test + @DisplayName("Patch a task with a due date parse exception should fail") + void patchTask_dueDateParseException_shouldFail() { + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL)); + + UserEntity userEntity = new UserEntity(); + userEntity.setId(USER_ID); + userEntity.setEmail(USER_EMAIL); + when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + + Long taskId = 2525L; + + TaskEntity taskEntity = new TaskEntity(); + taskEntity.setId(taskId); + taskEntity.setDescription("Test task"); + taskEntity.setHighPriority(true); + taskEntity.setDone(false); + taskEntity.setTag("test"); + taskEntity.setUser(userEntity); + when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); + + when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of()); + + TaskEntity savedTask = new TaskEntity(); + savedTask.setDescription("Test task updated"); + savedTask.setHighPriority(false); + savedTask.setDone(true); + savedTask.setTag(taskEntity.getTag()); + when(taskRepository.save(any())).thenReturn(savedTask); + + UserTasksDonePk pk = new UserTasksDonePk(USER_ID, taskId); + when(userTasksDoneRepository.findById(pk)).thenReturn(Optional.empty()); + + // wrong due date + String dueDate = "2026-31-31"; + + TaskPatchRequest patch = + new TaskPatchRequest("Test task updated", true, null, dueDate, false, "test"); + TaskResponse patched = taskService.patchTask(taskId, patch); + + assertNotNull(patched); + assertEquals("Test task updated", patched.description()); + assertTrue(patched.done()); + assertNull(patched.dueDate()); + assertNull(patched.dueDateFmt()); + assertFalse(patched.highPriority()); + assertEquals("test", patched.tag()); + assertTrue(patched.urls().isEmpty()); } }