diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 0d9713896..58737ab83 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -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:
diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx
new file mode 100644
index 000000000..787e30805
--- /dev/null
+++ b/ui/src/common/ContextMenus.test.jsx
@@ -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 ? : 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()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when enableFavourites is false', () => {
+ mockConfig.enableFavourites = false
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when showLove is false', () => {
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('passes resource="album" to LoveButton', () => {
+ render()
+ 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()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when enableFavourites is false', () => {
+ mockConfig.enableFavourites = false
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when showLove is false', () => {
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('passes resource="artist" to LoveButton', () => {
+ render()
+ expect(capturedLoveButtonProps.resource).toBe('artist')
+ })
+})
diff --git a/ui/src/common/LoveButton.test.jsx b/ui/src/common/LoveButton.test.jsx
new file mode 100644
index 000000000..dc56fc04b
--- /dev/null
+++ b/ui/src/common/LoveButton.test.jsx
@@ -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()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders a button when enableFavourites is true', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('is disabled when loading is true', () => {
+ useToggleLove.mockReturnValue([mockToggleLove, true])
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('is disabled when record.missing is true', () => {
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: false, missing: true })
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('is disabled when disabled prop is true', () => {
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('calls toggleLove when clicked', () => {
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ expect(mockToggleLove).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops click propagation to parent elements', () => {
+ const parentClick = vi.fn()
+ render(
+
+
+
,
+ )
+ 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()
+ expect(screen.getByRole('button')).toHaveAttribute('title')
+ })
+
+ it('has no title attribute when starredAt is not set', () => {
+ render()
+ expect(screen.getByRole('button')).not.toHaveAttribute('title')
+ })
+})
diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js
index 640e9ff89..4e5b64ef9 100644
--- a/ui/src/common/useToggleLove.test.js
+++ b/ui/src/common/useToggleLove.test.js
@@ -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)
+ })
+ })
})
diff --git a/ui/src/common/useToggleLove.test.md b/ui/src/common/useToggleLove.test.md
new file mode 100644
index 000000000..c293a7057
--- /dev/null
+++ b/ui/src/common/useToggleLove.test.md
@@ -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) |