mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
test(frontend): add unit tests for favorites flow
- Add LoveButton.test.jsx with 9 cases (render, disabled states, click, propagation, tooltip) - Add ContextMenus.test.jsx with 8 cases for AlbumContextMenu and ArtistContextMenu - Extend useToggleLove.test.js with error handling and loading state cases - Add useToggleLove.test.md documenting test cases and mocks - Add frontend-test service to docker-compose.dev.yml (profile: test) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
182bfce964
commit
1ed1d0ce79
@ -35,6 +35,16 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
frontend-test:
|
||||
image: node:24
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./ui:/app
|
||||
- node-modules:/app/node_modules
|
||||
command: sh -c "npm ci && npm test"
|
||||
profiles:
|
||||
- test
|
||||
|
||||
volumes:
|
||||
go-mod-cache:
|
||||
go-build-cache:
|
||||
|
||||
99
ui/src/common/ContextMenus.test.jsx
Normal file
99
ui/src/common/ContextMenus.test.jsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
|
||||
|
||||
const capturedLoveButtonProps = {}
|
||||
|
||||
vi.mock('./LoveButton', () => ({
|
||||
LoveButton: (props) => {
|
||||
Object.assign(capturedLoveButtonProps, props)
|
||||
return props.visible ? <button data-testid="love-button" /> : null
|
||||
},
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
enableFavourites: true,
|
||||
enableDownloads: false,
|
||||
enableSharing: false,
|
||||
}))
|
||||
|
||||
vi.mock('../config', () => ({ default: mockConfig }))
|
||||
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useDataProvider: () => ({ getList: vi.fn() }),
|
||||
useNotify: () => vi.fn(),
|
||||
useTranslate: () => (key) => key,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await vi.importActual('../utils')
|
||||
return { ...actual, formatBytes: vi.fn(() => '1 KB') }
|
||||
})
|
||||
|
||||
const albumRecord = { id: 'album-1', name: 'Test Album', size: 1000 }
|
||||
const artistRecord = { id: 'artist-1', name: 'Test Artist', size: 1000 }
|
||||
|
||||
describe('AlbumContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
|
||||
mockConfig.enableFavourites = true
|
||||
})
|
||||
|
||||
it('renders LoveButton when enableFavourites is true', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when showLove is false', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} showLove={false} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes resource="album" to LoveButton', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(capturedLoveButtonProps.resource).toBe('album')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtistContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
|
||||
mockConfig.enableFavourites = true
|
||||
})
|
||||
|
||||
it('renders LoveButton when enableFavourites is true', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when showLove is false', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} showLove={false} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes resource="artist" to LoveButton', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(capturedLoveButtonProps.resource).toBe('artist')
|
||||
})
|
||||
})
|
||||
100
ui/src/common/LoveButton.test.jsx
Normal file
100
ui/src/common/LoveButton.test.jsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { LoveButton } from './LoveButton'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({ enableFavourites: true }))
|
||||
|
||||
vi.mock('../config', () => ({ default: mockConfig }))
|
||||
|
||||
vi.mock('./useToggleLove', () => ({
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useRecordContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../utils/validations', () => ({
|
||||
isDateSet: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('LoveButton', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.enableFavourites = true
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: false })
|
||||
isDateSet.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('renders nothing when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
const { container } = render(<LoveButton resource="song" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders a button when enableFavourites is true', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is disabled when loading is true', () => {
|
||||
useToggleLove.mockReturnValue([mockToggleLove, true])
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('is disabled when record.missing is true', () => {
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: false, missing: true })
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<LoveButton resource="song" disabled={true} />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls toggleLove when clicked', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(mockToggleLove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops click propagation to parent elements', () => {
|
||||
const parentClick = vi.fn()
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<LoveButton resource="song" />
|
||||
</div>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows starredAt date as title when starredAt is set', () => {
|
||||
useRecordContext.mockReturnValue({
|
||||
id: 'song-1',
|
||||
starred: true,
|
||||
starredAt: '2024-01-15T12:00:00Z',
|
||||
})
|
||||
isDateSet.mockReturnValue(true)
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('has no title attribute when starredAt is not set', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).not.toHaveAttribute('title')
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import subsonic from '../subsonic'
|
||||
import { useDataProvider } from 'react-admin'
|
||||
import { useDataProvider, useNotify } from 'react-admin'
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: {
|
||||
@ -16,16 +16,21 @@ vi.mock('react-admin', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useDataProvider: vi.fn(),
|
||||
useNotify: vi.fn(() => vi.fn()),
|
||||
useNotify: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useToggleLove', () => {
|
||||
let getOne
|
||||
let mockNotify
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNotify = vi.fn()
|
||||
getOne = vi.fn(() => Promise.resolve())
|
||||
useDataProvider.mockReturnValue({ getOne })
|
||||
vi.clearAllMocks()
|
||||
useNotify.mockReturnValue(mockNotify)
|
||||
subsonic.star.mockResolvedValue()
|
||||
subsonic.unstar.mockResolvedValue()
|
||||
})
|
||||
|
||||
it('uses mediaFileId when present', async () => {
|
||||
@ -133,4 +138,51 @@ describe('useToggleLove', () => {
|
||||
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('calls notify when star() fails', async () => {
|
||||
subsonic.star.mockRejectedValue(new Error('Network error'))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
})
|
||||
|
||||
it('calls notify when unstar() fails', async () => {
|
||||
subsonic.unstar.mockRejectedValue(new Error('Network error'))
|
||||
const record = { id: 'sg-1', starred: true }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('is true while the API call is pending and false after resolve', async () => {
|
||||
let resolveToggle
|
||||
subsonic.star.mockReturnValue(new Promise((r) => { resolveToggle = r }))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
|
||||
act(() => { result.current[0]() })
|
||||
expect(result.current[1]).toBe(true)
|
||||
|
||||
await act(async () => { resolveToggle() })
|
||||
expect(result.current[1]).toBe(false)
|
||||
})
|
||||
|
||||
it('returns to false even when the API call fails', async () => {
|
||||
subsonic.star.mockRejectedValue(new Error('fail'))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(result.current[1]).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
27
ui/src/common/useToggleLove.test.md
Normal file
27
ui/src/common/useToggleLove.test.md
Normal file
@ -0,0 +1,27 @@
|
||||
# useToggleLove — Documentación de tests
|
||||
|
||||
## Mocks globales
|
||||
|
||||
Aplicados en todos los tests vía `beforeEach`:
|
||||
|
||||
| Mock | Reemplaza |
|
||||
|---|---|
|
||||
| `subsonic.star` / `subsonic.unstar` | Llamadas HTTP a la API Subsonic |
|
||||
| `useDataProvider` → `{ getOne }` | Acceso al data provider de React Admin |
|
||||
| `useNotify` → `mockNotify` | Notificaciones que el hook dispara al usuario |
|
||||
|
||||
## Casos de prueba
|
||||
|
||||
| Función bajo prueba | Caso de prueba | Mocks utilizados |
|
||||
|---|---|---|
|
||||
| `toggleLove` (acción de star) | Usa `mediaFileId` para llamar a `star()` cuando el record lo tiene | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (acción de star) | Usa `record.id` como fallback cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (acción de unstar) | Llama a `unstar()` cuando el record ya tiene `starred: true` | `subsonic.unstar` |
|
||||
| `refreshRecord` (playlist track) | Hace `getOne` tanto al playlist track como a la song cuando hay `mediaFileId` + `playlistId` | `subsonic.star`, `dataProvider.getOne` (×2) |
|
||||
| `refreshRecord` (playlist track) | Incluye el filtro `playlist_id` al refrescar el playlist track | `subsonic.unstar`, `dataProvider.getOne` |
|
||||
| `refreshRecord` (song directa) | Solo hace un `getOne` al resource original cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` (×1) |
|
||||
| `refreshRecord` (song directa) | No incluye filtro `playlist_id` para recursos que no son playlist | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `star()` falla | `subsonic.star` (rechazado), `useNotify` |
|
||||
| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `unstar()` falla | `subsonic.unstar` (rechazado), `useNotify` |
|
||||
| `loading` (estado) | Es `true` mientras la llamada está pendiente y `false` al resolverse | `subsonic.star` (promesa manual con `resolveToggle`) |
|
||||
| `loading` (estado) | Vuelve a `false` incluso cuando la llamada falla | `subsonic.star` (rechazado) |
|
||||
Loading…
x
Reference in New Issue
Block a user