diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte new file mode 100644 index 00000000..a2a0e627 --- /dev/null +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -0,0 +1,243 @@ + + +
+ {#if playlistQuery?.isPending} +

Loading…

+ {:else if playlistQuery?.isError} + playlistQuery.refetch()} /> + {:else if playlistQuery?.data} + {@const pl = playlistQuery.data} + {#if !editing} +
+
+ {#if pl.cover_url} + + {/if} +
+
+

{pl.name}

+ {#if pl.description} +

{pl.description}

+ {/if} +

+ {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} + {#if pl.is_public}· public{:else}· private{/if} + {#if !isOwner}· by {pl.owner_username}{/if} +

+
+ {#if isOwner} + + + {/if} +
+ {:else} +
+ + + +
+ + +
+
+ {/if} + +
+ {#if pl.tracks.length === 0} +

+ {#if isOwner} + No tracks yet. Add some via the "Add to playlist…" entry on any track row. + {:else} + No tracks in this playlist. + {/if} +

+ {:else} + {#each pl.tracks as row (row.position)} + (dragFromPos = p)} + onDrop={(_, p) => onDrop(p)} + /> + {/each} + {/if} +
+ {/if} +
diff --git a/web/src/routes/playlists/[id]/playlist.test.ts b/web/src/routes/playlists/[id]/playlist.test.ts new file mode 100644 index 00000000..49023af3 --- /dev/null +++ b/web/src/routes/playlists/[id]/playlist.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import { writable } from 'svelte/store'; +import type { PlaylistDetail } from '$lib/api/types'; + +vi.mock('$app/stores', () => ({ + page: writable({ params: { id: 'p1' } }) +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +vi.mock('$lib/api/playlists', () => ({ + createPlaylistQuery: vi.fn(), + updatePlaylist: vi.fn().mockResolvedValue(undefined), + deletePlaylist: vi.fn().mockResolvedValue(undefined), + removePlaylistTrack: vi.fn().mockResolvedValue(undefined), + reorderPlaylist: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) }; +}); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: 'u-self', username: 'me', is_admin: false } } +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + enqueueTracks: vi.fn(), + playNext: vi.fn(), + enqueueTrack: vi.fn() +})); + +// Cascade through PlaylistTrackRow's mocks (LikeButton + TrackMenu transitively). +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }), + flagTrack: vi.fn(), + unflagTrack: vi.fn() +})); +vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); + +import PlaylistDetailPage from './+page.svelte'; +import { createPlaylistQuery, deletePlaylist } from '$lib/api/playlists'; + +const mockedQuery = createPlaylistQuery as ReturnType; + +const ownDetail: PlaylistDetail = { + id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine', + description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274, + created_at: '', updated_at: '', + tracks: [ + { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' }, + { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' } + ] +}; + +describe('Playlist detail page', () => { + test('renders header + tracks for owner', () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + render(PlaylistDetailPage); + expect(screen.getByText('Mine')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument(); + }); + + test('hides edit + delete for non-owner', () => { + mockedQuery.mockReturnValue(mockQuery({ + data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true } + })); + render(PlaylistDetailPage); + expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument(); + }); + + test('Delete button confirms then deletes', async () => { + mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); + const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + render(PlaylistDetailPage); + await fireEvent.click(screen.getByLabelText(/delete playlist/i)); + await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1')); + confirmSpy.mockRestore(); + }); +});