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( , ) } const clickActionButton = (actionKey) => { fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`)) } beforeEach(() => { vi.clearAllMocks() // Mock console.error to suppress error logging in tests 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', // topSongs property is missing }, }, }) 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() }) }) })