From 6f2786b230a7658ff60d44b256e80174116c83c8 Mon Sep 17 00:00:00 2001 From: Martin Reitschmied Date: Sat, 4 Apr 2026 21:31:48 +0200 Subject: [PATCH] test: add frontend tests for download progress card, reset password, and library index Cover critical user flows: download progress display with size/ETA formatting, password reset form with validation and token errors, and library page with tab rendering, search filtering, view modes, and empty states. Co-Authored-By: Paperclip --- .../library/download-progress-card.test.tsx | 195 ++++++++++ inertia/pages/auth/reset_password.test.tsx | 194 ++++++++++ inertia/pages/library/index.test.tsx | 365 ++++++++++++++++++ 3 files changed, 754 insertions(+) create mode 100644 inertia/components/library/download-progress-card.test.tsx create mode 100644 inertia/pages/auth/reset_password.test.tsx create mode 100644 inertia/pages/library/index.test.tsx diff --git a/inertia/components/library/download-progress-card.test.tsx b/inertia/components/library/download-progress-card.test.tsx new file mode 100644 index 0000000..9f29d3f --- /dev/null +++ b/inertia/components/library/download-progress-card.test.tsx @@ -0,0 +1,195 @@ +import { render, screen } from '@testing-library/react' +import { DownloadProgressCard } from './download-progress-card' +import type { ActiveDownloadInfo } from '@/hooks/use_active_downloads' + +// Mock Hugeicons +vi.mock('@hugeicons/react', () => ({ + HugeiconsIcon: ({ className }: { className?: string }) => ( + + ), +})) + +vi.mock('@hugeicons/core-free-icons', () => { + const m = (name: string) => ({ name }) + return { + Download01Icon: m('Download01Icon'), + Time01Icon: m('Time01Icon'), + HardDriveIcon: m('HardDriveIcon'), + } +}) + +function makeDownload(overrides: Partial = {}): ActiveDownloadInfo { + return { + title: 'Test Download', + progress: 50, + status: 'downloading', + size: 1024 * 1024 * 500, // 500 MB + remaining: 1024 * 1024 * 250, // 250 MB + eta: 3600, + downloadClient: 'SABnzbd', + ...overrides, + } +} + +describe('DownloadProgressCard', () => { + describe('rendering', () => { + it('renders nothing when downloads array is empty', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders a card when there are downloads', () => { + render() + expect(screen.getByText('Test Download')).toBeInTheDocument() + }) + + it('renders multiple download items', () => { + const downloads = [ + makeDownload({ title: 'Movie A' }), + makeDownload({ title: 'Movie B' }), + makeDownload({ title: 'Movie C' }), + ] + render() + expect(screen.getByText('Movie A')).toBeInTheDocument() + expect(screen.getByText('Movie B')).toBeInTheDocument() + expect(screen.getByText('Movie C')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render( + + ) + const card = container.firstElementChild + expect(card?.className).toContain('my-custom-class') + }) + }) + + describe('progress display', () => { + it('shows progress percentage for downloading items', () => { + render() + expect(screen.getByText('75%')).toBeInTheDocument() + }) + + it('rounds progress percentage', () => { + render() + expect(screen.getByText('34%')).toBeInTheDocument() + }) + + it('shows "Importing" instead of percentage when status is importing', () => { + render() + expect(screen.getByText('Importing')).toBeInTheDocument() + expect(screen.queryByText('50%')).not.toBeInTheDocument() + }) + }) + + describe('file size formatting', () => { + it('displays downloaded and total size', () => { + render( + + ) + expect(screen.getByText('512.0 MB / 1.00 GB')).toBeInTheDocument() + }) + + it('formats KB correctly', () => { + render( + + ) + expect(screen.getByText('300.0 KB / 500.0 KB')).toBeInTheDocument() + }) + + it('does not show size when size is null', () => { + render( + + ) + // No size text should appear - check that HardDrive icon section is not rendered + expect(screen.queryByText(/KB|MB|GB/)).not.toBeInTheDocument() + }) + + it('does not show size when remaining is null', () => { + render( + + ) + expect(screen.queryByText(/\//)).not.toBeInTheDocument() + }) + }) + + describe('ETA formatting', () => { + it('displays hours and minutes for large ETAs', () => { + render( + // 2h 5m + ) + expect(screen.getByText('2h 5m remaining')).toBeInTheDocument() + }) + + it('displays minutes and seconds for medium ETAs', () => { + render( + // 2m 5s + ) + expect(screen.getByText('2m 5s remaining')).toBeInTheDocument() + }) + + it('displays only seconds for small ETAs', () => { + render( + + ) + expect(screen.getByText('45s remaining')).toBeInTheDocument() + }) + + it('does not show ETA when eta is null', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + + it('does not show ETA when eta is 0 or negative', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + + it('does not show ETA when importing', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + }) + + describe('download client', () => { + it('displays download client name', () => { + render( + + ) + expect(screen.getByText('SABnzbd')).toBeInTheDocument() + }) + + it('does not show download client when null', () => { + render( + + ) + expect(screen.queryByText('SABnzbd')).not.toBeInTheDocument() + }) + }) +}) diff --git a/inertia/pages/auth/reset_password.test.tsx b/inertia/pages/auth/reset_password.test.tsx new file mode 100644 index 0000000..30479db --- /dev/null +++ b/inertia/pages/auth/reset_password.test.tsx @@ -0,0 +1,194 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ResetPassword from './reset_password' + +const mockPost = vi.fn() +let mockProcessing = false +let mockFlash: { success?: string } = {} + +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }: { title: string }) => {title}, + Link: ({ children, href }: any) => {children}, + usePage: () => ({ props: { flash: mockFlash } }), + useForm: (initial: any) => ({ + data: { ...initial }, + setData: vi.fn(), + post: mockPost, + processing: mockProcessing, + }), +})) + +// Mock HamsterLogo +vi.mock('@/components/icons/hamster-logo', () => ({ + HamsterLogo: () =>
, +})) + +beforeEach(() => { + vi.clearAllMocks() + mockProcessing = false + mockFlash = {} +}) + +describe('ResetPassword', () => { + const defaultProps = { + token: 'test-token-123', + } + + describe('rendering', () => { + it('renders the page title', () => { + render() + expect(document.querySelector('title')?.textContent).toBe('Reset Password') + }) + + it('renders the heading', () => { + render() + expect(screen.getByText('Reset your password')).toBeInTheDocument() + }) + + it('renders description text', () => { + render() + expect(screen.getByText('Enter your new password below.')).toBeInTheDocument() + }) + + it('renders password input field', () => { + render() + expect(screen.getByLabelText('New Password')).toBeInTheDocument() + }) + + it('renders password confirmation input field', () => { + render() + expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument() + }) + + it('renders submit button with correct text', () => { + render() + expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument() + }) + + it('renders sign in link', () => { + render() + const signInLink = screen.getByText('Sign in') + expect(signInLink).toBeInTheDocument() + expect(signInLink.closest('a')).toHaveAttribute('href', '/login') + }) + + it('renders the hamster logo', () => { + render() + expect(screen.getByTestId('hamster-logo')).toBeInTheDocument() + }) + }) + + describe('form submission', () => { + it('renders a submit button that triggers form submission', () => { + render() + const button = screen.getByRole('button', { name: 'Reset Password' }) + expect(button).toHaveAttribute('type', 'submit') + }) + + it('button is inside a form element', () => { + render() + const button = screen.getByRole('button', { name: 'Reset Password' }) + expect(button.closest('form')).toBeInTheDocument() + }) + }) + + describe('processing state', () => { + it('shows "Resetting..." when processing', () => { + mockProcessing = true + render() + expect(screen.getByRole('button', { name: 'Resetting...' })).toBeInTheDocument() + }) + + it('disables button when processing', () => { + mockProcessing = true + render() + expect(screen.getByRole('button', { name: 'Resetting...' })).toBeDisabled() + }) + }) + + describe('validation errors', () => { + it('displays password error', () => { + render( + + ) + expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument() + }) + + it('displays password confirmation error', () => { + render( + + ) + expect(screen.getByText('Passwords do not match')).toBeInTheDocument() + }) + + it('displays both errors simultaneously', () => { + render( + + ) + expect(screen.getByText('Too short')).toBeInTheDocument() + expect(screen.getByText('Does not match')).toBeInTheDocument() + }) + }) + + describe('token error', () => { + it('displays token error from errors prop', () => { + render( + + ) + expect(screen.getByText(/Token has expired/)).toBeInTheDocument() + }) + + it('displays error from error prop', () => { + render() + expect(screen.getByText(/Invalid or expired reset link/)).toBeInTheDocument() + }) + + it('hides the form when error prop is set', () => { + render() + expect(screen.queryByLabelText('New Password')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reset Password' })).not.toBeInTheDocument() + }) + + it('shows "Request a new link" link when token error is displayed', () => { + render() + const link = screen.getByText('Request a new link') + expect(link.closest('a')).toHaveAttribute('href', '/forgot-password') + }) + + it('still shows sign in link when token error is displayed', () => { + render() + expect(screen.getByText('Sign in')).toBeInTheDocument() + }) + }) + + describe('flash messages', () => { + it('displays flash success message', () => { + mockFlash = { success: 'Your password has been reset successfully!' } + render() + expect(screen.getByText('Your password has been reset successfully!')).toBeInTheDocument() + }) + + it('does not show flash area when no flash message', () => { + mockFlash = {} + render() + expect( + screen.queryByText('Your password has been reset successfully!') + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/inertia/pages/library/index.test.tsx b/inertia/pages/library/index.test.tsx new file mode 100644 index 0000000..3c3bacc --- /dev/null +++ b/inertia/pages/library/index.test.tsx @@ -0,0 +1,365 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Library from './index' + +// Mock Inertia +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }: { title: string }) => {title}, + router: { visit: vi.fn() }, + Link: ({ children, href }: any) => {children}, + usePage: () => ({ props: {} }), +})) + +// Mock layout +vi.mock('@/components/layout', () => ({ + AppLayout: ({ children, actions }: { children: React.ReactNode; actions?: React.ReactNode }) => ( +
+ {actions &&
{actions}
} + {children} +
+ ), +})) + +// Mock Hugeicons +vi.mock('@hugeicons/react', () => ({ + HugeiconsIcon: ({ className }: { className?: string }) => ( + + ), +})) + +vi.mock('@hugeicons/core-free-icons', () => { + const m = (name: string) => ({ name }) + return { + Add01Icon: m('Add01Icon'), + Search01Icon: m('Search01Icon'), + GridIcon: m('GridIcon'), + Menu01Icon: m('Menu01Icon'), + SortingIcon: m('SortingIcon'), + MusicNote01Icon: m('MusicNote01Icon'), + Film01Icon: m('Film01Icon'), + Tv01Icon: m('Tv01Icon'), + Book01Icon: m('Book01Icon'), + MoreVerticalIcon: m('MoreVerticalIcon'), + Delete02Icon: m('Delete02Icon'), + EyeIcon: m('EyeIcon'), + CheckmarkCircle02Icon: m('CheckmarkCircle02Icon'), + Download01Icon: m('Download01Icon'), + Clock01Icon: m('Clock01Icon'), + FolderSearchIcon: m('FolderSearchIcon'), + } +}) + +// Mock sonner +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})) + +// Mock operation tracker +vi.mock('@/hooks/use_operation_tracker', () => ({ + useOperationTrackerContext: () => ({ runBulk: vi.fn() }), +})) + +// Mock media status badge +vi.mock('@/components/library/media-status-badge', () => ({ + MediaStatusBadge: () => null, + getMediaItemStatus: () => ({ status: 'none', progress: 0 }), +})) + +// Track fetch calls +let fetchMock: ReturnType + +const settingsResponse = (enabledTypes: string[]) => ({ + ok: true, + json: () => Promise.resolve({ enabledMediaTypes: enabledTypes }), +}) + +const moviesResponse = (movies: any[] = []) => ({ + ok: true, + json: () => Promise.resolve(movies), +}) + +const queueResponse = (queue: any[] = []) => ({ + ok: true, + json: () => Promise.resolve(queue), +}) + +const emptyOkResponse = () => ({ + ok: true, + json: () => Promise.resolve([]), +}) + +function setupFetchMock(options: { + enabledTypes?: string[] + movies?: any[] + artists?: any[] + tvShows?: any[] + authors?: any[] + queue?: any[] +} = {}) { + const { + enabledTypes = ['movies'], + movies = [], + artists = [], + tvShows = [], + authors = [], + queue = [], + } = options + + fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === '/api/v1/settings') return Promise.resolve(settingsResponse(enabledTypes)) + if (url === '/api/v1/queue') return Promise.resolve(queueResponse(queue)) + if (url === '/api/v1/movies') return Promise.resolve(moviesResponse(movies)) + if (url === '/api/v1/artists') return Promise.resolve(moviesResponse(artists)) + if (url === '/api/v1/tvshows') return Promise.resolve(moviesResponse(tvShows)) + if (url === '/api/v1/authors') return Promise.resolve(moviesResponse(authors)) + return Promise.resolve(emptyOkResponse()) + }) + global.fetch = fetchMock +} + +beforeEach(() => { + // Reset URL search params + window.history.replaceState({}, '', '/') + setupFetchMock() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Library', () => { + describe('initial loading', () => { + it('renders the page title', async () => { + render() + expect(document.querySelector('title')?.textContent).toBe('Library') + }) + + it('shows loading skeletons initially', () => { + render() + // Skeleton cards are rendered during loading + const skeletons = document.querySelectorAll('[data-slot="skeleton"]') + expect(skeletons.length).toBeGreaterThan(0) + }) + + it('fetches settings on mount', async () => { + render() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/settings') + }) + }) + + it('fetches queue and movies data on mount', async () => { + render() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/queue') + expect(fetchMock).toHaveBeenCalledWith('/api/v1/movies') + }) + }) + }) + + describe('tab rendering', () => { + it('renders tabs based on enabled media types from settings', async () => { + setupFetchMock({ enabledTypes: ['movies', 'tv', 'music'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Movies')).toBeInTheDocument() + expect(screen.getByText('TV Shows')).toBeInTheDocument() + expect(screen.getByText('Music')).toBeInTheDocument() + }) + }) + + it('always includes Missing tab', async () => { + setupFetchMock({ enabledTypes: ['movies'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Missing')).toBeInTheDocument() + }) + }) + + it('switches tab when clicked', async () => { + const user = userEvent.setup() + setupFetchMock({ enabledTypes: ['movies', 'music'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Music')).toBeInTheDocument() + }) + + await user.click(screen.getByText('Music')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/artists') + }) + }) + }) + + describe('movies content', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt123', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + { id: 2, title: 'Avatar', year: 2009, posterUrl: null, tmdbId: 'tt456', requested: true, hasFile: false, status: 'Released', runtime: 162, rating: 7.9, overview: null }, + { id: 3, title: 'Blade Runner', year: 1982, posterUrl: null, tmdbId: 'tt789', requested: false, hasFile: false, status: 'Released', runtime: 117, rating: 8.1, overview: null }, + ] + + it('displays movie titles after loading', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + expect(screen.getByText('Avatar')).toBeInTheDocument() + expect(screen.getByText('Blade Runner')).toBeInTheDocument() + }) + }) + + it('shows total count in stats bar', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText(/Showing 3 of 3 movies/)).toBeInTheDocument() + }) + }) + }) + + describe('search filtering', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt1', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + { id: 2, title: 'Interstellar', year: 2014, posterUrl: null, tmdbId: 'tt2', requested: true, hasFile: true, status: 'Released', runtime: 169, rating: 8.7, overview: null }, + { id: 3, title: 'The Matrix', year: 1999, posterUrl: null, tmdbId: 'tt3', requested: true, hasFile: true, status: 'Released', runtime: 136, rating: 8.7, overview: null }, + ] + + it('filters items by search query', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'Inter') + + expect(screen.getByText('Interstellar')).toBeInTheDocument() + expect(screen.queryByText('Inception')).not.toBeInTheDocument() + expect(screen.queryByText('The Matrix')).not.toBeInTheDocument() + }) + + it('shows filtered count in stats bar', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'In') + + expect(screen.getByText(/Showing 2 of 3 movies/)).toBeInTheDocument() + }) + + it('shows empty state when no items match search', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'zzzznotfound') + + expect(screen.getByText('No items found')).toBeInTheDocument() + expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument() + }) + }) + + describe('empty library state', () => { + it('shows empty state message when no movies exist', async () => { + setupFetchMock({ movies: [] }) + render() + + await waitFor(() => { + expect(screen.getByText('Your movies library is empty')).toBeInTheDocument() + }) + }) + + it('shows add button in empty state', async () => { + setupFetchMock({ movies: [] }) + render() + + await waitFor(() => { + expect(screen.getByText(/Add Movie/)).toBeInTheDocument() + }) + }) + }) + + describe('view mode toggle', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt1', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + ] + + it('defaults to grid view', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + // Grid view is rendered (grid has aspect-[2/3] containers) + const gridContainer = document.querySelector('.grid') + expect(gridContainer).toBeInTheDocument() + }) + + it('switches to list view when list button is clicked', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + // There are two view mode buttons (grid and list). The list button is the second one + // in the view mode toggle group + const buttons = document.querySelectorAll('.rounded-l-none') + expect(buttons.length).toBe(1) + await user.click(buttons[0] as HTMLElement) + + // After switching, the content should still show the movie + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + }) + + describe('sort options', () => { + it('shows sort dropdown button', async () => { + render() + // The sort button has text "Sort" + await waitFor(() => { + expect(screen.getByText('Sort')).toBeInTheDocument() + }) + }) + }) + + describe('scan library button', () => { + it('shows scan library button', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Scan Library')).toBeInTheDocument() + }) + }) + }) + + describe('add button', () => { + it('renders an Add button in the header', () => { + render() + expect(screen.getByText('Add')).toBeInTheDocument() + }) + }) +})