import React from 'react'
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import ArtistActions from './ArtistActions'
import subsonic from '../subsonic'
import {
openShareMenu,
openDownloadMenu,
DOWNLOAD_MENU_ARTIST,
} from '../actions'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
const mockDispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
vi.mock('../subsonic', () => ({
default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() },
}))
const { mockConfig, mockPermissions } = vi.hoisted(() => ({
mockConfig: { enableSharing: true, enableDownloads: true },
mockPermissions: { value: 'admin' },
}))
vi.mock('../config', () => ({ default: mockConfig }))
const mockNotify = vi.fn()
const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] })
const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } })
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useNotify: () => mockNotify,
useDataProvider: () => ({
getList: mockGetList,
refreshMetadata: mockRefreshMetadata,
}),
usePermissions: () => ({ permissions: mockPermissions.value }),
useTranslate: () => (x) => x,
}
})
describe('ArtistActions', () => {
const defaultRecord = {
id: 'ar1',
name: 'Artist',
stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } },
}
const renderArtistActions = (record = defaultRecord) => {
const theme = createTheme()
return render(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={record} />
</ThemeProvider>
</TestContext>,
)
}
const clickActionButton = (actionKey) => {
fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`))
}
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockConfig.enableSharing = true
mockConfig.enableDownloads = true
mockPermissions.value = 'admin'
const songWithReplayGain = {
id: 'rec1',
replayGain: {
albumGain: -5,
albumPeak: 1,
trackGain: -6,
trackPeak: 0.8,
},
}
subsonic.getSimilarSongs2.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
similarSongs2: { song: [songWithReplayGain] },
},
},
})
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
topSongs: { song: [songWithReplayGain] },
},
},
})
})
describe('Shuffle action', () => {
it('shuffles songs when clicked', async () => {
renderArtistActions()
clickActionButton('shuffle')
await waitFor(() =>
expect(mockGetList).toHaveBeenCalledWith('song', {
pagination: { page: 1, perPage: 500 },
sort: { field: 'random', order: 'ASC' },
filter: { album_artist_id: 'ar1', missing: false },
}),
)
expect(mockDispatch).toHaveBeenCalled()
})
})
describe('Radio action', () => {
it('starts radio when clicked', async () => {
renderArtistActions()
clickActionButton('radio')
await waitFor(() =>
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
)
expect(mockDispatch).toHaveBeenCalled()
})
it('maps replaygain info', async () => {
renderArtistActions()
clickActionButton('radio')
await waitFor(() =>
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
)
const action = mockDispatch.mock.calls[0][0]
expect(action.data.rec1).toMatchObject({
rgAlbumGain: -5,
rgAlbumPeak: 1,
rgTrackGain: -6,
rgTrackPeak: 0.8,
})
})
})
describe('Play action', () => {
it('plays top songs when clicked', async () => {
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockDispatch).toHaveBeenCalled()
})
it('maps replaygain info for top songs', async () => {
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
const action = mockDispatch.mock.calls[0][0]
expect(action.data.rec1).toMatchObject({
rgAlbumGain: -5,
rgAlbumPeak: 1,
rgTrackGain: -6,
rgTrackPeak: 0.8,
})
})
it('handles API rejection', async () => {
subsonic.getTopSongs.mockRejectedValue(new Error('Network error'))
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles failed API response', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password' },
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles empty song list', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
topSongs: { song: [] },
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith(
'message.noTopSongsFound',
'warning',
)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles missing topSongs property', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith(
'message.noTopSongsFound',
'warning',
)
expect(mockDispatch).not.toHaveBeenCalled()
})
})
describe('Share action', () => {
it('shows the share button and dispatches openShareMenu when clicked', () => {
renderArtistActions()
fireEvent.click(screen.getByText('ra.action.share'))
expect(mockDispatch).toHaveBeenCalledWith(
openShareMenu(['ar1'], 'artist', 'Artist'),
)
})
it('hides the share button when sharing is disabled', () => {
mockConfig.enableSharing = false
renderArtistActions()
expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument()
})
})
describe('Download action', () => {
it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => {
renderArtistActions()
expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument()
fireEvent.click(screen.getByText(/ra\.action\.download/))
expect(mockDispatch).toHaveBeenCalledWith(
openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST),
)
})
it('hides the download button when downloads are disabled', () => {
mockConfig.enableDownloads = false
renderArtistActions()
expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument()
})
})
describe('Album-artist gating', () => {
it('hides Share and Download for artists with no album-artist content', () => {
renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} })
expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument()
expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument()
})
it('hides Share and Download for a missing artist', () => {
renderArtistActions({ ...defaultRecord, missing: true })
expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument()
expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument()
})
})
describe('Refresh metadata action', () => {
const refreshLabel = 'resources.album.actions.refresh'
it('refreshes the artist metadata for admins', async () => {
renderArtistActions()
fireEvent.click(screen.getByRole('button', { name: refreshLabel }))
await waitFor(() =>
expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'),
)
})
it('hides the action for non-admin users', () => {
mockPermissions.value = 'regular'
renderArtistActions()
expect(
screen.queryByRole('button', { name: refreshLabel }),
).not.toBeInTheDocument()
})
})
})