diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx
index bbb68b1d..1e85d6ba 100644
--- a/frontend/src/components/HomePage.tsx
+++ b/frontend/src/components/HomePage.tsx
@@ -77,7 +77,6 @@ export const HomePage: React.FC = () => {
const data = JSON.parse(event.data);
if (data.status === 'success') {
getTasks(userInfo.email, userInfo.encryption_secret, userInfo.uuid);
- } else if (data.status === 'success') {
if (data.job === 'Add Task') {
console.log('Task added successfully');
toast.success('Task added successfully!', {
@@ -134,6 +133,7 @@ export const HomePage: React.FC = () => {
});
}
} catch (error) {
+ // else if (data.status === 'success') {
console.error('Failed to parse message data:', error);
}
};
@@ -156,6 +156,7 @@ export const HomePage: React.FC = () => {
return;
}
+ /* istanbul ignore if */
if (typeof window === 'undefined') {
return;
}
diff --git a/frontend/src/components/__tests__/HomePage.test.tsx b/frontend/src/components/__tests__/HomePage.test.tsx
index b1ce1b38..9da583ea 100644
--- a/frontend/src/components/__tests__/HomePage.test.tsx
+++ b/frontend/src/components/__tests__/HomePage.test.tsx
@@ -2,29 +2,87 @@ import { render, screen, waitFor } from '@testing-library/react';
import { HomePage } from '../HomePage';
// Mock dependencies
+let receivedNavbarProps: any = null;
+let mockSocket: any;
+let lastDriverConfig: any = null;
+const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+const mockFetchTaskwarriorTasks = jest.fn();
+const mockToastError = jest.fn();
+const mockToastSuccess = jest.fn();
+const mockedNavigate = jest.fn();
+const mockDrive = jest.fn();
+const mockDestroy = jest.fn();
+
jest.mock('../HomeComponents/Navbar/Navbar', () => ({
- Navbar: () =>
Mocked Navbar
,
+ Navbar: (props: any) => {
+ receivedNavbarProps = props;
+ return Mocked Navbar
;
+ },
}));
+
jest.mock('../HomeComponents/Hero/Hero', () => ({
Hero: () => Mocked Hero
,
}));
+
jest.mock('../HomeComponents/Footer/Footer', () => ({
Footer: () => Mocked Footer
,
}));
+
jest.mock('../HomeComponents/SetupGuide/SetupGuide', () => ({
SetupGuide: () => Mocked SetupGuide
,
}));
+
jest.mock('../HomeComponents/FAQ/FAQ', () => ({
FAQ: () => Mocked FAQ
,
}));
+
jest.mock('../HomeComponents/Tasks/Tasks', () => ({
Tasks: () => Mocked Tasks
,
}));
-const mockedNavigate = jest.fn();
+jest.mock('../HomeComponents/Tasks/hooks', () => ({
+ fetchTaskwarriorTasks: (...args: any[]) => mockFetchTaskwarriorTasks(...args),
+}));
+
+jest.mock('react-toastify', () => ({
+ toast: {
+ error: (...args: any[]) => mockToastError(...args),
+ success: (...args: any[]) => mockToastSuccess(...args),
+ },
+}));
+
+jest.mock('driver.js', () => {
+ return {
+ driver: jest.fn((config) => {
+ lastDriverConfig = config;
+ return {
+ drive: mockDrive,
+ destroy: mockDestroy,
+ isActive: jest.fn(() => true),
+ };
+ }),
+ };
+});
+
+beforeEach(() => {
+ mockSocket = {
+ onopen: null,
+ onclose: null,
+ onmessage: null,
+ onerror: null,
+ close: jest.fn(),
+ };
+
+ (global as any).WebSocket = jest.fn(() => mockSocket);
+});
+
jest.mock('react-router', () => ({
useNavigate: () => mockedNavigate,
}));
+
jest.mock('@/components/utils/URLs', () => ({
url: {
backendURL: 'http://mocked-backend-url/',
@@ -49,8 +107,13 @@ global.fetch = jest.fn(() =>
) as jest.Mock;
describe('HomePage', () => {
+ beforeEach(() => {
+ mockDestroy.mockClear();
+ });
+
afterEach(() => {
jest.clearAllMocks();
+ consoleErrorSpy.mockClear();
});
it('renders correctly when user info is fetched successfully', async () => {
@@ -91,4 +154,525 @@ describe('HomePage', () => {
expect(mockedNavigate).toHaveBeenCalledWith('/');
});
});
+
+ // Tasks Fetching Tests
+
+ describe('Task Fetching', () => {
+ it('calls fetchTaskwarriorTasks with correct parameters when user info is loaded', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchTaskwarriorTasks).toHaveBeenCalledTimes(1);
+ expect(mockFetchTaskwarriorTasks).toHaveBeenCalledWith({
+ email: 'mocked-email',
+ encryptionSecret: 'mocked-encryption-secret',
+ UUID: 'mocked-uuid',
+ backendURL: 'http://mocked-backend-url/',
+ });
+ });
+ });
+
+ it('updates tasks state when fetchTaskwarriorTasks returns data', async () => {
+ const mockTasks = [
+ { id: 1, description: 'Test task 1' },
+ { id: 2, description: 'Test task 2' },
+ ];
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce(mockTasks);
+
+ render();
+
+ await waitFor(() => {
+ expect(receivedNavbarProps.email).toBe('mocked-email');
+ expect(receivedNavbarProps.encryptionSecret).toBe(
+ 'mocked-encryption-secret'
+ );
+ expect(receivedNavbarProps.UUID).toBe('mocked-uuid');
+ expect(receivedNavbarProps.tasks).toEqual(mockTasks);
+ });
+ });
+
+ it('sets tasks to empty array when fetchTaskwarriorTasks returns null', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce(null);
+
+ render();
+
+ await waitFor(() => {
+ expect(receivedNavbarProps.tasks).toEqual([]);
+ });
+ });
+
+ it('handles fetchTaskwarriorTasks error with toast.error and resets loading state', async () => {
+ mockFetchTaskwarriorTasks.mockRejectedValueOnce(new Error('Test error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(receivedNavbarProps.isLoading).toBe(false);
+ expect(mockToastError).toHaveBeenCalled();
+ });
+ });
+
+ it('toggles loading state correctly during task fetch', async () => {
+ const mockTasks = [
+ { id: 1, description: 'Test task 1' },
+ { id: 2, description: 'Test task 2' },
+ ];
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce(mockTasks);
+
+ render();
+
+ await waitFor(() => {
+ expect(receivedNavbarProps.isLoading).toBe(true);
+ });
+
+ await waitFor(() => {
+ expect(receivedNavbarProps.isLoading).toBe(false);
+ });
+ });
+ });
+
+ // WebSocket Tests
+ describe('WebSocket Behavior', () => {
+ it('creates WebSocket with the correct URL', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ expect((global as any).WebSocket).toHaveBeenCalledWith(
+ 'ws://mocked-backend-url/ws?clientID=mocked-uuid'
+ );
+ });
+ });
+
+ it('does not create the WebSocket when userInfo is missing', async () => {
+ //Mock fetch to return null user info
+ (fetch as jest.Mock).mockImplementationOnce(() => {
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(null),
+ });
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not create the WebSocket when userInfo.uuid is missing', async () => {
+ (fetch as jest.Mock).mockImplementationOnce(() => {
+ Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ picture: 'mocked-picture',
+ email: 'mocked-email',
+ encryptionSecret: 'mocked-encryption-secret',
+ name: 'mock-name',
+ uuid: null,
+ }),
+ });
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).not.toHaveBeenCalled();
+ });
+ });
+
+ it('refreshes tasks when WebSocket receives a success status message', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValue([]);
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ const messageEvent = {
+ data: JSON.stringify({ status: 'success' }),
+ };
+
+ mockSocket.onmessage(messageEvent);
+
+ await waitFor(() => {
+ expect(mockFetchTaskwarriorTasks).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ test.each([
+ { job: 'Add Task' },
+ { job: 'Edit Task' },
+ { job: 'Delete Task' },
+ { job: 'Complete Task' },
+ ])(
+ 'shows success toast when WebSocket receives success with job "%s"',
+ async ({ job }) => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ const messageEvent = {
+ data: JSON.stringify({
+ status: 'success',
+ job,
+ }),
+ };
+
+ mockSocket.onmessage(messageEvent);
+
+ await waitFor(() => {
+ expect(mockToastSuccess).toHaveBeenCalled();
+ });
+ }
+ );
+
+ it('shows error toast when WebSocket receives a failure status message', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ const messageEvent = {
+ data: JSON.stringify({
+ status: 'failure',
+ job: 'Any Action',
+ }),
+ };
+
+ mockSocket.onmessage(messageEvent);
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalled();
+ });
+ });
+
+ it('handles malformed JSON in WebSocket message without crashing', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ mockSocket.onmessage({ data: 'NOT_JSON' });
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockToastError).not.toHaveBeenCalled();
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ });
+
+ it('closes WebSocket on Component unmount', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ const { unmount } = render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ expect(mockSocket.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles success status with unknown job without throwing', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalled();
+ });
+
+ const event = {
+ data: JSON.stringify({ status: 'success', job: 'UnknownJob' }),
+ };
+
+ expect(() => mockSocket.onmessage(event)).not.toThrow();
+ });
+
+ it('handles success status with no job field gracefully', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalled();
+ });
+
+ const event = {
+ data: JSON.stringify({ status: 'success' }),
+ };
+
+ mockSocket.onmessage(event);
+
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ expect(mockToastError).not.toHaveBeenCalled();
+ });
+
+ it('triggers WebSocket onopen without errors', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalled();
+ });
+
+ expect(() => mockSocket.onopen()).not.toThrow();
+ });
+
+ it('handles WebSocket error event without crashing', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalled();
+ });
+
+ expect(() => mockSocket.onerror('test error')).not.toThrow();
+ });
+
+ it('handles WebSocket onclose without crashing', async () => {
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalled();
+ });
+
+ expect(() => mockSocket.onclose()).not.toThrow();
+ });
+ });
+
+ // Onboarding Tour Tests
+
+ describe('Onboarding Tour', () => {
+ it('does not start the tour if userInfo.email is missing', async () => {
+ jest.useFakeTimers();
+ localStorage.clear();
+
+ (fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ uuid: 'mocked-uuid',
+ name: 'Mock User',
+ encryption_secret: 'mock-secret',
+ picture: 'mocked-pic',
+ // email missing
+ }),
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).not.toHaveBeenCalled();
+ });
+
+ expect(mockDrive).not.toHaveBeenCalled();
+
+ expect(localStorage.length).toBe(0);
+
+ jest.useRealTimers();
+ });
+
+ it('starts the tour for first-time users', async () => {
+ jest.useFakeTimers();
+
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ localStorage.clear();
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ expect(mockDrive).toHaveBeenCalledTimes(1);
+
+ jest.useRealTimers();
+ });
+
+ it('does not start the tour multiple times if already started', async () => {
+ jest.useFakeTimers();
+ localStorage.clear();
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ const initialDriveCalls = mockDrive.mock.calls.length;
+
+ rerender();
+
+ jest.runAllTimers();
+
+ expect(mockDrive).toHaveBeenCalledTimes(initialDriveCalls);
+
+ jest.useRealTimers();
+ });
+
+ it('does NOT start the tour if already seen', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ localStorage.setItem('ccsync-home-tour-mocked-email', 'seen');
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ expect(mockDrive).not.toHaveBeenCalled();
+ });
+
+ it('marks tour as seen when onDestroyed is called', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ localStorage.clear();
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ expect(lastDriverConfig).not.toBeNull();
+
+ lastDriverConfig.onDestroyed();
+
+ expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe(
+ 'seen'
+ );
+ });
+
+ it('marks tour as seen when onCloseClick is called', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ localStorage.clear();
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ expect(lastDriverConfig).not.toBeNull();
+
+ lastDriverConfig.onCloseClick();
+
+ expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe(
+ 'seen'
+ );
+
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears the tour timeout on component unmount', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ localStorage.clear();
+
+ const { unmount } = render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ expect(mockDrive).not.toHaveBeenCalled();
+ });
+
+ it('adds skip button inside popover and handles its click', async () => {
+ jest.useFakeTimers();
+ localStorage.clear();
+
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ render();
+
+ await waitFor(() => {
+ expect((global as any).WebSocket).toHaveBeenCalledTimes(1);
+ });
+
+ jest.runAllTimers();
+
+ const mockPopover = {
+ footerButtons: document.createElement('div'),
+ };
+
+ lastDriverConfig.onPopoverRender(mockPopover);
+
+ const skipBtn = mockPopover.footerButtons.querySelector(
+ '[data-driver-skip-button]'
+ ) as HTMLButtonElement;
+ expect(skipBtn).toBeTruthy();
+ expect(skipBtn?.textContent).toBe('Skip');
+
+ skipBtn?.click();
+
+ expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe(
+ 'seen'
+ );
+
+ expect(mockDestroy).toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
+
+ it('does not add skip button twice', () => {
+ const mockPopover = {
+ footerButtons: document.createElement('div'),
+ };
+
+ lastDriverConfig.onPopoverRender(mockPopover);
+ lastDriverConfig.onPopoverRender(mockPopover);
+
+ const skipButtons = mockPopover.footerButtons.querySelectorAll(
+ '[data-driver-skip-button]'
+ );
+
+ expect(skipButtons.length).toBe(1);
+ });
+ });
+
+ describe('Rendering', () => {
+ it('renders all required section IDs', async () => {
+ mockFetchTaskwarriorTasks.mockResolvedValueOnce([]);
+
+ render();
+
+ // Wait for user info to load
+ await waitFor(() => {
+ expect(document.getElementById('home-navbar')).toBeTruthy();
+ });
+
+ expect(document.getElementById('home-navbar')).toBeTruthy();
+ expect(document.getElementById('home-hero')).toBeTruthy();
+ expect(document.getElementById('home-tasks')).toBeTruthy();
+ expect(document.getElementById('home-setup-guide')).toBeTruthy();
+ expect(document.getElementById('home-faq')).toBeTruthy();
+ });
+ });
});