diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte new file mode 100644 index 00000000..0f03714a --- /dev/null +++ b/web/src/routes/albums/[id]/+page.svelte @@ -0,0 +1,71 @@ + + +
+ {#if notFound} + + {:else if query.isError} + + {:else if showSkeleton.value && !query.data} + + {:else if query.data} + {@const album = query.data} +
+ {album.title} +
+

{album.title}

+

+ {album.artist_name} +

+ {#if album.year} +

{album.year}

+ {/if} +

+ {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} + ยท {formatDuration(album.duration_sec)} +

+
+
+ + {#if album.tracks.length === 0} +

This album has no tracks.

+ {:else} +
+ {#each album.tracks as track (track.id)} + + {/each} +
+ {/if} + {/if} +
diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts new file mode 100644 index 00000000..2f4bbca3 --- /dev/null +++ b/web/src/routes/albums/[id]/album.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AlbumDetail, TrackRef } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record })); + +vi.mock('$app/state', () => ({ + page: { + get params() { return state.pageParams; }, + get url() { return new URL('http://localhost/albums/' + state.pageParams.id); } + } +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { album: (id: string) => ['album', id] }, + createAlbumQuery: vi.fn() +})); + +import AlbumPage from './+page.svelte'; +import { createAlbumQuery } from '$lib/api/queries'; + +function track(id: string, title: string, number: number, dur: number): TrackRef { + return { + id, title, + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + track_number: number, disc_number: 1, + duration_sec: dur, + stream_url: `/api/tracks/${id}/stream` + }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageParams = { id: 'xyz' }; +}); + +describe('album detail page', () => { + test('renders hero metadata + one TrackRow per track', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + year: 1959, track_count: 2, duration_sec: 544 + 565, + cover_url: '/api/albums/xyz/cover', + tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue'); + expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); + expect(screen.getByText(/1959/)).toBeInTheDocument(); + expect(screen.getByText(/2 tracks/i)).toBeInTheDocument(); + + expect(screen.getByText('So What')).toBeInTheDocument(); + expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument(); + + const img = screen.getByRole('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/xyz/cover'); + }); + + test('back link points to /artists/:artistId with the artist name', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'T', + artist_id: 'md', artist_name: 'Miles Davis', + track_count: 0, duration_sec: 0, + cover_url: '/api/albums/xyz/cover', + tracks: [] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + const back = screen.getByRole('link', { name: /Miles Davis/ }); + expect(back).toHaveAttribute('href', '/artists/md'); + }); + + test('404 renders non-retryable "Album not found" with back link', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'not_found', message: 'nope', status: 404 } + })); + render(AlbumPage); + expect(screen.getByText(/album not found/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + test('non-404 error renders the retry banner', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'server_error', message: 'boom', status: 500 } + })); + render(AlbumPage); + expect(screen.getByRole('alert')).toHaveTextContent('boom'); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + test('pending (after delay) renders the album skeleton', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); + vi.useFakeTimers(); + try { + render(AlbumPage); + vi.advanceTimersByTime(200); + flushSync(); + expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +});