import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import type { TrackRef } from '$lib/api/types'; const invalidateMock = vi.fn(); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; }); vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); import RemoveTrackPopover from './RemoveTrackPopover.svelte'; import { removeTrack } from '$lib/api/admin/tracks'; const track: TrackRef = { id: 't1', title: 'Roygbiv', album_id: 'a1', album_title: 'Geogaddi', artist_id: 'ar1', artist_name: 'Boards of Canada', duration_sec: 240, stream_url: '/api/tracks/t1/stream' }; afterEach(() => vi.clearAllMocks()); describe('RemoveTrackPopover', () => { test('renders the title and subtext', () => { render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument(); expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument(); }); test('renders the Lidarr unmonitor checkbox', () => { render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i }); expect(cb).toBeInTheDocument(); expect((cb as HTMLInputElement).checked).toBe(false); }); test('Cancel button calls onClose without firing removeTrack', async () => { const onClose = vi.fn(); render(RemoveTrackPopover, { props: { track, onClose } }); await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); expect(removeTrack).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); test('Remove without checkbox calls removeTrack with unmonitor=false', async () => { (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); const onClose = vi.fn(); render(RemoveTrackPopover, { props: { track, onClose } }); await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); // confirm() chains 3-5 awaited invalidateQueries calls before onClose, // each a separate microtask. waitFor polls until the side effects land. await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false }); }); test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => { (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); const onClose = vi.fn(); render(RemoveTrackPopover, { props: { track, onClose } }); await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i })); await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true }); }); test('failed remove surfaces error via copyForCode and does not close', async () => { (removeTrack as ReturnType).mockRejectedValue({ code: 'not_found' }); const onClose = vi.fn(); render(RemoveTrackPopover, { props: { track, onClose } }); await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); await waitFor(() => expect(removeTrack).toHaveBeenCalled()); // The catch path runs synchronously after the rejected await; one more // microtask boundary is enough. await Promise.resolve(); expect(onClose).not.toHaveBeenCalled(); }); test('success invalidates album, artist and home queries', async () => { (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1', deleted_album_id: 'a1', deleted_artist_id: 'ar1' }); render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); // Wait for all three baseline invalidations (album, artist, home) plus // the two cascade ones (deleted_album_id, deleted_artist_id) to land. await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5)); const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey); const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k)); expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true); expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true); expect(flat.some((k) => k === 'home')).toBe(true); }); });