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..45917556c
--- /dev/null
+++ b/ui/src/common/ContextMenus.test.jsx
@@ -0,0 +1,121 @@
+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')
+ })
+
+ it('hides LoveButton when record.missing is true', () => {
+ render()
+ expect(capturedLoveButtonProps.visible).toBe(false)
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('renders nothing when record is undefined', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+})
+
+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')
+ })
+
+ it('hides LoveButton when record.missing is true', () => {
+ render()
+ expect(capturedLoveButtonProps.visible).toBe(false)
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('renders nothing when record is undefined', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/ui/src/common/LoveButton.test.jsx b/ui/src/common/LoveButton.test.jsx
new file mode 100644
index 000000000..0afcca1b5
--- /dev/null
+++ b/ui/src/common/LoveButton.test.jsx
@@ -0,0 +1,122 @@
+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(),
+}))
+
+vi.mock('@material-ui/icons/Favorite', () => ({
+ default: () => ,
+}))
+
+vi.mock('@material-ui/icons/FavoriteBorder', () => ({
+ default: () => ,
+}))
+
+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', () => {
+ const starredAt = '2024-01-15T12:00:00Z'
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: true, starredAt })
+ isDateSet.mockReturnValue(true)
+ render()
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'title',
+ new Date(starredAt).toLocaleString(),
+ )
+ expect(isDateSet).toHaveBeenCalledWith(starredAt)
+ })
+
+ it('has no title attribute when starredAt is not set', () => {
+ render()
+ expect(screen.getByRole('button')).not.toHaveAttribute('title')
+ })
+
+ it('renders FavoriteIcon when record is starred', () => {
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: true })
+ render()
+ expect(screen.getByTestId('favorite-icon')).toBeInTheDocument()
+ expect(screen.queryByTestId('favorite-border-icon')).not.toBeInTheDocument()
+ })
+
+ it('renders FavoriteBorderIcon when record is not starred', () => {
+ render()
+ expect(screen.getByTestId('favorite-border-icon')).toBeInTheDocument()
+ expect(screen.queryByTestId('favorite-icon')).not.toBeInTheDocument()
+ })
+})
diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js
index 640e9ff89..bc9636fc3 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 () => {
@@ -35,6 +40,8 @@ describe('useToggleLove', () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(getOne).toHaveBeenCalledTimes(2)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'pt-1' })
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
@@ -57,6 +64,15 @@ describe('useToggleLove', () => {
expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
})
+ it('does not call notify on a successful toggle', async () => {
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(mockNotify).not.toHaveBeenCalled()
+ })
+
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
@@ -133,4 +149,96 @@ 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')
+ })
+
+ it('does not call notify when refresh fails after a successful toggle', async () => {
+ getOne.mockRejectedValue(new Error('refresh failed'))
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(result.current[1]).toBe(false)
+ })
+ })
+
+ 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)
+ })
+ })
+
+ describe('unmount safety', () => {
+ it('does not warn when the promise resolves after unmount', async () => {
+ let resolveStar
+ subsonic.star.mockReturnValue(
+ new Promise((r) => {
+ resolveStar = r
+ }),
+ )
+ const record = { id: 'sg-1', starred: false }
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const { result, unmount } = renderHook(() =>
+ useToggleLove('song', record),
+ )
+ act(() => {
+ result.current[0]()
+ })
+ unmount()
+ await act(async () => {
+ resolveStar()
+ })
+
+ const stateUpdateWarnings = errorSpy.mock.calls.filter(
+ ([msg]) =>
+ typeof msg === 'string' &&
+ msg.includes("Can't perform a React state update"),
+ )
+ expect(stateUpdateWarnings).toHaveLength(0)
+
+ errorSpy.mockRestore()
+ })
+ })
})
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) |