From c6dd3dd9d58a5b81a4435bb61a7355b3882367d3 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Mon, 21 Apr 2025 18:59:55 -0300 Subject: [PATCH] Feat/321 improve search and results (#419) * bugfix: when reloading the page logged it doesn't go to home * feat: make the alert error dismissible * feat: improve dashboard page. Issue #321 * feat: improve home search tasks. Issue #321 * feat: improve home search. issue #412 * test: fix test cases * chore: improve url - copilot review * test: add unit tests. Issue #321 * chore: fix checkstyle issue * test: add more tests to backend. Issue #321 * chore: fix sonarcloud issues. Issue #321 * test: add frontend tests. Issue #321 * test: add more tests to frontend. Issue #321 * test: add more tests to frontend. Issue #321 * test: add more tests to frontend. Issue #321 --- .../components/SearchResults.test.tsx | 84 +++++ .../src/__test__/components/Sidebar.test.tsx | 2 +- client/src/__test__/views/Home.test.tsx | 31 +- client/src/__test__/views/Home2.test.tsx | 197 +++++++++++ client/src/__test__/views/NoteAdd.test.tsx | 31 +- client/src/__test__/views/TaskAdd.test.tsx | 36 +- client/src/assets/icons8-external-link-30.png | Bin 258 -> 443 bytes client/src/components/AlertError/index.tsx | 29 +- client/src/components/ContentHeader/index.tsx | 4 +- .../src/components/HomeFilterButton/index.tsx | 26 ++ .../components/SearchNoteResults/index.tsx | 80 +++++ client/src/components/SearchResults/index.tsx | 81 +++++ client/src/components/Sidebar/index.tsx | 4 +- client/src/components/Sidebar/style.scss | 4 +- client/src/layout/PrivateLayout/style.css | 4 +- client/src/styles/custom.scss | 49 ++- client/src/styles/theme.scss | 30 ++ client/src/views/About/index.tsx | 2 +- client/src/views/Account/index.tsx | 7 +- client/src/views/Home/index.tsx | 314 +++++++++++++----- client/src/views/Landing/index.tsx | 10 +- client/src/views/Login/index.tsx | 16 +- client/src/views/Note/index.tsx | 9 +- client/src/views/NoteAdd/index.tsx | 25 +- client/src/views/Task/index.tsx | 11 +- client/src/views/TaskAdd/index.tsx | 25 +- .../server/controller/HomeController.java | 61 ++++ .../server/repository/TaskRepository.java | 15 +- .../server/service/HomeService.java | 56 +++- .../server/service/TaskService.java | 51 ++- .../tasknoteapp/server/util/TimeAgoUtil.java | 42 ++- .../server/controller/HomeControllerTest.java | 74 +++++ .../server/service/HomeServiceTest.java | 81 +++++ .../server/service/TaskServiceTest.java | 216 ++++++++++++ .../server/util/TimeAgoUtilTest.java | 60 +++- 35 files changed, 1606 insertions(+), 161 deletions(-) create mode 100644 client/src/__test__/components/SearchResults.test.tsx create mode 100644 client/src/__test__/views/Home2.test.tsx create mode 100644 client/src/components/HomeFilterButton/index.tsx create mode 100644 client/src/components/SearchNoteResults/index.tsx create mode 100644 client/src/components/SearchResults/index.tsx diff --git a/client/src/__test__/components/SearchResults.test.tsx b/client/src/__test__/components/SearchResults.test.tsx new file mode 100644 index 0000000..e3f885c --- /dev/null +++ b/client/src/__test__/components/SearchResults.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SearchResults } from '../../components/SearchResults'; +import { TaskResponse } from '../../types/TaskResponse'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('SearchResults Component', () => { + const mockTaskAction = vi.fn(); + + const sampleTasks: TaskResponse[] = [ + { + id: 1, + description: 'Task 1', + done: false, + highPriority: false, + dueDate: '2023-10-01', + dueDateFmt: '2023-10-01', + lastUpdate: 'Some time ago', + tag: 'tag1', + urls: ['https://example.com'], + }, + { + id: 2, + description: 'Task 2', + done: false, + highPriority: false, + urls: [], + dueDate: '', + dueDateFmt: '', + lastUpdate: 'Some time ago', + tag: 'tag2' + }, + ]; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders "No tasks found" when results array is empty', () => { + render(); + expect(screen.getByText(/No tasks found/i)).toBeDefined(); + }); + + it('renders the correct number of tasks', () => { + render(); + expect(screen.getByText(/2 task\(s\) found/i)).toBeDefined(); + expect(screen.getByText('Task 1')).toBeDefined(); + expect(screen.getByText('Task 2')).toBeDefined(); + }); + + it('triggers taskAction with "done" action when done icon is clicked', () => { + render(); + const doneButton = screen.getByTestId('task-home-result-done-1'); + fireEvent.click(doneButton); + expect(mockTaskAction).toHaveBeenCalledWith('done', sampleTasks[0]); + }); + + it('triggers taskAction with "edit" action when edit icon is clicked', () => { + render(); + const editButton = screen.getByTestId('task-home-result-edit-1'); + fireEvent.click(editButton); + expect(mockTaskAction).toHaveBeenCalledWith('edit', sampleTasks[0]); + }); + + it('triggers taskAction with "delete" action when delete icon is clicked', () => { + render(); + const deleteButton = screen.getByTestId('task-home-result-delete-1'); + fireEvent.click(deleteButton); + expect(mockTaskAction).toHaveBeenCalledWith('delete', sampleTasks[0]); + }); + + it('renders external link icon when task has URLs', () => { + render(); + const externalLink = screen.getByAltText('external link'); + expect(externalLink).toBeDefined(); + // expect(externalLink.closest('a')).toHaveAttribute('href', 'https://example.com'); + }); + + it('renders due date icon when task has a due date', () => { + render(); + const dueDateIcon = screen.getByTitle('2023-10-01'); + expect(dueDateIcon).toBeDefined(); + }); +}); diff --git a/client/src/__test__/components/Sidebar.test.tsx b/client/src/__test__/components/Sidebar.test.tsx index 1bfe75b..f2577f2 100644 --- a/client/src/__test__/components/Sidebar.test.tsx +++ b/client/src/__test__/components/Sidebar.test.tsx @@ -38,7 +38,7 @@ describe('Sidebar Component', () => { - + diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index edc8654..824b848 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -7,6 +7,7 @@ import api from '../../api-service/api'; import Home from '../../views/Home'; import '../../i18n'; import AuthContext from '../../context/AuthContext'; +import { TasksChartResponse } from '../../types/TasksChartResponse'; // Mock the Chart component vi.mock('react-charts', () => ({ @@ -33,14 +34,32 @@ const authContextMock = { updateUser: vi.fn() }; +const mockData: SummaryResponse = { + pendingTaskCount: 354, + doneTaskCount: 555, + notesCount: 2222 +}; + +const mockTags: string[] = ['tag1', 'tag2']; + +const mockChart: TasksChartResponse[] = [ + { day: 'S', count: 5, date: new Date() }, + { day: 'M', count: 10, date: new Date() }, +]; + describe('Renders the home view', () => { it('should render text based on new contentHeader component', async () => { - const mockData: SummaryResponse = { - pendingTaskCount: 354, - doneTaskCount: 555, - notesCount: 2222 - }; - const mockedGetJSON = vi.spyOn(api, 'getJSON').mockResolvedValue(mockData); + + vi.spyOn(api, "getJSON").mockImplementation((url: string) => { + if (url === 'http://localhost:8585/rest/home/tasks/tags') { + return Promise.resolve(mockTags); + } else if (url === 'http://localhost:8585/rest/home/summary') { + return Promise.resolve(mockData); + } else if (url === 'http://localhost:8585/rest/home/completed-tasks-chart') { + return Promise.resolve(mockChart); + } + return Promise.reject(new Error("Unknown endpoint: " + url)); + }); await act(async () => { render( diff --git a/client/src/__test__/views/Home2.test.tsx b/client/src/__test__/views/Home2.test.tsx new file mode 100644 index 0000000..7198ec0 --- /dev/null +++ b/client/src/__test__/views/Home2.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, vi, expect, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import AuthContext from '../../context/AuthContext'; +import Home from '../../views/Home'; +import api from '../../api-service/api'; +import '../../i18n'; +import { TasksChartResponse } from '../../types/TasksChartResponse'; +import { SummaryResponse } from '../../types/SummaryResponse'; +import { TaskResponse } from '../../types/TaskResponse'; +import { NoteResponse } from '../../types/NoteResponse'; + +vi.mock('react-charts', () => ({ + Chart: ({ options }) =>
Mocked Chart
+})); + +vi.mock('../../api-service/api'); + +const mockAuthContext = { + signed: true, + user: { + userId: 1, + name: 'Ricardo', + email: 'ricardo@campos.com', + admin: false, + createdAt: new Date(), + gravatarImageUrl: 'http://image.com' + }, + checkCurrentAuthUser: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), + register: vi.fn(), + isAdmin: false, + updateUser: vi.fn() +}; + +const mockTags = ['work', 'personal']; +const mockTasks: TaskResponse[] = [ + { id: 1, description: 'Task 1', done: false, highPriority: false, dueDate: '', dueDateFmt: '', tag: 'tag1', urls: [], lastUpdate: 'Moments ago' }, + { id: 2, description: 'Task 2', done: false, highPriority: false, dueDate: '', dueDateFmt: '', tag: 'tag2', urls: [], lastUpdate: 'Moments ago' }, +]; + +const mockNotes: NoteResponse[] = [ + { id: 1, title: 'Note 1', description: 'Description 1', url: '' }, + { id: 2, title: 'Note 2', description: 'Description 2', url: '' }, +]; +const mockChart: TasksChartResponse[] = [ + { day: 'S', count: 5, date: new Date() }, + { day: 'M', count: 10, date: new Date() }, +]; +const mockSummary: SummaryResponse = { + pendingTaskCount: 354, + doneTaskCount: 555, + notesCount: 2222 +}; + +describe('Home Component', () => { + beforeEach(() => { + vi.spyOn(api, 'getJSON').mockImplementation((url) => { + if (url.includes('/tasks/tags')) return Promise.resolve(mockTags); + if (url.includes('/tasks/filter')) return Promise.resolve(mockTasks); + if (url.includes('/summary')) return Promise.resolve(mockSummary); + if (url.includes('/completed-tasks-chart')) return Promise.resolve(mockChart); + if (url.includes('/search')) return Promise.resolve({ tasks: mockTasks, notes: mockNotes }); + return Promise.reject(new Error('Unknown endpoint')); + }); + + vi.spyOn(api, 'patchJSON').mockResolvedValue({}); + vi.spyOn(api, 'deleteNoContent').mockResolvedValue({}); + }); + + const renderComponent = () => { + return render( + + + + + + ); + } + + it('renders the component with initial data', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Welcome to TaskNote! Get ready to complete your pending tasks')).toBeDefined(); + expect(screen.getByText('🔥 High Priority')).toBeDefined(); + expect(screen.getByText('#work')).toBeDefined(); + expect(screen.getByText('#personal')).toBeDefined(); + }); + }); + + it('handles search functionality', async () => { + renderComponent(); + + const searchInput = screen.getByPlaceholderText('Search tasks & notes'); + const form = searchInput.closest('form') as HTMLFormElement; + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + fireEvent.submit(form); + }); + + expect(screen.getByText('Task 1')).toBeDefined(); + expect(screen.getByText('Task 2')).toBeDefined(); + expect(screen.getByText('Note 1')).toBeDefined(); + expect(screen.getByText('Note 2')).toBeDefined(); + }); + + it('handles search functionality with error', async () => { + renderComponent(); + + const searchInput = screen.getByPlaceholderText('Search tasks & notes'); + const form = searchInput.closest('form') as HTMLFormElement; + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'a' } }); + fireEvent.submit(form); + }); + + expect(screen.getByText('Please type at least 3 characters')).toBeDefined(); + }); + + it('loads tasks based on filter', async () => { + renderComponent(); + + const highPriorityButton = screen.getByText('🔥 High Priority'); + + await act(async () => { + fireEvent.click(highPriorityButton); + }); + + expect(screen.getByText('Task 1')).toBeDefined(); + expect(screen.getByText('Task 2')).toBeDefined(); + }); + + it('marks a task as done', async () => { + renderComponent(); + + const searchInput = screen.getByPlaceholderText('Search tasks & notes'); + const form = searchInput.closest('form') as HTMLFormElement; + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + fireEvent.submit(form); + }); + + const markAsDoneButton = screen.getByTestId('task-home-result-done-1'); + + await act(async () => { + fireEvent.click(markAsDoneButton!); + }); + + expect(api.patchJSON).toHaveBeenCalledWith(expect.stringContaining('/tasks/1'), expect.objectContaining({ done: true })); + }); + + it('deletes a task', async () => { + renderComponent(); + + const searchInput = screen.getByPlaceholderText('Search tasks & notes'); + const form = searchInput.closest('form') as HTMLFormElement; + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + fireEvent.submit(form); + }); + + const deleteButton = screen.getByTestId('task-home-result-delete-1'); + + await act(async () => { + fireEvent.click(deleteButton!); + }); + + expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/tasks/1')); + }); + + it('opens a note in the modal', async () => { + renderComponent(); + + const searchInput = screen.getByPlaceholderText('Search tasks & notes'); + const form = searchInput.closest('form') as HTMLFormElement; + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + fireEvent.submit(form); + }); + + const openNoteButton = screen.getByTestId('note-home-result-open-1'); + + await act(async () => { + fireEvent.click(openNoteButton!); + }); + + expect(screen.getByText('Description 1')).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 11d5ed4..21960a3 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -9,6 +9,8 @@ import i18n from '../../i18n'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import { NoteResponse } from '../../types/NoteResponse'; +import SidebarContext from '../../context/SidebarContext'; +import { beforeEach } from 'node:test'; vi.mock('../../api-service/api'); @@ -27,6 +29,17 @@ vi.mock('react-i18next', () => ({ I18nextProvider: ({ children }: any) => children, })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +import { useSearchParams } from "react-router-dom"; + const authContextMock = { signed: true, user: { @@ -45,19 +58,31 @@ const authContextMock = { updateUser: vi.fn(), }; +const sidebarContextMock = { + currentPage: '/home', + setNewPage: vi.fn() +}; + describe('NoteAdd Component', () => { const renderNoteAdd = () => { return render( - + + + ); }; + beforeEach(() => { + // Reset mock between tests + (useSearchParams as unknown as ReturnType).mockReset(); + }); + it('should render the NoteAdd component', () => { const { getByText } = renderNoteAdd(); expect(getByText('note_form_title_label')).toBeDefined(); @@ -76,6 +101,10 @@ describe('NoteAdd Component', () => { }); it('should add a new note when form is valid', async () => { + (useSearchParams as unknown as ReturnType).mockReturnValue([ + new URLSearchParams("backTo=home"), + ]); + const { getByLabelText, getByTestId, getByRole } = renderNoteAdd(); const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement; const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; diff --git a/client/src/__test__/views/TaskAdd.test.tsx b/client/src/__test__/views/TaskAdd.test.tsx index 36cd0f3..0cf88d5 100644 --- a/client/src/__test__/views/TaskAdd.test.tsx +++ b/client/src/__test__/views/TaskAdd.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter } from 'react-router'; import { I18nextProvider } from 'react-i18next'; import TaskAdd from '../../views/TaskAdd'; @@ -9,6 +9,7 @@ import i18n from '../../i18n'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import TaskNoteRequest from '../../types/TaskNoteRequest'; +import SidebarContext from '../../context/SidebarContext'; vi.mock('../../api-service/api'); @@ -27,6 +28,17 @@ vi.mock('react-i18next', () => ({ I18nextProvider: ({ children }: any) => children, })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +import { useSearchParams } from "react-router-dom"; + const authContextMock = { signed: true, user: { @@ -45,19 +57,31 @@ const authContextMock = { updateUser: vi.fn(), }; +const sidebarContextMock = { + currentPage: '/home', + setNewPage: vi.fn() +}; + describe('TaskAdd Component', () => { const renderTaskAdd = () => { return render( - + + + ); }; + beforeEach(() => { + // Reset mock between tests + (useSearchParams as unknown as ReturnType).mockReset(); + }); + it('should render the TaskAdd component', () => { const { getByText } = renderTaskAdd(); expect(getByText('task_form_title')).toBeDefined(); @@ -77,6 +101,10 @@ describe('TaskAdd Component', () => { }); it('should add a new task when form is valid', async () => { + (useSearchParams as unknown as ReturnType).mockReturnValue([ + new URLSearchParams("backTo=home"), + ]); + const { getByLabelText, getByRole } = renderTaskAdd(); const descriptionInput = getByLabelText('task_form_desc_label') as HTMLInputElement; const submitButton = getByRole('button', { name: 'task_form_submit' }); @@ -98,6 +126,10 @@ describe('TaskAdd Component', () => { }); it('should render text based on new contentHeader component', () => { + (useSearchParams as unknown as ReturnType).mockReturnValue([ + new URLSearchParams("backTo=home"), + ]); + const { getByText } = renderTaskAdd(); expect(getByText('Add')).toBeDefined(); diff --git a/client/src/assets/icons8-external-link-30.png b/client/src/assets/icons8-external-link-30.png index ed15a3f9e19c0269d16073c051098250891bef9e..49580ab66418148979476cf152c4e17a15f0696d 100644 GIT binary patch delta 396 zcmV;70dxL>0=ol{Bt`*qQb$4o*~u(_00004XF*Lt006O%3;baP0000WV@Og>004R> z004l5008;`004mK004C`008P>0026e000+ooVrmwks%m=2XskIMF;5w6%R8h(p_8T z0003INkla)s1y{z_Xu>%kO#?-QORp`@|J=fx^$D` z-bH-|p)M(Cx#`W#O|y9aEjh{g_;Yedpj9SyQYI5py#gaocxjEk*$uiGO6L7H1fGFG4b~@cXN_JvXaQV0=)H^oQJLeXlu&E* zSCC_1xL=PNHGOf=ci_PX^?_q+ddTSl*d_)0phBt(YxLTk-fc*Y8y~Nb>fFtH)lJwk zV{7z>3(tXma&W*!I;+YN>v-yM8mw6GH?TU?r&WqOy4Am5nONm~omy}U&ZDw++5kf# q)wW^oOU=K0000xR0Vg=`($NWsMz}D+28DQ;C=U;6Sxe)Ee^#JYj~A7=S1Z`08uj|i6`k*~HlvSA gO#yhEe5(FfcbHzSO=GBOod5s;07*qoM6N<$f void; }; /** @@ -12,20 +13,24 @@ type Props = { * @param {Props} props the AlertError props with the message to be displayed. * @param {string} [props.errorMessage] Optional error message. * @param {string} [props.dataTestid] Optional data-testid property. + * @param {Function} [props.onClose] OnClose function to be called. * @returns {React.ReactNode} the AlertError rendered component. */ const AlertError: React.FC = (props: Props): React.ReactNode => { - return props.errorMessage && props.errorMessage.length > 0 - ? ( - - - - { props.errorMessage } - - - - ) - : null; + if (!props.errorMessage || props.errorMessage.length === 0) { + return null; + } + + return ( + + { props.errorMessage } + + ); }; export default AlertError; diff --git a/client/src/components/ContentHeader/index.tsx b/client/src/components/ContentHeader/index.tsx index a89d52b..9e7dc0d 100644 --- a/client/src/components/ContentHeader/index.tsx +++ b/client/src/components/ContentHeader/index.tsx @@ -49,7 +49,7 @@ const ContentHeader: React.FC = (props: Props): React.ReactNode => { {props.isHomeComponent && ( - setNewPage('/tasks/new')}> + setNewPage('/tasks/new')}> - setNewPage('/notes/new')}> + setNewPage('/notes/new')}> + ); +}; + +export default HomeFilterButton; diff --git a/client/src/components/SearchNoteResults/index.tsx b/client/src/components/SearchNoteResults/index.tsx new file mode 100644 index 0000000..dad1698 --- /dev/null +++ b/client/src/components/SearchNoteResults/index.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { ListGroup } from 'react-bootstrap'; +import ExternalLinkIcon from '../../assets/icons8-external-link-30.png'; +import { PencilSquare, Trash } from 'react-bootstrap-icons'; +import { NoteResponse } from '../../types/NoteResponse'; + +interface SearchResultsProps { + results: NoteResponse[]; + noteAction: (action: string, task: NoteResponse) => void; +} + +export const SearchNoteResults: React.FC = ({ results, noteAction }) => { + return ( + <> + {results.length === 0 + ? ( +

No notes found

+ ) + : ( + <> +

+ {results.length} + {' '} + notes(s) found +

+ + {results.map((note: NoteResponse) => ( + + {note.title} + + ) => { + e.preventDefault(); + e.stopPropagation(); + noteAction('open', note); + }} + data-testid={`note-home-result-open-${note.id}`} + > + Open + + + {note.url && note.url.length > 0 && ( + + external link + + )} + + + + ))} + + + )} + + ); +}; diff --git a/client/src/components/SearchResults/index.tsx b/client/src/components/SearchResults/index.tsx new file mode 100644 index 0000000..aedfb62 --- /dev/null +++ b/client/src/components/SearchResults/index.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { TaskResponse } from '../../types/TaskResponse'; +import { ListGroup } from 'react-bootstrap'; +import ExternalLinkIcon from '../../assets/icons8-external-link-30.png'; +import { Check2Square, PencilSquare, Trash } from 'react-bootstrap-icons'; + +interface SearchResultsProps { + results: TaskResponse[]; + taskAction: (action: string, task: TaskResponse) => void; +} + +export const SearchResults: React.FC = ({ results, taskAction }) => { + return ( + <> + {results.length === 0 + ? ( +

No tasks found

+ ) + : ( + <> +

+ {results.length} + {' '} + task(s) found +

+ + {results.map((task: TaskResponse) => ( + + {task.description} + {task.urls && task.urls.length > 0 && ( + + external link + + )} + {task.dueDateFmt && ( + 📅 + )} + + + + ))} + + + )} + + ); +}; diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 50de7fc..5edec75 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -36,7 +36,7 @@ function Sidebar(props: React.PropsWithChildren): React.ReactNode { return ( <> - - - - - - - - - -

{t('home_card_search_result_label')}

- - - {searchResults && searchResults.tasks.length > 0 && ( - searchResults.tasks.map((task: TaskResponse) => ( - - - [Task] - {' '} - {task.description} - - - {task.urls.length > 0 - ? ( - {task.urls[0]} - ) - : 'No URL!'} - - - )) - )} - {searchResults && searchResults.notes.length > 0 && ( - searchResults.notes.map((note: NoteResponse) => ( - - - [Note] - {' '} - {note.title} - - - - { note.description } - - - - )) - )} - {searchResults?.tasks.length === 0 && searchResults?.notes.length === 0 && ( -

{t('home_card_search_empty_result')}

- )} -
- -
+ ); } diff --git a/client/src/views/Landing/index.tsx b/client/src/views/Landing/index.tsx index a55aca0..69c93e8 100644 --- a/client/src/views/Landing/index.tsx +++ b/client/src/views/Landing/index.tsx @@ -1,6 +1,6 @@ import React, { useContext, useEffect } from 'react'; import { Button, Container } from 'react-bootstrap'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { handleDefaultLang } from '../../lang-service/LangHandler'; import { setDefaultLang } from '../../storage-service/storage'; @@ -18,7 +18,8 @@ import './styles.scss'; * @returns {React.ReactNode} The Landing page component. */ function Landing(): React.ReactNode { - const { checkCurrentAuthUser } = useContext(AuthContext); + const { signed, checkCurrentAuthUser } = useContext(AuthContext); + const navigate = useNavigate(); const { i18n, t } = useTranslation(); const handleLanguage = (lang: string): void => { @@ -29,7 +30,10 @@ function Landing(): React.ReactNode { useEffect(() => { checkCurrentAuthUser(window.location.pathname); handleDefaultLang(); - }, []); + if (signed) { + navigate('/home'); + } + }, [signed]); return ( diff --git a/client/src/views/Login/index.tsx b/client/src/views/Login/index.tsx index bc80fd5..18cf69a 100644 --- a/client/src/views/Login/index.tsx +++ b/client/src/views/Login/index.tsx @@ -1,6 +1,8 @@ -import React from 'react'; -import './styles.scss'; +import React, { useContext, useEffect } from 'react'; import LoginForm from '../../components/LoginForm'; +import AuthContext from '../../context/AuthContext'; +import './styles.scss'; +import { useNavigate } from 'react-router'; /** * Login page component. @@ -11,6 +13,16 @@ import LoginForm from '../../components/LoginForm'; * @returns {React.ReactNode} The Login page component. */ function Login(): React.ReactNode { + const { signed, checkCurrentAuthUser } = useContext(AuthContext); + const navigate = useNavigate(); + + useEffect(() => { + checkCurrentAuthUser(window.location.pathname); + if (signed) { + navigate('/home'); + } + }, [signed]); + return ; } diff --git a/client/src/views/Note/index.tsx b/client/src/views/Note/index.tsx index 209be38..7e1e7cc 100644 --- a/client/src/views/Note/index.tsx +++ b/client/src/views/Note/index.tsx @@ -167,7 +167,7 @@ function Note(): React.ReactNode { }, []); return ( - + - + setErrorMessage('')} + /> @@ -191,7 +194,7 @@ function Note(): React.ReactNode { /> - +
diff --git a/client/src/views/Task/index.tsx b/client/src/views/Task/index.tsx index e8e8a24..9791bdc 100644 --- a/client/src/views/Task/index.tsx +++ b/client/src/views/Task/index.tsx @@ -160,7 +160,7 @@ function Task(): React.ReactNode { }, []); return ( - + - + setErrorMessage('')} + /> @@ -184,7 +187,7 @@ function Task(): React.ReactNode { /> - +
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 e1a5d9f..76857c1 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.TaskResponse; import br.com.tasknoteapp.server.response.TasksChartResponse; import br.com.tasknoteapp.server.service.HomeService; import io.swagger.v3.oas.annotations.Operation; @@ -14,6 +15,7 @@ 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.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -106,4 +108,63 @@ public class HomeController { public List getTasksChart() { return homeService.getTasksChartData(); } + + /** + * Get the tasks given a filter. + * + * @returns List of TaskResponse with found tasks. + */ + @GetMapping("/tasks/filter/{filter}") + @Operation( + summary = "Get the tasks given a filter", + description = "Get the tasks given a filter which can be high | all | tag", + responses = { + @ApiResponse( + responseCode = "200", + description = "Found tasks or empty array", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = TaskResponse.class, type = "array"))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Access Denied", + content = @Content(schema = @Schema(implementation = Void.class))) + }) + public List tasksByFilter( + @Parameter( + name = "filter", + in = ParameterIn.PATH, + description = "Task filter key. One of high, all, tag", + required = true) + @PathVariable + String filter) { + return homeService.getTasksByFilter(filter); + } + + /** + * Get the top 5 tags. + * + * @returns List of String with the tags. + */ + @GetMapping("/tasks/tags") + @Operation( + summary = "Get the top 5 tags", + description = "Get the top 5 tags or the ones in use", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of tags or an empty list", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = String.class, type = "array"))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Access Denied", + content = @Content(schema = @Schema(implementation = Void.class))) + }) + public List getTasksTags() { + return homeService.getTopTasksTag(); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java index d652b42..5414ded 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java @@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.TaskEntity; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; /** This interface represents a task repository, for database access. */ public interface TaskRepository extends JpaRepository { @@ -11,6 +12,16 @@ public interface TaskRepository extends JpaRepository { List findAllByUser_id(Long userId); @Query( - "select t from TaskEntity t where upper(t.description) like upper(%?1%) and t.user.id = ?2") - List findAllBySearchTerm(String searchTerm, Long userId); + """ + select distinct t + from TaskEntity t + left join TaskUrlEntity tu on tu.id.taskId = t.id + where ( + upper(t.description) like upper(concat('%', :searchTerm, '%')) or + upper(t.tag) like upper(concat('%', :searchTerm, '%')) or + upper(tu.id.url) like upper(concat('%', :searchTerm, '%')) + ) and t.user.id = :userId and t.done = false + """) + List findAllBySearchTerm( + @Param("searchTerm") String searchTerm, @Param("userId") Long userId); } 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 83c8e48..b34cd4b 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 @@ -16,9 +16,11 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,6 +43,8 @@ public class HomeService { private final NotesCreatedRepository notesCreatedRepository; + private static final String N_TASKS_FOUND = "{} tasks found!"; + /** * Get summary for the home page. * @@ -78,7 +82,7 @@ public class HomeService { log.info("Searching for {}", term); List tasks = taskService.searchTasks(term); - log.info("{} tasks found!", tasks.size()); + log.info(N_TASKS_FOUND, tasks.size()); List notes = noteService.searchNotes(term); log.info("{} notes found!", notes.size()); @@ -136,6 +140,56 @@ public class HomeService { return chartData; } + /** + * Get tasks by a given filter. + * + * @param filter The filter to get the tasks. + * @return {@link List} of {@link TaskResponse} with found records or an empty list. + */ + public List getTasksByFilter(String filter) { + log.info("Getting tasks by filter for filter: {}", filter); + + List tasks = taskService.getTasksByFilter(filter); + log.info(N_TASKS_FOUND, tasks.size()); + + return tasks; + } + + /** + * Get up to 5 most used tags. + * + * @return List of String with the tags. + */ + public List getTopTasksTag() { + log.info("Getting top tags for the tasks"); + + List tasks = taskService.getTasksByFilter("all"); + log.info(N_TASKS_FOUND, tasks.size()); + + Map tagsCount = new HashMap<>(); + for (TaskResponse task : tasks) { + if (tagsCount.size() == 5) { + break; + } + + String tag = task.tag(); + if (tag.isBlank()) { + tag = "untagged"; + } + tagsCount.putIfAbsent(tag, 0); + tagsCount.put(tag, tagsCount.get(tag) + 1); + } + + Map sortedDesc = + tagsCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .collect( + Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new)); + + return sortedDesc.keySet().stream().toList(); + } + private List createListFromDate(LocalDateTime date) { List list = new ArrayList<>(); for (int i = 0; i < 7; i++) { 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 00e41cf..b457a11 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 @@ -133,9 +133,9 @@ public class TaskService { if (!Objects.isNull(patch.done())) { taskEntity.setDone(patch.done()); } - + patchDueDate(taskEntity, patch); - + taskEntity.setHighPriority(false); if (!Objects.isNull(patch.highPriority())) { taskEntity.setHighPriority(patch.highPriority()); @@ -212,6 +212,10 @@ public class TaskService { log.info("Searching tasks to user {}", user.getId()); + if (Objects.isNull(searchTerm) || searchTerm.isBlank()) { + return List.of(); + } + List tasks = taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId()); log.info("{} tasks found!", tasks.size()); @@ -221,6 +225,49 @@ public class TaskService { .toList(); } + /** + * Get tasks by a given filter. + * + * @param filter The filter to get the tasks. + * @return {@link List} of {@link TaskResponse} with found records or an empty list. + */ + public List getTasksByFilter(String filter) { + UserEntity user = getCurrentUser(); + + List allTasks = + taskRepository.findAllByUser_id(user.getId()).stream() + .filter(t -> t.getDone().equals(Boolean.FALSE)) + .toList(); + if (allTasks.isEmpty()) { + return List.of(); + } + + if (filter.equals("all")) { + return allTasks.stream() + .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) + .toList(); + } + + if (filter.equals("high")) { + return allTasks.stream() + .filter(TaskEntity::getHighPriority) + .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) + .toList(); + } + + if (filter.equals("untagged")) { + return allTasks.stream() + .filter(t -> t.getTag() == null || t.getTag().isBlank()) + .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) + .toList(); + } + + return allTasks.stream() + .filter(t -> t.getTag().equals(filter)) + .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) + .toList(); + } + private UserEntity getCurrentUser() { Optional currentUserEmail = authUtil.getCurrentUserEmail(); String email = currentUserEmail.orElseThrow(); diff --git a/server/src/main/java/br/com/tasknoteapp/server/util/TimeAgoUtil.java b/server/src/main/java/br/com/tasknoteapp/server/util/TimeAgoUtil.java index 20c29ad..a37d91f 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/util/TimeAgoUtil.java +++ b/server/src/main/java/br/com/tasknoteapp/server/util/TimeAgoUtil.java @@ -4,6 +4,8 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Period; +import java.time.format.TextStyle; +import java.util.Locale; import java.util.Objects; /** This class contains util methods to format local date time. */ @@ -60,23 +62,47 @@ public class TimeAgoUtil { return null; } + StringBuilder sb = new StringBuilder(); + // Format should be: yyyy-MM-dd Period period = Period.between(LocalDate.now(), futureDate); if (period.getYears() > 1) { - return String.format("%d years left", period.getYears()); + sb.append(String.format("%d years left", period.getYears())); } else if (period.getYears() > 0) { - return String.format("%d year left", period.getYears()); + sb.append(String.format("%d year left", period.getYears())); } else if (period.getMonths() > 1) { - return String.format("%d months left", period.getMonths()); + sb.append(String.format("%d months left", period.getMonths())); } else if (period.getMonths() > 0) { - return String.format("%d month left", period.getMonths()); + sb.append(String.format("%d month left", period.getMonths())); } else if (period.getDays() > 1) { - return String.format("%d days left", period.getDays()); + sb.append(String.format("%d days left", period.getDays())); } else if (period.getDays() > 0) { - return String.format("%d day left", period.getDays()); + sb.append(String.format("%d day left", period.getDays())); } else if (period.getDays() == 0) { - return String.format("0 days left", period.getDays()); + sb.append("0 days left"); + } else { + sb.append("Due"); } - return "Due date"; + + String dayOfWeek = futureDate.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.ENGLISH); + int dayOfMonth = futureDate.getDayOfMonth(); + String suffix = getDaySuffix(dayOfMonth); + String month = futureDate.getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH); + int year = futureDate.getYear(); + String dateFmt = String.format(" (%s %d%s, %s %d)", dayOfWeek, dayOfMonth, suffix, month, year); + + return sb.toString() + dateFmt; + } + + private static String getDaySuffix(int day) { + if (day >= 11 && day <= 13) { + return "th"; + } + return switch (day % 10) { + case 1 -> "st"; + case 2 -> "nd"; + case 3 -> "rd"; + default -> "th"; + }; } } 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 deb09cb..6bcfa20 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 @@ -139,4 +139,78 @@ class HomeControllerTest { .andExpect(status().isUnauthorized()) .andReturn(); } + + @Test + @DisplayName("Get tasks by filter using the filter all it should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void tasksByFilter_allTasks_shouldSucceed() throws Exception { + String filter = "all"; + TaskResponse taskResponse = + new TaskResponse( + 1L, "Desc", false, true, null, null, "Moments ago", "tag", List.of("http://test.com")); + when(homeService.getTasksByFilter(filter)).thenReturn(List.of(taskResponse)); + + mockMvc + .perform( + get("/rest/home/tasks/filter/{filter}", filter) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(taskResponse.id())) + .andExpect(jsonPath("$[0].description").value(taskResponse.description())) + .andExpect(jsonPath("$[0].done", Matchers.is(false))) + .andExpect(jsonPath("$[0].highPriority", Matchers.is(true))) + .andExpect(jsonPath("$[0].dueDate", Matchers.nullValue())) + .andExpect(jsonPath("$[0].dueDateFmt", Matchers.nullValue())) + .andExpect(jsonPath("$[0].lastUpdate").value("Moments ago")) + .andExpect(jsonPath("$[0].urls[0]").value(taskResponse.urls().get(0))) + .andReturn(); + } + + @Test + @DisplayName("Get tasks by filter not authorized it should fail") + void tasksByFilter_notAuthorized_shouldFail() throws Exception { + String filter = "all"; + + mockMvc + .perform( + get("/rest/home/tasks/filter/{filter}", filter) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andReturn(); + } + + @Test + @DisplayName("Get task tags following the happy path it should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getTasksTags_happyPath_shouldSucceed() throws Exception { + when(homeService.getTopTasksTag()).thenReturn(List.of("tag1", "tag2")); + + mockMvc + .perform( + get("/rest/home/tasks/tags") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0]").value("tag1")) + .andExpect(jsonPath("$[1]").value("tag2")) + .andReturn(); + } + + @Test + @DisplayName("Get task tags not authorized it should fail") + void getTasksTags_notAuthorized_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/rest/home/tasks/tags") + .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/service/HomeServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java index 91b6805..e5099bc 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 @@ -156,4 +156,85 @@ class HomeServiceTest { Assertions.assertEquals(7, chartData.size()); Assertions.assertEquals(firstDay, chartData.get(0).day()); } + + @Test + @DisplayName("Get tasks by filter high priority tasks it should succeed") + void getTasksByFilter_highTasks_shouldSucceed() { + String filter = "high"; + + TaskResponse highTask1 = + new TaskResponse(2L, "Task 1", false, true, null, null, null, "tag", List.of()); + when(taskService.getTasksByFilter(filter)).thenReturn(List.of(highTask1)); + + List list = homeService.getTasksByFilter(filter); + + Assertions.assertNotNull(list); + Assertions.assertEquals(1, list.size()); + Assertions.assertTrue(list.get(0).highPriority()); + } + + @Test + @DisplayName("Get top tasks tag should return up to 5 most used tags") + void getTopTasksTag_shouldReturnTopTags() { + TaskResponse task1 = + new TaskResponse(1L, "Task 1", false, false, null, null, null, "tag1", List.of()); + TaskResponse task2 = + new TaskResponse(2L, "Task 2", false, false, null, null, null, "tag2", List.of()); + TaskResponse task3 = + new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of()); + TaskResponse task4 = + new TaskResponse(4L, "Task 4", false, false, null, null, null, "tag3", List.of()); + TaskResponse task5 = + new TaskResponse(5L, "Task 5", false, false, null, null, null, "tag2", List.of()); + TaskResponse task6 = + new TaskResponse(6L, "Task 6", false, false, null, null, null, "tag4", List.of()); + TaskResponse task7 = + new TaskResponse(7L, "Task 7", false, false, null, null, null, "tag5", List.of()); + TaskResponse task8 = + new TaskResponse(8L, "Task 8", false, false, null, null, null, "tag6", List.of()); + + when(taskService.getTasksByFilter("all")) + .thenReturn(List.of(task1, task2, task3, task4, task5, task6, task7, task8)); + + List topTags = homeService.getTopTasksTag(); + + Assertions.assertNotNull(topTags); + Assertions.assertEquals(5, topTags.size()); + Assertions.assertTrue(topTags.contains("tag1")); + Assertions.assertTrue(topTags.contains("tag2")); + Assertions.assertTrue(topTags.contains("tag3")); + Assertions.assertTrue(topTags.contains("tag4")); + Assertions.assertTrue(topTags.contains("tag5")); + } + + @Test + @DisplayName("Get top tasks tag with no tags should return empty list") + void getTopTasksTag_noTags_shouldReturnEmptyList() { + when(taskService.getTasksByFilter("all")).thenReturn(List.of()); + + List topTags = homeService.getTopTasksTag(); + + Assertions.assertNotNull(topTags); + Assertions.assertTrue(topTags.isEmpty()); + } + + @Test + @DisplayName("Get top tasks tag with blank tags should handle untagged tasks") + void getTopTasksTag_blankTags_shouldHandleUntagged() { + TaskResponse task1 = + new TaskResponse(1L, "Task 1", false, false, null, null, null, "", List.of()); + TaskResponse task2 = + new TaskResponse(2L, "Task 2", false, false, null, null, null, " ", List.of()); + TaskResponse task3 = + new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of()); + + when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1, task2, task3)); + + List topTags = homeService.getTopTasksTag(); + + Assertions.assertNotNull(topTags); + Assertions.assertEquals(2, topTags.size()); + Assertions.assertTrue(topTags.contains("untagged")); + Assertions.assertTrue(topTags.contains("tag1")); + } } 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 7af8c24..ac5ff30 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 @@ -485,4 +485,220 @@ class TaskServiceTest { assertEquals("test", patched.tag()); assertTrue(patched.urls().isEmpty()); } + + @Test + @DisplayName("Search tasks with matching term should succeed") + void searchTasks_matchingTerm_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)); + + TaskEntity taskEntity = new TaskEntity(); + taskEntity.setId(1L); + taskEntity.setDescription("Write unit tests"); + taskEntity.setHighPriority(false); + taskEntity.setTag("development"); + + String searchTerm = "unit"; + when(taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), USER_ID)) + .thenReturn(List.of(taskEntity)); + + List responses = taskService.searchTasks(searchTerm); + + assertNotNull(responses); + assertFalse(responses.isEmpty()); + assertEquals(1, responses.size()); + assertEquals(taskEntity.getDescription(), responses.get(0).description()); + } + + @Test + @DisplayName("Search tasks with no matching term should return empty list") + void searchTasks_noMatchingTerm_shouldReturnEmptyList() { + 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)); + + String searchTerm = "nonexistent"; + + when(taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), USER_ID)) + .thenReturn(List.of()); + + List responses = taskService.searchTasks(searchTerm); + + assertNotNull(responses); + assertTrue(responses.isEmpty()); + } + + @Test + @DisplayName("Search tasks with null search term should return empty list") + void searchTasks_nullSearchTerm_shouldReturnEmptyList() { + 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)); + + String searchTerm = null; + + when(taskRepository.findAllBySearchTerm(null, USER_ID)).thenReturn(List.of()); + + List responses = taskService.searchTasks(searchTerm); + + assertNotNull(responses); + assertTrue(responses.isEmpty()); + } + + @Test + @DisplayName("Get tasks by filter 'all' should succeed") + void getTasksByFilter_all_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)); + + TaskEntity task1 = new TaskEntity(); + task1.setId(1L); + task1.setDescription("Task 1"); + task1.setHighPriority(false); + task1.setDone(false); + task1.setTag("tag1"); + + TaskEntity task2 = new TaskEntity(); + task2.setId(2L); + task2.setDescription("Task 2"); + task2.setHighPriority(true); + task2.setDone(false); + task2.setTag("tag2"); + + when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); + + List responses = taskService.getTasksByFilter("all"); + + assertEquals(2, responses.size()); + assertEquals("tag1", responses.get(0).tag()); + assertEquals("tag2", responses.get(1).tag()); + } + + @Test + @DisplayName("Get tasks by filter 'high' should succeed") + void getTasksByFilter_high_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)); + + TaskEntity task1 = new TaskEntity(); + task1.setId(1L); + task1.setDescription("Task 1"); + task1.setHighPriority(false); + task1.setDone(false); + task1.setTag("tag1"); + + TaskEntity task2 = new TaskEntity(); + task2.setId(2L); + task2.setDescription("Task 2"); + task2.setHighPriority(true); + task2.setDone(false); + task2.setTag("tag2"); + + when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); + + List responses = taskService.getTasksByFilter("high"); + + assertEquals(1, responses.size()); + assertEquals("tag2", responses.get(0).tag()); + } + + @Test + @DisplayName("Get tasks by filter 'untagged' should succeed") + void getTasksByFilter_untagged_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)); + + TaskEntity task1 = new TaskEntity(); + task1.setId(1L); + task1.setDescription("Task 1"); + task1.setHighPriority(false); + task1.setDone(false); + task1.setTag(null); + + TaskEntity task2 = new TaskEntity(); + task2.setId(2L); + task2.setDescription("Task 2"); + task2.setHighPriority(true); + task2.setDone(false); + task2.setTag(""); + + when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); + + List responses = taskService.getTasksByFilter("untagged"); + + assertEquals(2, responses.size()); + assertNull(responses.get(0).tag()); + assertTrue(responses.get(1).tag().isBlank()); + } + + @Test + @DisplayName("Get tasks by specific tag filter should succeed") + void getTasksByFilter_specificTag_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)); + + TaskEntity task1 = new TaskEntity(); + task1.setId(1L); + task1.setDescription("Task 1"); + task1.setHighPriority(false); + task1.setDone(false); + task1.setTag("tag1"); + + TaskEntity task2 = new TaskEntity(); + task2.setId(2L); + task2.setDescription("Task 2"); + task2.setHighPriority(true); + task2.setDone(false); + task2.setTag("tag2"); + + when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); + + List responses = taskService.getTasksByFilter("tag1"); + + assertEquals(1, responses.size()); + assertEquals("tag1", responses.get(0).tag()); + } + + @Test + @DisplayName("Get tasks by filter with no matching tasks should return empty list") + void getTasksByFilter_noMatchingTasks_shouldReturnEmptyList() { + 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)); + + when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of()); + + List responses = taskService.getTasksByFilter("nonexistent"); + + assertTrue(responses.isEmpty()); + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/util/TimeAgoUtilTest.java b/server/src/test/java/br/com/tasknoteapp/server/util/TimeAgoUtilTest.java index 8685fed..c830f10 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/util/TimeAgoUtilTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/util/TimeAgoUtilTest.java @@ -2,6 +2,9 @@ package br.com.tasknoteapp.server.util; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Period; +import java.time.format.TextStyle; +import java.util.Locale; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -21,8 +24,61 @@ class TimeAgoUtilTest { @Test void formatDueDateTest() { Assertions.assertNull(TimeAgoUtil.formatDueDate(null)); - Assertions.assertEquals("1 day left", TimeAgoUtil.formatDueDate(LocalDate.now().plusDays(1L))); + + LocalDate localDate1 = LocalDate.now().plusDays(1L); + String expected1 = "1 day left" + getFormattedSuffix(localDate1); + Assertions.assertEquals(expected1, TimeAgoUtil.formatDueDate(localDate1)); + + LocalDate localDate2 = LocalDate.now().plusDays(12L); + String expected2 = "12 days left" + getFormattedSuffix(localDate2); + Assertions.assertEquals(expected2, TimeAgoUtil.formatDueDate(localDate2)); + } + + private String getFormattedSuffix(LocalDate futureDate) { + String dayOfWeek = futureDate.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.ENGLISH); + int dayOfMonth = futureDate.getDayOfMonth(); + String suffix = getDaySuffix(dayOfMonth); + String month = futureDate.getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH); + int year = futureDate.getYear(); + return String.format(" (%s %d%s, %s %d)", dayOfWeek, dayOfMonth, suffix, month, year); + } + + private static String getDaySuffix(int day) { + if (day >= 11 && day <= 13) { + return "th"; + } + return switch (day % 10) { + case 1 -> "st"; + case 2 -> "nd"; + case 3 -> "rd"; + default -> "th"; + }; + } + + @Test + void formatDueDateEdgeCasesTest() { + // Test for today + LocalDate today = LocalDate.now(); + String expectedToday = "0 days left" + getFormattedSuffix(today); + Assertions.assertEquals(expectedToday, TimeAgoUtil.formatDueDate(today)); + + // Test for a past date + LocalDate pastDate = LocalDate.now().minusDays(9L); Assertions.assertEquals( - "12 days left", TimeAgoUtil.formatDueDate(LocalDate.now().plusDays(12L))); + "Due" + getFormattedSuffix(pastDate), TimeAgoUtil.formatDueDate(pastDate)); + + // Test for a far future date + LocalDate farFutureDate = LocalDate.now().plusYears(5L); + String expectedFarFuture = "5 years left" + getFormattedSuffix(farFutureDate); + Assertions.assertEquals(expectedFarFuture, TimeAgoUtil.formatDueDate(farFutureDate)); + + // Test for a leap year date + LocalDate leapYearDate = LocalDate.of(2024, 2, 29); + if (LocalDate.now().isBefore(leapYearDate)) { + Period period = Period.between(LocalDate.now(), leapYearDate); + String expectedLeapYear = + String.format("%d days left", period.getDays()) + getFormattedSuffix(leapYearDate); + Assertions.assertEquals(expectedLeapYear, TimeAgoUtil.formatDueDate(leapYearDate)); + } } }