diff --git a/.github/workflows/frontend-test.yml b/.github/workflows/frontend-test.yml index a7f3e07f..84f741fb 100644 --- a/.github/workflows/frontend-test.yml +++ b/.github/workflows/frontend-test.yml @@ -27,13 +27,13 @@ jobs: - name: Install dependencies run: npm ci working-directory: frontend - + # 1. RUN ONLY TESTS ON PUSH - name: Run frontend tests if: github.event_name == 'push' run: npm test working-directory: frontend - + # 2. RUN COVERAGE ON PRs - name: Run jest with coverage if: github.event_name == 'pull_request' diff --git a/frontend/src/components/HomeComponents/Navbar/__tests__/navbar-utils.test.ts b/frontend/src/components/HomeComponents/Navbar/__tests__/navbar-utils.test.ts index db69623e..82dc615e 100644 --- a/frontend/src/components/HomeComponents/Navbar/__tests__/navbar-utils.test.ts +++ b/frontend/src/components/HomeComponents/Navbar/__tests__/navbar-utils.test.ts @@ -1,7 +1,8 @@ import { Task } from '@/components/utils/types'; import { handleLogout, deleteAllTasks } from '../navbar-utils'; +import { toast } from 'react-toastify'; -// Mock external dependencies +// Toast mock jest.mock('react-toastify', () => ({ toast: { success: jest.fn(), @@ -11,18 +12,29 @@ jest.mock('react-toastify', () => ({ }, })); +// Dexie mock jest.mock('dexie', () => { - return jest.fn().mockImplementation(() => ({ + const mockCount = jest.fn(); + const mockDelete = jest.fn(); + + const DexieMock = jest.fn().mockImplementation(() => ({ version: jest.fn().mockReturnThis(), stores: jest.fn().mockReturnThis(), table: jest.fn().mockReturnValue({ where: jest.fn().mockReturnThis(), equals: jest.fn().mockReturnThis(), - delete: jest.fn().mockResolvedValue(undefined), // simulates delete success + count: mockCount, + delete: mockDelete, }), })); + + return Object.assign(DexieMock, { + __mockCount: mockCount, + __mockDelete: mockDelete, + }); }); +// URL mock jest.mock('@/components/utils/URLs.ts', () => ({ url: { backendURL: 'http://localhost:3000/', @@ -32,12 +44,26 @@ jest.mock('@/components/utils/URLs.ts', () => ({ global.fetch = jest.fn(); describe('navbar-utils', () => { + const mockToast = toast as jest.Mocked; + const Dexie = require('dexie'); + const mockCount = Dexie.__mockCount as jest.Mock; + const mockDelete = Dexie.__mockDelete as jest.Mock; + + beforeAll(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + beforeEach(() => { + mockToast.info.mockReturnValue('toast-id' as any); + }); + afterEach(() => { jest.clearAllMocks(); }); describe('handleLogout', () => { - it('should call fetch with correct URL and redirect on success', async () => { + it('calls fetch with correct URL and redirects on success', async () => { (fetch as jest.Mock).mockResolvedValue({ ok: true }); await handleLogout(); @@ -46,47 +72,78 @@ describe('navbar-utils', () => { method: 'POST', credentials: 'include', }); + expect(window.location.href).toBe('http://localhost/'); }); - it('should log an error if fetch fails', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + it('logs error when response is not ok', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(); (fetch as jest.Mock).mockResolvedValue({ ok: false }); await handleLogout(); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to logout'); - consoleErrorSpy.mockRestore(); + expect(spy).toHaveBeenCalledWith('Failed to logout'); + spy.mockRestore(); }); - it('should log an error if fetch throws an exception', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + it('logs error when fetch throws exception', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(); (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); await handleLogout(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error logging out:', - expect.any(Error) - ); - consoleErrorSpy.mockRestore(); + expect(spy).toHaveBeenCalledWith('Error logging out:', expect.any(Error)); + spy.mockRestore(); }); }); describe('deleteAllTasks', () => { - it('should delete tasks without error', async () => { - const props = { - imgurl: '', - email: 'test@example.com', - encryptionSecret: '', - origin: '', - UUID: '', - tasks: [] as Task[] | null, - }; - - await expect(deleteAllTasks(props)).resolves.toBeUndefined(); + const props = { + imgurl: '', + email: 'test@example.com', + encryptionSecret: '', + origin: '', + UUID: '', + tasks: [] as Task[] | null, + }; + + it('shows error toast when no tasks exist', async () => { + mockCount.mockResolvedValueOnce(0); + + await deleteAllTasks(props); + + expect(mockToast.info).toHaveBeenCalled(); + expect(mockToast.update).toHaveBeenCalledWith( + 'toast-id', + expect.objectContaining({ type: 'error' }) + ); + }); + + it('deletes tasks and shows success toast when tasks exist', async () => { + mockCount.mockResolvedValueOnce(3); + mockDelete.mockResolvedValueOnce(undefined); + + await deleteAllTasks(props); + + expect(mockDelete).toHaveBeenCalled(); + expect(mockToast.update).toHaveBeenCalledWith( + 'toast-id', + expect.objectContaining({ type: 'success' }) + ); + }); + + it('shows error toast when deletion fails', async () => { + mockCount.mockResolvedValueOnce(2); + mockDelete.mockRejectedValueOnce(new Error('DB error')); + + await deleteAllTasks(props); + + expect(mockToast.update).toHaveBeenCalledWith( + 'toast-id', + expect.objectContaining({ type: 'error' }) + ); }); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx b/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx index 7c5b9330..171bc988 100644 --- a/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx +++ b/frontend/src/components/HomeComponents/Tasks/ReportsView.tsx @@ -16,32 +16,32 @@ export const ReportsView: React.FC = ({ tasks }) => { ); const countStatuses = (filterDate: Date) => { - return tasks - .filter((task) => { + return tasks.reduce( + (acc, task) => { + if (task.status === 'pending' && isOverdue(task.due ?? '')) { + acc.overdue += 1; + return acc; + } + const taskDateStr = task.end || task.due || task.entry; - if (!taskDateStr) return false; + if (!taskDateStr) return acc; const parsedDate = parseTaskwarriorDate(taskDateStr); - if (!parsedDate) return false; + if (!parsedDate) return acc; const modifiedDate = getStartOfDay(parsedDate); - return modifiedDate >= filterDate; - }) - .reduce( - (acc, task) => { - if (task.status === 'completed') { - acc.completed += 1; - } else if (task.status === 'pending') { - if (isOverdue(task.due)) { - acc.overdue += 1; - } else { - acc.ongoing += 1; - } - } - return acc; - }, - { completed: 0, ongoing: 0, overdue: 0 } - ); + if (modifiedDate < filterDate) return acc; + + if (task.status === 'completed') { + acc.completed += 1; + } else if (task.status === 'pending') { + acc.ongoing += 1; + } + + return acc; + }, + { completed: 0, ongoing: 0, overdue: 0 } + ); }; const dailyData = [{ name: 'Today', ...countStatuses(today) }]; diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap b/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap index 2310c32b..5b555cf3 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/__snapshots__/ReportView.test.tsx.snap @@ -606,7 +606,7 @@ exports[`ReportsView Component using Snapshot renders correctly with only severa ongoing: 1 - overdue: 0 + overdue: 3
- overdue: 1 + overdue: 3