veza/apps/web/src/features/tracks/hooks/useTrackList.test.ts
2025-12-12 21:34:34 -05:00

761 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useTrackList } from './useTrackList';
import { getTracks } from '../services/trackListService';
import type { Track } from '../../player/types';
// Mock getTracks service
vi.mock('../services/trackListService', () => ({
getTracks: vi.fn(),
}));
// Mock useSearchParams from react-router-dom
const mockSetSearchParams = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
};
});
const mockTracks: Track[] = [
{
id: 1,
title: 'Track A',
artist: 'Artist 1',
album: 'Album 1',
duration: 180,
url: 'https://example.com/track1.mp3',
genre: 'Rock',
},
{
id: 2,
title: 'Track B',
artist: 'Artist 2',
album: 'Album 2',
duration: 240,
url: 'https://example.com/track2.mp3',
genre: 'Pop',
},
{
id: 3,
title: 'Track C',
artist: 'Artist 1',
duration: 120,
url: 'https://example.com/track3.mp3',
genre: 'Rock',
},
];
describe('useTrackList', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockSearchParams.forEach((_, key) => {
mockSearchParams.delete(key);
});
});
afterEach(() => {
localStorage.clear();
});
describe('Client-side mode (useService = false)', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
expect(result.current.tracks).toEqual([]);
expect(result.current.displayMode).toBe('list');
expect(result.current.sortOptions.field).toBe('title');
expect(result.current.sortOptions.order).toBe('asc');
expect(result.current.filterOptions).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.pagination).toEqual({ page: 1, limit: 20 });
});
it('should initialize with provided values', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
initialDisplayMode: 'grid',
initialSortOptions: { field: 'duration', order: 'desc' },
initialFilterOptions: { genre: 'Rock' },
initialPagination: { page: 1, limit: 10 },
}),
);
expect(result.current.tracks.length).toBeGreaterThan(0);
expect(result.current.displayMode).toBe('grid');
expect(result.current.sortOptions.field).toBe('duration');
expect(result.current.sortOptions.order).toBe('desc');
expect(result.current.filterOptions.genre).toBe('Rock');
});
it('should filter tracks by genre', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
}),
);
act(() => {
result.current.setFilterOptions({ genre: 'Rock' });
});
expect(result.current.filteredTracks).toHaveLength(2);
expect(
result.current.filteredTracks.every((t) => t.genre === 'Rock'),
).toBe(true);
});
it('should sort tracks by title', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
}),
);
act(() => {
result.current.setSortField('title');
result.current.setSortOrder('asc');
});
expect(result.current.filteredTracks[0].title).toBe('Track A');
});
it('should paginate tracks', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
initialPagination: { page: 1, limit: 2 },
}),
);
expect(result.current.filteredTracks).toHaveLength(2);
expect(result.current.total).toBe(3);
expect(result.current.totalPages).toBe(2);
});
});
describe('Service mode (useService = true)', () => {
it('should load tracks from service on mount', async () => {
const mockResponse = {
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(getTracks).toHaveBeenCalled();
expect(result.current.tracks).toEqual(mockTracks);
expect(result.current.total).toBe(mockTracks.length);
});
it('should not load tracks if autoLoad is false', () => {
renderHook(() => useTrackList({ useService: true, autoLoad: false }));
expect(getTracks).not.toHaveBeenCalled();
});
it('should reload tracks when pagination changes', async () => {
const mockResponse1 = {
data: [mockTracks[0]],
total: mockTracks.length,
page: 1,
limit: 1,
totalPages: 3,
};
const mockResponse2 = {
data: [mockTracks[1]],
total: mockTracks.length,
page: 2,
limit: 1,
totalPages: 3,
};
vi.mocked(getTracks)
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const { result } = renderHook(() =>
useTrackList({
useService: true,
autoLoad: true,
initialPagination: { page: 1, limit: 1 },
}),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setPage(2);
});
await waitFor(() => {
expect(getTracks).toHaveBeenCalledTimes(2);
});
});
it('should reload tracks when filters change', async () => {
const mockResponse = {
data: [mockTracks[0], mockTracks[2]],
total: 2,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setFilterOptions({ genre: 'Rock' });
});
await waitFor(() => {
expect(getTracks).toHaveBeenCalledWith(
expect.objectContaining({
filters: { genre: 'Rock' },
}),
);
});
});
it('should reload tracks when sort changes', async () => {
const mockResponse = {
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setSortField('duration');
});
await waitFor(() => {
expect(getTracks).toHaveBeenCalledWith(
expect.objectContaining({
sort: { field: 'duration', order: 'asc' },
}),
);
});
});
it('should handle loading state', async () => {
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(getTracks).mockReturnValue(promise as any);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
expect(result.current.isLoading).toBe(true);
act(() => {
resolvePromise!({
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
});
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
});
it('should handle errors', async () => {
const error = new Error('Failed to load tracks');
vi.mocked(getTracks).mockRejectedValue(error);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toEqual(error);
expect(result.current.tracks).toEqual([]);
});
it('should manually load tracks', async () => {
const mockResponse = {
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: false }),
);
await act(async () => {
await result.current.loadTracks();
});
expect(getTracks).toHaveBeenCalled();
expect(result.current.tracks).toEqual(mockTracks);
});
it('should refresh tracks', async () => {
const mockResponse = {
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({ useService: true, autoLoad: true }),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const callCount = vi.mocked(getTracks).mock.calls.length;
await act(async () => {
await result.current.refreshTracks();
});
expect(getTracks).toHaveBeenCalledTimes(callCount + 1);
});
it('should reset to page 1 when filters change', async () => {
const mockResponse = {
data: mockTracks,
total: mockTracks.length,
page: 1,
limit: 20,
totalPages: 1,
};
vi.mocked(getTracks).mockResolvedValue(mockResponse);
const { result } = renderHook(() =>
useTrackList({
useService: true,
autoLoad: true,
initialPagination: { page: 2, limit: 20 },
}),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setFilterOptions({ genre: 'Rock' });
});
await waitFor(() => {
expect(result.current.pagination.page).toBe(1);
});
});
});
describe('CRUD operations', () => {
it('should add track', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
act(() => {
result.current.addTrack(mockTracks[0]);
});
expect(result.current.tracks).toContain(mockTracks[0]);
});
it('should remove track', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
}),
);
act(() => {
result.current.removeTrack(2);
});
expect(result.current.tracks).toHaveLength(2);
expect(result.current.tracks.find((t) => t.id === 2)).toBeUndefined();
});
it('should update track', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
}),
);
const updatedTrack = { ...mockTracks[0], title: 'Updated Title' };
act(() => {
result.current.updateTrack(updatedTrack);
});
const updated = result.current.tracks.find(
(t) => t.id === updatedTrack.id,
);
expect(updated?.title).toBe('Updated Title');
});
});
describe('Pagination controls', () => {
it('should set page', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
act(() => {
result.current.setPage(3);
});
expect(result.current.pagination.page).toBe(3);
});
it('should set limit', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
act(() => {
result.current.setLimit(50);
});
expect(result.current.pagination.limit).toBe(50);
expect(result.current.pagination.page).toBe(1); // Should reset to page 1
});
it('should set pagination', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
act(() => {
result.current.setPagination({ page: 2, limit: 10 });
});
expect(result.current.pagination).toEqual({ page: 2, limit: 10 });
});
});
describe('Search', () => {
it('should set search query', () => {
const { result } = renderHook(() =>
useTrackList({ useService: false, autoLoad: false }),
);
act(() => {
result.current.setSearchQuery('test query');
});
// In client mode, search is handled in filteredTracks
expect(result.current.filteredTracks).toBeDefined();
});
it('should filter tracks by search query in client mode', () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
initialTracks: mockTracks,
}),
);
act(() => {
result.current.setSearchQuery('Track A');
});
expect(result.current.filteredTracks).toHaveLength(1);
expect(result.current.filteredTracks[0].title).toBe('Track A');
});
});
describe('Persistence', () => {
it('should persist filters to localStorage when persistFilters is enabled', async () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistFilters: true,
}),
);
act(() => {
result.current.setFilterOptions({ genre: 'Rock', artist: 'Artist 1' });
});
await waitFor(
() => {
const stored = localStorage.getItem('trackList_filters');
expect(stored).toBeTruthy();
if (stored) {
const parsed = JSON.parse(stored);
expect(parsed.genre).toBe('Rock');
expect(parsed.artist).toBe('Artist 1');
}
},
{ timeout: 2000 },
);
});
it('should persist sort options to localStorage when persistSort is enabled', async () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistSort: true,
}),
);
act(() => {
result.current.setSortField('artist');
result.current.setSortOrder('desc');
});
await waitFor(
() => {
const stored = localStorage.getItem('trackList_sort');
expect(stored).toBeTruthy();
if (stored) {
const parsed = JSON.parse(stored);
expect(parsed.field).toBe('artist');
expect(parsed.order).toBe('desc');
}
},
{ timeout: 2000 },
);
});
it('should restore filters from localStorage on mount when persistFilters is enabled', () => {
localStorage.setItem(
'trackList_filters',
JSON.stringify({ genre: 'Pop', artist: 'Artist 2' }),
);
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistFilters: true,
}),
);
expect(result.current.filterOptions.genre).toBe('Pop');
expect(result.current.filterOptions.artist).toBe('Artist 2');
});
it('should restore sort options from localStorage on mount when persistSort is enabled', () => {
localStorage.setItem(
'trackList_sort',
JSON.stringify({ field: 'duration', order: 'desc' }),
);
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistSort: true,
}),
);
expect(result.current.sortOptions.field).toBe('duration');
expect(result.current.sortOptions.order).toBe('desc');
});
it('should use custom storage key prefix', async () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistFilters: true,
storageKeyPrefix: 'customPrefix',
}),
);
act(() => {
result.current.setFilterOptions({ genre: 'Jazz' });
});
await waitFor(
() => {
const stored = localStorage.getItem('customPrefix_filters');
expect(stored).toBeTruthy();
if (stored) {
const parsed = JSON.parse(stored);
expect(parsed.genre).toBe('Jazz');
}
},
{ timeout: 2000 },
);
});
it('should sync filters with URL params when syncUrlParams is enabled', async () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
syncUrlParams: true,
}),
);
act(() => {
result.current.setFilterOptions({ genre: 'Rock', artist: 'Artist 1' });
});
await waitFor(
() => {
expect(mockSetSearchParams).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
it('should sync sort options with URL params when syncUrlParams is enabled', async () => {
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
syncUrlParams: true,
}),
);
act(() => {
result.current.setSortField('artist');
result.current.setSortOrder('desc');
});
await waitFor(
() => {
expect(mockSetSearchParams).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
it('should load filters from URL params when syncUrlParams is enabled', () => {
mockSearchParams.set('genre', 'Pop');
mockSearchParams.set('artist', 'Artist 2');
mockSearchParams.set('year', '2020');
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
syncUrlParams: true,
}),
);
expect(result.current.filterOptions.genre).toBe('Pop');
expect(result.current.filterOptions.artist).toBe('Artist 2');
expect(result.current.filterOptions.year).toBe(2020);
});
it('should load sort options from URL params when syncUrlParams is enabled', () => {
mockSearchParams.set('sortField', 'duration');
mockSearchParams.set('sortOrder', 'desc');
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
syncUrlParams: true,
}),
);
expect(result.current.sortOptions.field).toBe('duration');
expect(result.current.sortOptions.order).toBe('desc');
});
it('should prioritize URL params over localStorage when both are enabled', () => {
localStorage.setItem(
'trackList_filters',
JSON.stringify({ genre: 'Rock' }),
);
mockSearchParams.set('genre', 'Pop');
const { result } = renderHook(() =>
useTrackList({
useService: false,
autoLoad: false,
persistFilters: true,
syncUrlParams: true,
}),
);
// URL params should take priority
expect(result.current.filterOptions.genre).toBe('Pop');
});
});
});