diff --git a/web/src/lib/api/admin/tracks.test.ts b/web/src/lib/api/admin/tracks.test.ts deleted file mode 100644 index 7ac97aa7..00000000 --- a/web/src/lib/api/admin/tracks.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { removeTrack } from './tracks'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function stubFetch(status: number, body: unknown, init: Partial = {}) { - const res = new Response( - body === null ? null : JSON.stringify(body), - { status, headers: { 'Content-Type': 'application/json' }, ...init } - ); - const spy = vi.fn().mockResolvedValue(res); - vi.stubGlobal('fetch', spy); - return spy; -} - -describe('removeTrack', () => { - test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => { - const spy = stubFetch(200, { - deleted_track_id: 't-1', - deleted_album_id: 'al-1' - }); - - const r = await removeTrack('t-1'); - expect(r.deleted_track_id).toBe('t-1'); - expect(r.deleted_album_id).toBe('al-1'); - expect(r.deleted_artist_id).toBeUndefined(); - expect(r.lidarr_unmonitor_failed).toBeUndefined(); - - const call = spy.mock.calls[0]; - expect(call[0]).toBe('/api/admin/tracks/t-1'); - expect((call[1] as RequestInit).method).toBe('DELETE'); - }); - - test('appends ?unmonitor=true when option set', async () => { - const spy = stubFetch(200, { - deleted_track_id: 't-1', - lidarr_unmonitor_failed: true - }); - - const r = await removeTrack('t-1', { unmonitor: true }); - expect(r.lidarr_unmonitor_failed).toBe(true); - - const call = spy.mock.calls[0]; - expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true'); - }); - - test('omits the query string when unmonitor: false', async () => { - const spy = stubFetch(200, { deleted_track_id: 't-1' }); - - await removeTrack('t-1', { unmonitor: false }); - const call = spy.mock.calls[0]; - expect(call[0]).toBe('/api/admin/tracks/t-1'); - }); - - test('surfaces not_found as ApiError', async () => { - stubFetch(404, { error: 'not_found' }); - - await expect(removeTrack('t-1')).rejects.toMatchObject({ - code: 'not_found' - }); - }); -}); diff --git a/web/src/lib/api/admin/tracks.ts b/web/src/lib/api/admin/tracks.ts deleted file mode 100644 index 05fab4b8..00000000 --- a/web/src/lib/api/admin/tracks.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { apiFetch } from '$lib/api/client'; - -export type RemoveTrackResult = { - deleted_track_id: string; - deleted_album_id?: string; - deleted_artist_id?: string; - /** Only present (always `true` when set) when the operator requested - * unmonitor=true AND the Lidarr unmonitor call failed. The destructive - * file + DB delete already succeeded — surface a follow-up toast so - * the operator knows to manually unmonitor in Lidarr. */ - lidarr_unmonitor_failed?: true; -}; - -export type RemoveTrackOptions = { - /** When true, after the file + DB delete the server calls - * Lidarr.UnmonitorTrack so Lidarr won't search for a replacement. - * When false / omitted, no Lidarr call — Lidarr's monitoring will - * re-import on next scan, which is the operator's "find a replacement" - * path. */ - unmonitor?: boolean; -}; - -/** - * DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from - * disk and the DB row, cascading album/artist tidy-up. Optionally - * unmonitors the track in Lidarr. - * - * On success returns the deleted track id plus optional album/artist - * ids when their parent row was tidied up by the server-side cascade. - * Caller is responsible for invalidating any TanStack Query keys the - * deleted entities backed. - */ -export async function removeTrack( - id: string, - options: RemoveTrackOptions = {} -): Promise { - const params = new URLSearchParams(); - if (options.unmonitor) params.set('unmonitor', 'true'); - const qs = params.toString(); - const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`; - return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult; -} diff --git a/web/src/lib/components/CompactTrackCard.svelte b/web/src/lib/components/CompactTrackCard.svelte index 2588bb7a..3452cc10 100644 --- a/web/src/lib/components/CompactTrackCard.svelte +++ b/web/src/lib/components/CompactTrackCard.svelte @@ -1,9 +1,10 @@ - - diff --git a/web/src/lib/components/RemoveTrackPopover.test.ts b/web/src/lib/components/RemoveTrackPopover.test.ts deleted file mode 100644 index 93b2505a..00000000 --- a/web/src/lib/components/RemoveTrackPopover.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { makeTrack } from '$test-utils/fixtures/track'; - -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 = makeTrack({ title: 'Roygbiv' }); - -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); - }); -}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index d883211c..74661f4f 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -1,30 +1,27 @@