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:
Salome Gene 2026-05-15 11:57:30 -05:00
parent 182bfce964
commit 1ed1d0ce79
5 changed files with 291 additions and 3 deletions

View File

@ -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:

View 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')
})
})

View 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')
})
})

View File

@ -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)
})
})
})

View 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) |