diff --git a/client/src/__test__/components/CompletedTasks.test.tsx b/client/src/__test__/components/CompletedTasks.test.tsx new file mode 100644 index 0000000..e3160ef --- /dev/null +++ b/client/src/__test__/components/CompletedTasks.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { act, render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import api from '../../api-service/api'; +import { TasksChartResponse } from '../../types/TasksChartResponse'; +import CompletedTasks from '../../components/CompletedTasks'; + +// Mock the Chart component +vi.mock('react-charts', () => ({ + Chart: ({ options }) =>
Mocked Chart
+})); + +vi.mock('../../api-service/api'); + +describe('CompletedTasks Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render loading state initially', async () => { + // Create a promise that never resolves + vi.spyOn(api, 'getJSON').mockImplementation(() => new Promise(() => {})); + + act(() => { + render(); + }); + + expect(screen.getByText('Loading...')).toBeDefined(); + }); + + it('should render chart with data after fetching', async () => { + const mockData: TasksChartResponse[] = [ + { day: 'S', count: 5, date: new Date() }, + { day: 'M', count: 10, date: new Date() }, + ]; + vi.spyOn(api, 'getJSON').mockResolvedValue(mockData); + + act(() => { + render(); + }); + + expect(screen.getByText('Loading...')).toBeDefined(); + + await waitForElementToBeRemoved(() => screen.queryByText('Loading...')); + + expect(screen.queryByText('Loading...')).toBeNull(); + expect(screen.getByText('Completed Tasks')).toBeDefined(); + expect(screen.getByText('Summary from the last 7 days')).toBeDefined(); + }); +}); diff --git a/client/src/__test__/components/TaskProgress.test.tsx b/client/src/__test__/components/TaskProgress.test.tsx new file mode 100644 index 0000000..19b67c2 --- /dev/null +++ b/client/src/__test__/components/TaskProgress.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import api from '../../api-service/api'; +import { SummaryResponse } from '../../types/SummaryResponse'; +import TaskProgress from '../../components/TaskProgress'; + +vi.mock('../../api-service/api'); + +describe('TaskProgress Component', () => { + it('should render after fetching', async () => { + const mockData: SummaryResponse = { + pendingTaskCount: 354, + doneTaskCount: 555, + notesCount: 2222 + }; + const mockedGetJSON = vi.spyOn(api, 'getJSON').mockResolvedValue(mockData); + + act(() => { + render(); + }); + + expect(mockedGetJSON).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByText('Total')).toBeDefined(); + expect(screen.queryByText(mockData.pendingTaskCount + mockData.doneTaskCount)).toBeDefined(); + expect(screen.queryByText('Pending')).toBeDefined(); + expect(screen.queryByText(mockData.pendingTaskCount)).toBeDefined(); + expect(screen.queryByText('Completed')).toBeDefined(); + expect(screen.queryByText(mockData.doneTaskCount)).toBeDefined(); + }); + }); +}); diff --git a/client/src/__test__/setup.ts b/client/src/__test__/setup.ts index bc5ffd9..a851609 100644 --- a/client/src/__test__/setup.ts +++ b/client/src/__test__/setup.ts @@ -1,5 +1,6 @@ -import { afterEach } from 'vitest'; +import { afterEach, beforeEach } from 'vitest'; import { cleanup } from '@testing-library/react'; +import { vi } from 'vitest'; // runs a cleanup after each test case (e.g. clearing jsdom) afterEach(() => { @@ -21,3 +22,31 @@ class ResizeObserver { } window.ResizeObserver = window.ResizeObserver || ResizeObserver; + +// Mock fetch globally +global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([]), + text: vi.fn().mockResolvedValue(''), + blob: vi.fn().mockResolvedValue(new Blob()), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + headers: new Headers(), + status: 200, + statusText: 'OK', +}); + +beforeEach(() => { + vi.resetAllMocks(); + + // Reset the fetch mock with default successful response + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([]), + text: vi.fn().mockResolvedValue(''), + blob: vi.fn().mockResolvedValue(new Blob()), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + headers: new Headers(), + status: 200, + statusText: 'OK', + }); +}); diff --git a/client/src/components/CompletedTasks/index.tsx b/client/src/components/CompletedTasks/index.tsx index 4fb9968..3cd484e 100644 --- a/client/src/components/CompletedTasks/index.tsx +++ b/client/src/components/CompletedTasks/index.tsx @@ -1,60 +1,49 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Col, Row } from 'react-bootstrap'; import { AxisOptions, Chart } from 'react-charts'; +import { TasksChartResponse } from '../../types/TasksChartResponse'; +import api from '../../api-service/api'; +import ApiConfig from '../../api-service/apiConfig'; import './style.css'; -type DailyStars = { - date: string; - stars: number; -}; type Series = { label: string; - data: DailyStars[]; + data: TasksChartResponse[]; }; -const data: Series[] = [ - { - label: 'Completed tasks', - data: [ - { - date: 'S', // Sunday - stars: 3 - }, - { - date: 'M', // Monday - stars: 5 - }, - { - date: 'T', // Tuesday - stars: 2 - }, - { - date: 'W', // Wednesday - stars: 4 - }, - { - date: 'T', // Thursday - stars: 8 - }, - { - date: 'F', // Friday - stars: 6 - }, - { - date: 'S', // Saturday - stars: 4 - } - ] - } -]; function CompletedTasks(): React.ReactNode { - const primaryAxis = React.useMemo((): AxisOptions => ({ - getValue: datum => datum.date + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + const primaryAxis = useMemo((): AxisOptions => ({ + getValue: datum => datum.day }), []); - const secondaryAxes = React.useMemo((): AxisOptions[] => [{ - getValue: datum => datum.stars + + const secondaryAxes = useMemo((): AxisOptions[] => [{ + getValue: datum => datum.count }], []); + const getChartData = async (): Promise => { + try { + const response: TasksChartResponse[] = await api.getJSON(`${ApiConfig.homeUrl}/completed-tasks-chart`); + const chartData: Series[] = [{ + label: 'Completed Tasks', + data: response + }]; + setData(chartData); + } + catch (error) { + console.error('Error fetching chart data:', error); + } + finally { + setLoading(false); + } + }; + + useEffect(() => { + getChartData(); + }, []); + return (
@@ -75,7 +64,9 @@ function CompletedTasks(): React.ReactNode { - + {loading + ?
Loading...
+ : }
diff --git a/client/src/components/TaskProgress/index.tsx b/client/src/components/TaskProgress/index.tsx index 60705d9..08f1501 100644 --- a/client/src/components/TaskProgress/index.tsx +++ b/client/src/components/TaskProgress/index.tsx @@ -1,8 +1,43 @@ -import React from 'react'; -import './style.css'; +import React, { useEffect, useState } from 'react'; import { Col, Row } from 'react-bootstrap'; +import { SummaryResponse } from '../../types/SummaryResponse'; +import api from '../../api-service/api'; +import ApiConfig from '../../api-service/apiConfig'; +import './style.css'; +/** + * Task progress component. + * + * This component displays the progress of tasks. + * + * @returns {React.ReactNode} The task progress component. + */ function TaskProgress(): React.ReactNode { + const [completedTasks, setCompletedTasks] = useState(0); + const [pendingTasks, setPendingTasks] = useState(0); + const [totalTasks, setTotalTasks] = useState(); + + /** + * Fetches the tasks progress. + */ + const fetchTasksProgress = async (): Promise => { + try { + const response: SummaryResponse = await api.getJSON(`${ApiConfig.homeUrl}/summary`); + const { doneTaskCount } = response; + const { pendingTaskCount } = response; + setCompletedTasks(doneTaskCount); + setPendingTasks(pendingTaskCount); + setTotalTasks(doneTaskCount + pendingTaskCount); + } + catch (e) { + console.error(e); + } + }; + + useEffect(() => { + fetchTasksProgress(); + }, []); + return (
@@ -36,7 +71,7 @@ function TaskProgress(): React.ReactNode {
Total
-
2
+
{totalTasks}
@@ -60,7 +95,7 @@ function TaskProgress(): React.ReactNode {
Pending
-
4
+
{pendingTasks}
@@ -73,7 +108,7 @@ function TaskProgress(): React.ReactNode {
Completed
-
6
+
{completedTasks}
diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx index 2868678..cc6df25 100644 --- a/client/src/context/AuthProvider.tsx +++ b/client/src/context/AuthProvider.tsx @@ -28,6 +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') { console.warn(e.message); } diff --git a/client/src/types/SummaryResponse.ts b/client/src/types/SummaryResponse.ts new file mode 100644 index 0000000..8bbd378 --- /dev/null +++ b/client/src/types/SummaryResponse.ts @@ -0,0 +1,5 @@ +export type SummaryResponse = { + pendingTaskCount: number; + doneTaskCount: number; + notesCount: number; +}; diff --git a/client/src/types/TasksChartResponse.ts b/client/src/types/TasksChartResponse.ts new file mode 100644 index 0000000..a3b4f75 --- /dev/null +++ b/client/src/types/TasksChartResponse.ts @@ -0,0 +1,5 @@ +export type TasksChartResponse = { + date: Date; + day: string; + count: number; +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 12902cf..a3b2567 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,7 +28,7 @@ services: SERVER_SERVLET_CONTEXT_PATH: / ports: - "8585:8585" - image: server:latest + image: server:candidate db: container_name: db diff --git a/server/src/main/java/br/com/tasknoteapp/server/config/SecurityConfig.java b/server/src/main/java/br/com/tasknoteapp/server/config/SecurityConfig.java index e24ea80..a4e60dc 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/config/SecurityConfig.java +++ b/server/src/main/java/br/com/tasknoteapp/server/config/SecurityConfig.java @@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.config; import br.com.tasknoteapp.server.filter.JwtAuthenticationFilter; import br.com.tasknoteapp.server.service.UserService; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -53,6 +54,13 @@ public class SecurityConfig { .permitAll()) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) + .exceptionHandling( + exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + (request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized: " + authException.getMessage()); + })) .authenticationProvider(authenticationProvider()); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java index a862c95..e1a5d9f 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java @@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.controller; import br.com.tasknoteapp.server.response.SearchResponse; import br.com.tasknoteapp.server.response.SummaryResponse; +import br.com.tasknoteapp.server.response.TasksChartResponse; import br.com.tasknoteapp.server.service.HomeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,6 +11,7 @@ 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 java.util.List; import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -43,8 +45,8 @@ public class HomeController { mediaType = "application/json", schema = @Schema(implementation = SummaryResponse.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), }) public SummaryResponse getSummary() { @@ -69,8 +71,8 @@ public class HomeController { mediaType = "application/json", schema = @Schema(implementation = SearchResponse.class, type = "array"))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))) }) public SearchResponse search( @@ -84,4 +86,24 @@ public class HomeController { String term) { return homeService.search(term); } + + /** + * Get the data for the completed tasks chart. + * + * @return List of TasksChartResponse with the data. + */ + @GetMapping("/completed-tasks-chart") + @Operation( + summary = "Get completed tasks chart", + description = "Get the data for the completed tasks chart.", + responses = { + @ApiResponse(responseCode = "200", description = "Data successfully retrieved"), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Access Denied", + content = @Content(schema = @Schema(implementation = Void.class))) + }) + public List getTasksChart() { + return homeService.getTasksChartData(); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java index 49988de..fad579a 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java @@ -53,8 +53,8 @@ public class NoteController { mediaType = "application/json", schema = @Schema(implementation = NoteResponse.class, type = "array"))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))) }) public List getAllNotes() { @@ -82,8 +82,8 @@ public class NoteController { mediaType = "application/json", schema = @Schema(implementation = NoteResponse.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", @@ -132,8 +132,8 @@ public class NoteController { description = "Wrong or missing information", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), }) public ResponseEntity postNotes( @@ -163,8 +163,8 @@ public class NoteController { description = "Note successfully deleted", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java index fb7b34a..ba9becc 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java @@ -53,8 +53,8 @@ public class TaskController { mediaType = "application/json", schema = @Schema(implementation = TaskResponse.class, type = "array"))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))) }) public List getAllTasks() { @@ -77,8 +77,8 @@ public class TaskController { responseCode = "200", description = "Return the found Task and its urls, if any."), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", @@ -118,8 +118,8 @@ public class TaskController { mediaType = "application/json", schema = @Schema(implementation = TaskResponse.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", @@ -168,8 +168,8 @@ public class TaskController { description = "Wrong or missing information", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), }) public ResponseEntity postTasks( @@ -199,8 +199,8 @@ public class TaskController { description = "Task successfully deleted", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java index ea1b33d..671b515 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java @@ -45,8 +45,8 @@ public class UserController { mediaType = "application/json", schema = @Schema(implementation = UserResponse.class, type = "array"))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), }) public List getAllUsers() { @@ -66,8 +66,8 @@ public class UserController { mediaType = "application/json", schema = @Schema(implementation = UserResponse.class))), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))), @ApiResponse( responseCode = "404", diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java index 38eafd2..f52f7f1 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java @@ -39,8 +39,8 @@ public class UserSessionController { responses = { @ApiResponse(responseCode = "200", description = "Session successfully refreshed"), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))) }) public JwtAuthenticationResponse refresh() { @@ -59,8 +59,8 @@ public class UserSessionController { responses = { @ApiResponse(responseCode = "200", description = "Account successfully deleted"), @ApiResponse( - responseCode = "403", - description = "Forbidden. Access Denied", + responseCode = "401", + description = "Unauthorized. Access Denied", content = @Content(schema = @Schema(implementation = Void.class))) }) public UserResponse deleteAccount() { diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDone.java b/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDone.java new file mode 100644 index 0000000..af2a97e --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDone.java @@ -0,0 +1,25 @@ +package br.com.tasknoteapp.server.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** This class represents a done task for a user in the database. */ +@Data +@Entity +@ToString +@Table(name = "user_tasks_done") +@EqualsAndHashCode +public class UserTasksDone { + + @EmbeddedId private UserTasksDonePk id; + + @Column(name = "done_date") + private LocalDateTime doneDate; +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDonePk.java b/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDonePk.java new file mode 100644 index 0000000..c6b3d80 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/UserTasksDonePk.java @@ -0,0 +1,20 @@ +package br.com.tasknoteapp.server.entity; + +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** This class represents a UserTasksDone primary key. */ +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Embeddable +public class UserTasksDonePk { + + private Long userId; + + private Long taskId; +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/exception/UserForbiddenException.java b/server/src/main/java/br/com/tasknoteapp/server/exception/UserForbiddenException.java index 37ff35d..9bf96c2 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/exception/UserForbiddenException.java +++ b/server/src/main/java/br/com/tasknoteapp/server/exception/UserForbiddenException.java @@ -9,6 +9,6 @@ import org.springframework.web.server.ResponseStatusException; public class UserForbiddenException extends ResponseStatusException { public UserForbiddenException() { - super(HttpStatus.FORBIDDEN, "User not authorized"); + super(HttpStatus.FORBIDDEN, "Forbidden content for this User."); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java index 2274e55..4660252 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java +++ b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java @@ -36,34 +36,58 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { throws ServletException, IOException { final String authorizationHeader = request.getHeader("Authorization"); - if (Objects.isNull(authorizationHeader) || authorizationHeader.isBlank()) { + // Skip authentication for paths that don't require it + String requestPath = request.getServletPath(); + if (requestPath.startsWith("/auth/") || !requestPath.startsWith("/rest/")) { filterChain.doFilter(request, response); return; } - String jwtToken = authorizationHeader; - if (authorizationHeader.startsWith("Bearer ")) { - jwtToken = authorizationHeader.substring(7); + // For protected paths, require valid authentication + if (Objects.isNull(authorizationHeader) || authorizationHeader.isBlank()) { + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized: Missing authentication token"); + return; } - String email = jwtService.getEmailFromToken(jwtToken); - if (!Objects.isNull(email) - && Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) { + try { + String jwtToken = authorizationHeader; + + if (authorizationHeader.startsWith("Bearer ")) { + jwtToken = authorizationHeader.substring(7); + } + + String email = jwtService.getEmailFromToken(jwtToken); + + if (Objects.isNull(email)) { + throw new ServletException("Invalid token: email not found"); + } + UserDetails user = userService.userDetailsService().loadUserByUsername(email); - if (jwtService.validateTokenAndUser(jwtToken, user)) { - SecurityContext context = SecurityContextHolder.createEmptyContext(); - - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - user.getUsername(), user.getPassword(), user.getAuthorities()); - - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - context.setAuthentication(authToken); - SecurityContextHolder.setContext(context); + if (!jwtService.validateTokenAndUser(jwtToken, user)) { + throw new ServletException("Invalid token for user"); } - } - filterChain.doFilter(request, response); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + user.getUsername(), user.getPassword(), user.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + context.setAuthentication(authToken); + SecurityContextHolder.setContext(context); + + filterChain.doFilter(request, response); + } + catch (Exception e) { + log.error("Error authenticating user", e); + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized: " + e.getMessage()); + return; + } } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/UserTasksDoneRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/UserTasksDoneRepository.java new file mode 100644 index 0000000..e0106a9 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/UserTasksDoneRepository.java @@ -0,0 +1,13 @@ +package br.com.tasknoteapp.server.repository; + +import br.com.tasknoteapp.server.entity.UserTasksDone; +import br.com.tasknoteapp.server.entity.UserTasksDonePk; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +/** This interface contains methods to access the user done tasks table in the database. */ +public interface UserTasksDoneRepository extends JpaRepository { + + List findAllByDoneDateAfterAndId_userId(LocalDateTime date, Long userId); +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/TasksChartResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/TasksChartResponse.java new file mode 100644 index 0000000..3cde387 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/response/TasksChartResponse.java @@ -0,0 +1,11 @@ +package br.com.tasknoteapp.server.response; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "This record represents the data for the completed tasks chart.") +public record TasksChartResponse( + @Schema(description = "The full date.") LocalDateTime date, + @Schema(description = "The day. Fri, Sat, Mon, and so on.", example = "Fri") String day, + @Schema(description = "The amount for the day", example = "1") Integer count) {} 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 9fdddc7..dca1aca 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 @@ -157,13 +157,13 @@ public class AuthService { Optional currentUserEmail = authUtil.getCurrentUserEmail(); if (currentUserEmail.isEmpty()) { log.error("Unable to get current user from the request"); - throw new UserForbiddenException(); + throw new UserNotFoundException(); } Optional currentUserOpt = findByEmail(currentUserEmail.get()); if (currentUserOpt.isEmpty()) { log.error("Unable to find user by email with value: {}", currentUserEmail.get()); - throw new UserForbiddenException(); + throw new UserNotFoundException(); } UserEntity currentUser = currentUserOpt.get(); diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java index b5db9e4..3aed389 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java @@ -1,10 +1,22 @@ package br.com.tasknoteapp.server.service; +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.entity.UserTasksDone; +import br.com.tasknoteapp.server.repository.UserTasksDoneRepository; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.SearchResponse; import br.com.tasknoteapp.server.response.SummaryResponse; import br.com.tasknoteapp.server.response.TaskResponse; +import br.com.tasknoteapp.server.response.TasksChartResponse; +import br.com.tasknoteapp.server.util.AuthUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +31,12 @@ public class HomeService { private final NoteService noteService; + private final UserTasksDoneRepository userTasksDoneRepository; + + private final AuthUtil authUtil; + + private final AuthService authService; + /** * Get summary for the home page. * @@ -53,4 +71,67 @@ public class HomeService { return new SearchResponse(tasks, notes); } + + /** + * Get the data for the completed tasks chart. + * + * @return List of TasksChartResponse. + */ + public List getTasksChartData() { + Optional currentUserEmail = authUtil.getCurrentUserEmail(); + String email = currentUserEmail.orElseThrow(); + UserEntity user = authService.findByEmail(email).orElseThrow(); + + LocalDateTime date = LocalDateTime.now(); + List tasks = + userTasksDoneRepository.findAllByDoneDateAfterAndId_userId( + date.minusDays(8L), user.getId()); + log.info("Tasks finished in the last 7 days: {}", tasks.size()); + + if (tasks.isEmpty()) { + return createListFromDate(date); + } + + Map dataMap = new HashMap<>(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + for (UserTasksDone taskDone : tasks) { + String formattedDateTime = taskDone.getDoneDate().format(formatter); + dataMap.putIfAbsent(formattedDateTime, 0); + dataMap.put(formattedDateTime, dataMap.get(formattedDateTime) + 1); + } + + for (int i = 0; i < 7; i++) { + LocalDateTime dateUpdated = date.minusDays(i); + String formattedDateTime = dateUpdated.format(formatter); + dataMap.putIfAbsent(formattedDateTime, 0); + } + + List chartData = new ArrayList<>(); + for (Map.Entry entry : dataMap.entrySet()) { + log.info("Day: {}, Count: {}", entry.getKey(), entry.getValue()); + LocalDate parsedDate = LocalDate.parse(entry.getKey(), formatter); + chartData.add( + new TasksChartResponse( + parsedDate.atStartOfDay(), + getDayOfWeek(parsedDate.atStartOfDay()), + entry.getValue())); + } + + chartData.sort((t1, t2) -> t2.date().compareTo(t1.date())); + + return chartData; + } + + private List createListFromDate(LocalDateTime date) { + List list = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + LocalDateTime dateUpdated = date.minusDays(i); + list.add(new TasksChartResponse(dateUpdated, getDayOfWeek(dateUpdated), 0)); + } + return list; + } + + private String getDayOfWeek(LocalDateTime date) { + return date.getDayOfWeek().toString().substring(0, 3); + } } 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 e010a27..7e5c10d 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 @@ -4,9 +4,12 @@ import br.com.tasknoteapp.server.entity.TaskEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntityPk; import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.entity.UserTasksDone; +import br.com.tasknoteapp.server.entity.UserTasksDonePk; import br.com.tasknoteapp.server.exception.TaskNotFoundException; import br.com.tasknoteapp.server.repository.TaskRepository; import br.com.tasknoteapp.server.repository.TaskUrlRepository; +import br.com.tasknoteapp.server.repository.UserTasksDoneRepository; import br.com.tasknoteapp.server.request.TaskPatchRequest; import br.com.tasknoteapp.server.request.TaskRequest; import br.com.tasknoteapp.server.response.TaskResponse; @@ -36,6 +39,8 @@ public class TaskService { private final TaskUrlRepository taskUrlRepository; + private final UserTasksDoneRepository userTasksDoneRepository; + /** * Get all tasks for the current user. * @@ -162,6 +167,22 @@ public class TaskService { log.info("Task patched! Id {}", patchedTask.getId()); + if (taskEntity.getDone()) { + UserTasksDone userTasksDone = new UserTasksDone(); + userTasksDone.setId(new UserTasksDonePk(user.getId(), taskEntity.getId())); + userTasksDone.setDoneDate(LocalDateTime.now()); + userTasksDoneRepository.save(userTasksDone); + log.info("Task done saved in the history! Id {}", taskEntity.getId()); + } else { + log.info("Task undone! Id {}", taskEntity.getId()); + Optional userTasksDone = + userTasksDoneRepository.findById(new UserTasksDonePk(user.getId(), taskEntity.getId())); + if (userTasksDone.isPresent()) { + userTasksDoneRepository.delete(userTasksDone.get()); + log.info("Task undone deleted from history! Id {}", taskEntity.getId()); + } + } + return TaskResponse.fromEntity(patchedTask, getAllTasksUrls(taskId)); } diff --git a/server/src/main/resources/db/migration/V202503052101__add_task_chart.sql b/server/src/main/resources/db/migration/V202503052101__add_task_chart.sql new file mode 100644 index 0000000..8ca14a6 --- /dev/null +++ b/server/src/main/resources/db/migration/V202503052101__add_task_chart.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS tasknote.user_tasks_done ( + user_id INTEGER NOT NULL, + task_id INTEGER NOT NULL, + done_date TIMESTAMP NOT NULL, + CONSTRAINT user_tasks_done_pk PRIMARY KEY (user_id, task_id), + CONSTRAINT user_tasks_done_user_id_fk FOREIGN KEY (user_id) REFERENCES tasknote.users (id), + CONSTRAINT user_tasks_done_task_id_fk FOREIGN KEY (task_id) REFERENCES tasknote.tasks (id) +); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java index 0f68bf0..fdf142e 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java @@ -10,8 +10,10 @@ import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.SearchResponse; import br.com.tasknoteapp.server.response.SummaryResponse; import br.com.tasknoteapp.server.response.TaskResponse; +import br.com.tasknoteapp.server.response.TasksChartResponse; import br.com.tasknoteapp.server.service.HomeService; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.hamcrest.Matchers; import org.junit.jupiter.api.DisplayName; @@ -66,7 +68,7 @@ class HomeControllerTest { .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -101,4 +103,40 @@ class HomeControllerTest { .andExpect(jsonPath("$.notes[0].urls", Matchers.empty())) .andReturn(); } + + @Test + @DisplayName("Get tasks chart data with happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getTasksChart_happyPath_shouldSucceed() throws Exception { + TasksChartResponse responseOne = new TasksChartResponse(LocalDateTime.now(), "Thu", 1); + TasksChartResponse responseTwo = new TasksChartResponse(LocalDateTime.now(), "Fri", 2); + + when(homeService.getTasksChartData()).thenReturn(List.of(responseOne, responseTwo)); + + mockMvc + .perform( + get("/rest/home/completed-tasks-chart") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].day").value("Thu")) + .andExpect(jsonPath("$[0].count").value(1)) + .andExpect(jsonPath("$[1].day").value("Fri")) + .andExpect(jsonPath("$[1].count").value(2)) + .andReturn(); + } + + @Test + @DisplayName("Get tasks chart data user not authorized should fail") + void getTasksChart_userNotAuthorized_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/home/completed-tasks-chart") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andReturn(); + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java index 3d84f2d..8c847ac 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java @@ -80,15 +80,15 @@ class NoteControllerTest { } @Test - @DisplayName("Get all notes with 403 forbidden request should fail") - void getAllNotes_forbidden_shouldFail() throws Exception { + @DisplayName("Get all notes with 401 unauthorized request should fail") + void getAllNotes_unauthorized_shouldFail() throws Exception { mockMvc .perform( get("/rest/notes") .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -158,8 +158,8 @@ class NoteControllerTest { } @Test - @DisplayName("Patch a note via patch request with 403 forbidden exception should fail") - void patchNote_forbidden_shouldFail() throws Exception { + @DisplayName("Patch a note via patch request with 401 unauthorized exception should fail") + void patchNote_unauthorized_shouldFail() throws Exception { Long noteId = 123L; final String payloadJson = @@ -178,7 +178,7 @@ class NoteControllerTest { .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON) .content(payloadJson)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -242,12 +242,12 @@ class NoteControllerTest { } @Test - @DisplayName("Post create note with 403 forbidden request should fail") - void postNotes_forbidden_shouldFail() throws Exception { + @DisplayName("Post create note with 401 unauthorized request should fail") + void postNotes_unauthorized_shouldFail() throws Exception { final String payloadJson = """ { - "description": "Forbidden" + "description": "Any description here" } """; @@ -258,7 +258,7 @@ class NoteControllerTest { .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON) .content(payloadJson)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -281,8 +281,8 @@ class NoteControllerTest { } @Test - @DisplayName("Delete note with 403 request forbidden should fail") - void deleteNote_forbidden_shouldFail() throws Exception { + @DisplayName("Delete note with 401 unauthorized should fail") + void deleteNote_unauthorized_shouldFail() throws Exception { final Long noteId = 453L; mockMvc @@ -291,7 +291,7 @@ class NoteControllerTest { .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java index 86b4705..63be86e 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java @@ -81,15 +81,15 @@ class TaskControllerTest { } @Test - @DisplayName("Get all tasks with 403 forbidden request should fail") - void getAllTasks_forbidden_shouldFail() throws Exception { + @DisplayName("Get all tasks with 401 unauthorized request should fail") + void getAllTasks_unauthorized_shouldFail() throws Exception { mockMvc .perform( get("/rest/tasks") .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -147,8 +147,8 @@ class TaskControllerTest { } @Test - @DisplayName("Get task by id forbidden should fail") - void getTaskById_forbidden_shouldFail() throws Exception { + @DisplayName("Get task by id unauthorized should fail") + void getTaskById_unauthorized_shouldFail() throws Exception { Long taskId = 997L; mockMvc @@ -157,7 +157,7 @@ class TaskControllerTest { .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -236,8 +236,8 @@ class TaskControllerTest { } @Test - @DisplayName("Patch a task via patch request with 403 forbidden exception") - void patchTask_forbidden_shouldFail() throws Exception { + @DisplayName("Patch a task via patch request with 401 unauthorized exception") + void patchTask_unauthorized_shouldFail() throws Exception { Long taskId = 111L; final String payloadJson = @@ -257,7 +257,7 @@ class TaskControllerTest { .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON) .content(payloadJson)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -269,15 +269,7 @@ class TaskControllerTest { TaskResponse taskResponse = new TaskResponse( - 858L, - "Description patched", - false, - true, - null, - null, - "Moments ago", - "tag", - List.of()); + 858L, "Description patched", false, true, null, null, "Moments ago", "tag", List.of()); when(taskService.createTask(request)).thenReturn(taskResponse); final String payloadJson = @@ -324,12 +316,12 @@ class TaskControllerTest { } @Test - @DisplayName("Post create task with 403 forbidden request should fail") - void postTasks_forbidden_shouldFail() throws Exception { + @DisplayName("Post create task with 401 unauthorized request should fail") + void postTasks_unauthorized_shouldFail() throws Exception { final String payloadJson = """ { - "description": "Forbidden" + "description": "Any description here" } """; @@ -340,7 +332,7 @@ class TaskControllerTest { .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON) .content(payloadJson)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -363,8 +355,8 @@ class TaskControllerTest { } @Test - @DisplayName("Delete task with 403 request forbidden should fail") - void deleteTask_forbidden_shouldFail() throws Exception { + @DisplayName("Delete task with 401 unauthorized request should fail") + void deleteTask_unauthorized_shouldFail() throws Exception { final Long taskId = 533L; mockMvc @@ -373,7 +365,7 @@ class TaskControllerTest { .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } 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 ec0118e..f40a537 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 @@ -50,15 +50,15 @@ class UserControllerTest { } @Test - @DisplayName("Get all users with 403 forbidden request should fail") - void getAllUsers_forbidden_shouldFail() throws Exception { + @DisplayName("Get all users with 401 unauthorized request should fail") + void getAllUsers_unauthorized_shouldFail() throws Exception { mockMvc .perform( get("/rest/users") .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } 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 2e8240b..0814c46 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 @@ -47,15 +47,15 @@ class UserSessionControllerTest { } @Test - @DisplayName("Refresh with 403 forbidden request should fail") - void refresh_forbidden_shouldFail() throws Exception { + @DisplayName("Refresh with 401 unauthorized request should fail") + void refresh_unauthorized_shouldFail() throws Exception { mockMvc .perform( get("/rest/user-sessions/refresh") .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } @@ -77,15 +77,15 @@ class UserSessionControllerTest { } @Test - @DisplayName("Delete account with 403 forbidden request should fail") - void deleteAccount_forbidden_shouldFail() throws Exception { + @DisplayName("Delete account with 401 unauthorized request should fail") + void deleteAccount_unauthorized_shouldFail() throws Exception { mockMvc .perform( post("/rest/user-sessions/delete-account") .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isForbidden()) + .andExpect(status().isUnauthorized()) .andReturn(); } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java index 8d58105..c7829b7 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -289,7 +289,7 @@ class AuthServiceTest { when(authUtil.getCurrentUserEmail()).thenReturn(Optional.empty()); Assertions.assertThrows( - UserForbiddenException.class, + UserNotFoundException.class, () -> { authService.getAllUsers(); }); @@ -303,7 +303,7 @@ class AuthServiceTest { when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); Assertions.assertThrows( - UserForbiddenException.class, + UserNotFoundException.class, () -> { authService.getAllUsers(); }); @@ -403,12 +403,13 @@ class AuthServiceTest { 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); - + 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); diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java index 10f2067..2042b92 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java @@ -1,14 +1,24 @@ package br.com.tasknoteapp.server.service; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.entity.UserTasksDone; +import br.com.tasknoteapp.server.entity.UserTasksDonePk; +import br.com.tasknoteapp.server.repository.UserTasksDoneRepository; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.SearchResponse; import br.com.tasknoteapp.server.response.SummaryResponse; import br.com.tasknoteapp.server.response.TaskResponse; +import br.com.tasknoteapp.server.response.TasksChartResponse; +import br.com.tasknoteapp.server.util.AuthUtil; +import java.time.LocalDateTime; 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; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -21,6 +31,12 @@ class HomeServiceTest { @Mock private NoteService noteService; + @Mock private UserTasksDoneRepository userTasksDoneRepository; + + @Mock private AuthUtil authUtil; + + @Mock private AuthService authService; + private HomeService homeService; private List tasks; @@ -28,7 +44,8 @@ class HomeServiceTest { @BeforeEach void setUp() { - homeService = new HomeService(taskService, noteService); + homeService = + new HomeService(taskService, noteService, userTasksDoneRepository, authUtil, authService); TaskResponse task1 = new TaskResponse(2L, "Task 1", false, false, null, null, null, "tag", List.of()); @@ -48,9 +65,9 @@ class HomeServiceTest { SummaryResponse summary = homeService.getSummary(); - assertEquals(2, summary.pendingTaskCount()); - assertEquals(0, summary.doneTaskCount()); - assertEquals(2, summary.notesCount()); + Assertions.assertEquals(2, summary.pendingTaskCount()); + Assertions.assertEquals(0, summary.doneTaskCount()); + Assertions.assertEquals(2, summary.notesCount()); } @Test @@ -61,7 +78,61 @@ class HomeServiceTest { SearchResponse searchResponse = homeService.search(term); - assertEquals(0, searchResponse.tasks().size()); - assertEquals(2, searchResponse.notes().size()); + Assertions.assertEquals(0, searchResponse.tasks().size()); + Assertions.assertEquals(2, searchResponse.notes().size()); + } + + @Test + @DisplayName("Get tasks chart data happy path should succeed") + void getTasksChartData_happyPath_shouldSucceed() { + Long userId = 1L; + Long taskId = 2L; + String userEmail = "user@domain.com"; + LocalDateTime now = LocalDateTime.now(); + String firstDay = now.getDayOfWeek().toString().substring(0, 3); + + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(userEmail)); + + UserEntity userEntity = new UserEntity(); + userEntity.setId(userId); + userEntity.setEmail(userEmail); + when(authService.findByEmail(userEmail)).thenReturn(Optional.of(userEntity)); + + UserTasksDone userTasksDone = new UserTasksDone(); + userTasksDone.setId(new UserTasksDonePk(userId, taskId)); + userTasksDone.setDoneDate(now); + when(userTasksDoneRepository.findAllByDoneDateAfterAndId_userId(any(), any())) + .thenReturn(List.of(userTasksDone)); + + List chartData = homeService.getTasksChartData(); + + Assertions.assertNotNull(chartData); + Assertions.assertEquals(7, chartData.size()); + Assertions.assertEquals(firstDay, chartData.get(0).day()); + } + + @Test + @DisplayName("Get tasks chart data empty data should succeed") + void getTasksChartData_emptyData_shouldSucceed() { + Long userId = 1L; + String userEmail = "user@domain.com"; + LocalDateTime now = LocalDateTime.now(); + String firstDay = now.getDayOfWeek().toString().substring(0, 3); + + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(userEmail)); + + UserEntity userEntity = new UserEntity(); + userEntity.setId(userId); + userEntity.setEmail(userEmail); + when(authService.findByEmail(userEmail)).thenReturn(Optional.of(userEntity)); + + when(userTasksDoneRepository.findAllByDoneDateAfterAndId_userId(any(), any())) + .thenReturn(List.of()); + + List chartData = homeService.getTasksChartData(); + + Assertions.assertNotNull(chartData); + Assertions.assertEquals(7, chartData.size()); + Assertions.assertEquals(firstDay, chartData.get(0).day()); } } 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 6246a01..3c96ec2 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 @@ -10,9 +10,11 @@ import br.com.tasknoteapp.server.entity.TaskEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntityPk; import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.entity.UserTasksDonePk; import br.com.tasknoteapp.server.exception.TaskNotFoundException; import br.com.tasknoteapp.server.repository.TaskRepository; import br.com.tasknoteapp.server.repository.TaskUrlRepository; +import br.com.tasknoteapp.server.repository.UserTasksDoneRepository; import br.com.tasknoteapp.server.request.TaskPatchRequest; import br.com.tasknoteapp.server.request.TaskRequest; import br.com.tasknoteapp.server.response.TaskResponse; @@ -38,6 +40,8 @@ class TaskServiceTest { @Mock TaskUrlRepository taskUrlRepository; + @Mock UserTasksDoneRepository userTasksDoneRepository; + private static final Long USER_ID = 123L; private static final String USER_EMAIL = "test@domain.com"; @@ -46,7 +50,9 @@ class TaskServiceTest { @BeforeEach void setup() { - taskService = new TaskService(taskRepository, authService, authUtil, taskUrlRepository); + taskService = + new TaskService( + taskRepository, authService, authUtil, taskUrlRepository, userTasksDoneRepository); } @Test @@ -345,6 +351,7 @@ class TaskServiceTest { 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)); @@ -354,9 +361,13 @@ class TaskServiceTest { TaskEntity entity = new TaskEntity(); entity.setDescription("Updated description"); entity.setHighPriority(false); + entity.setDone(false); entity.setTag(taskEntity.getTag()); when(taskRepository.save(any())).thenReturn(entity); + 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); TaskResponse patched = taskService.patchTask(taskId, patch);