Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion client/src/__test__/views/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import api from '../../api-service/api';
import { TaskResponse } from '../../types/TaskResponse';
import { NoteResponse } from '../../types/NoteResponse';
import Home from '../../views/Home';
import { clearHomeCache } from '../../utils/HomeCache';

// filepath: client/src/views/Home/index.test.tsx

Expand Down Expand Up @@ -40,6 +41,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({
}));

vi.mock('react-router', () => ({
useLocation: () => ({ state: null }),
NavLink: ({ to, children }: { to: string, children: React.ReactNode }) => (
<a href={to} data-testid={`navlink-${to}`}>{children}</a>
)
Expand Down Expand Up @@ -227,6 +229,7 @@ describe('Home Component', () => {

beforeEach(() => {
// Reset mocks and setup default responses
clearHomeCache();
vi.clearAllMocks();
localStorage.clear();
(api.getJSON as any).mockImplementation((url: string) => {
Expand Down Expand Up @@ -984,4 +987,4 @@ describe('Home Component', () => {
// First note should have preview with the first two lines
expect(noteCards[0].textContent).toBe('Line 1\nLine 2');
});*/
});
});
201 changes: 201 additions & 0 deletions client/src/__test__/views/HomeNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import AuthContext from '../../context/AuthContext';
import FilterProvider from '../../context/FilterProvider';
import SidebarContext from '../../context/SidebarContext';
import api from '../../api-service/api';
import Home from '../../views/Home';
import {
cacheHomeItems,
clearHomeCache,
getCachedHomeItems
} from '../../utils/HomeCache';

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() }
})
}));

vi.mock('../../api-service/api', () => ({
default: {
getJSON: vi.fn(),
postJSON: vi.fn(),
patchJSON: vi.fn(),
putJSON: vi.fn(),
deleteNoContent: vi.fn()
}
}));

vi.mock('../../lang-service/LangHandler', () => ({
handleDefaultLang: vi.fn()
}));

vi.mock('../../utils/TranslatorUtils', () => ({
translateServerResponse: (text: string) => text,
translateTaskResponse: (tasks: unknown[]) => tasks
}));

const user = {
userId: 9,
name: 'Test User',
email: 'test@example.com',
admin: false,
createdAt: new Date(),
gravatarImageUrl: '',
lang: 'en',
lastLogin: '',
theme: 'light'
};

const authValue = {
signed: true,
loading: false,
user,
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false,
updateUser: vi.fn()
};

const task = (id: number, description: string) => ({
id,
description,
completed: false,
urls: [],
tags: [],
lastUpdate: '2026-09-25',
highPriority: false,
dueDateFmt: '',
dueDate: ''
});

function FormRoute() {
const navigate = useNavigate();

return (
<>
<button onClick={() => navigate('/home')}>Cancel</button>
<button onClick={() => navigate('/home', { state: { refreshHome: true } })}>Save</button>
</>
);
}

function TestApp() {
return (
<AuthContext.Provider value={authValue}>
<FilterProvider>
<SidebarContext.Provider value={{ currentPage: '/home', setNewPage: vi.fn() }}>
<MemoryRouter initialEntries={['/home']}>
<NavigateToForm />
<Routes>
<Route path="/home" element={<Home />} />
<Route path="/tasks/new" element={<FormRoute />} />
</Routes>
</MemoryRouter>
</SidebarContext.Provider>
</FilterProvider>
</AuthContext.Provider>
);
}

function NavigateToForm() {
const navigate = useNavigate();
return <button onClick={() => navigate('/tasks/new')}>Add task</button>;
}

const getItemCalls = () => (api.getJSON as ReturnType<typeof vi.fn>).mock.calls
.filter(([url]) => String(url).includes('/home/items'));

const getTagCalls = () => (api.getJSON as ReturnType<typeof vi.fn>).mock.calls
.filter(([url]) => String(url).includes('/tasks/tags'));

describe('Home navigation cache', () => {
beforeEach(() => {
clearHomeCache();
localStorage.clear();
vi.clearAllMocks();
(api.getJSON as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/tasks/tags')) {
return Promise.resolve(['work']);
}
if (url.includes('/home/items')) {
return Promise.resolve({ tasks: [task(1, 'Saved from server')], notes: [] });
}
return Promise.resolve([]);
});
});

afterEach(() => {
clearHomeCache();
});

test('keeps cached responses separate from the API and Home view', () => {
const response = { tasks: [task(1, 'Cached')], notes: [] };
cacheHomeItems(user.userId, '/home/items', response);

response.tasks[0].lastUpdate = 'changed by Home';
response.tasks[0].tags.push('changed by API');

const firstRead = getCachedHomeItems(user.userId, '/home/items');
expect(firstRead?.tasks[0].lastUpdate).toBe('2026-09-25');
expect(firstRead?.tasks[0].tags).toEqual([]);

firstRead!.tasks[0].lastUpdate = 'changed by Home';
firstRead!.tasks[0].urls.push('changed by Home');

const secondRead = getCachedHomeItems(user.userId, '/home/items');
expect(secondRead?.tasks[0].lastUpdate).toBe('2026-09-25');
expect(secondRead?.tasks[0].urls).toEqual([]);
});

test('returns to cached home data without fetching when a form is cancelled', async () => {
render(<TestApp />);

await waitFor(() => expect(getItemCalls()).toHaveLength(1));
await waitFor(() => expect(getTagCalls()).toHaveLength(1));
expect(await screen.findByText('Saved from server')).toBeDefined();

fireEvent.click(screen.getByRole('button', { name: 'Add task' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));

expect(await screen.findByText('Saved from server')).toBeDefined();
expect(getItemCalls()).toHaveLength(1);
expect(getTagCalls()).toHaveLength(1);
});

test('refreshes home items and tags after a form is saved', async () => {
let itemRequest = 0;
(api.getJSON as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/tasks/tags')) {
return Promise.resolve(itemRequest === 0 ? ['work'] : ['work', 'new']);
}
if (url.includes('/home/items')) {
itemRequest += 1;
return Promise.resolve({
tasks: [task(1, itemRequest === 1 ? 'Before save' : 'After save')],
notes: []
});
}
return Promise.resolve([]);
});

render(<TestApp />);
expect(await screen.findByText('Before save')).toBeDefined();
await waitFor(() => expect(getTagCalls()).toHaveLength(1));

fireEvent.click(screen.getByRole('button', { name: 'Add task' }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

expect(await screen.findByText('After save')).toBeDefined();
await waitFor(() => {
expect(getItemCalls()).toHaveLength(2);
expect(getTagCalls()).toHaveLength(2);
});
});
});
75 changes: 75 additions & 0 deletions client/src/utils/HomeCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { HomeItemsResponse } from '../types/HomeItemsResponse';

const itemsByUser = new Map<number, { itemsUrl: string; items: HomeItemsResponse }>();
const tagsByUser = new Map<number, string[]>();

const copyHomeItems = (items: HomeItemsResponse): HomeItemsResponse => ({
tasks: items.tasks.map(task => ({ ...task, tags: [...task.tags], urls: [...task.urls] })),
notes: items.notes.map(note => ({ ...note, tags: [...note.tags] }))
});

/**
* Return the last home response for this user and filter, if one is available.
*
* @param {number} userId - The signed-in user's ID.
* @param {string} itemsUrl - The URL identifying the current filter.
* @returns {HomeItemsResponse | undefined} A cached response, if available.
*/
export const getCachedHomeItems = (
userId: number,
itemsUrl: string
): HomeItemsResponse | undefined => {
const cached = itemsByUser.get(userId);
return cached?.itemsUrl === itemsUrl
? copyHomeItems(cached.items)
: undefined;
};

/**
* Store the latest home response for this user and filter.
*
* @param {number} userId - The signed-in user's ID.
* @param {string} itemsUrl - The URL identifying the current filter.
* @param {HomeItemsResponse} items - The response returned by the home API.
*/
export const cacheHomeItems = (
userId: number,
itemsUrl: string,
items: HomeItemsResponse
): void => {
itemsByUser.set(userId, {
itemsUrl,
items: copyHomeItems(items)
});
};

/**
* Return the last tag list loaded for this user, if one is available.
*
* @param {number} userId - The signed-in user's ID.
* @returns {string[] | undefined} A cached tag list, if available.
*/
export const getCachedHomeTags = (userId: number): string[] | undefined => {
const tags = tagsByUser.get(userId);
return tags ? [...tags] : undefined;
};

/**
* Store the latest tag list loaded for this user.
*
* @param {number} userId - The signed-in user's ID.
* @param {string[]} tags - The tags returned by the home API.
*/
export const cacheHomeTags = (userId: number, tags: string[]): void => {
tagsByUser.set(userId, [...tags]);
};

/**
* Clear the in-memory home data cache.
*
* This is exported so tests can start with an empty cache.
*/
export const clearHomeCache = (): void => {
itemsByUser.clear();
tagsByUser.clear();
};
Loading
Loading