package api import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func TestAudioContentType(t *testing.T) { cases := map[string]string{ "mp3": "audio/mpeg", "MP3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg", "opus": "audio/ogg", "m4a": "audio/mp4", "aac": "audio/aac", "wav": "audio/wav", "unknown": "application/octet-stream", "": "application/octet-stream", } for in, want := range cases { if got := audioContentType(in); got != want { t.Errorf("audioContentType(%q) = %q, want %q", in, got, want) } } } func TestImageContentType(t *testing.T) { cases := map[string]string{ "/a/cover.jpg": "image/jpeg", "/a/cover.JPG": "image/jpeg", "/a/cover.jpeg": "image/jpeg", "/a/cover.png": "image/png", "/a/cover.webp": "image/webp", "/a/cover.gif": "image/gif", "/a/cover.bmp": "application/octet-stream", "/a/cover": "application/octet-stream", } for in, want := range cases { if got := imageContentType(in); got != want { t.Errorf("imageContentType(%q) = %q, want %q", in, got, want) } } } func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil { t.Fatal(err) } got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "any-track.flac"))) if got != filepath.Join(dir, "cover.jpg") { t.Errorf("got %q, want cover.jpg path", got) } } func TestFindSidecarCover_FallsBackToFolder(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil { t.Fatal(err) } got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "t.flac"))) if got != filepath.Join(dir, "folder.png") { t.Errorf("got %q, want folder.png path", got) } } func TestFindSidecarCover_NoneFound(t *testing.T) { dir := t.TempDir() if got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "t.flac"))); got != "" { t.Errorf("got %q, want empty string", got) } } func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) { _, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) dir := t.TempDir() explicit := filepath.Join(dir, "explicit.jpg") if err := os.WriteFile(explicit, []byte("e"), 0o644); err != nil { t.Fatal(err) } if _, err := pool.Exec(context.Background(), `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil { t.Fatalf("UPDATE albums: %v", err) } got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) if got != explicit { t.Errorf("got %q, want %q", got, explicit) } } func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) { _, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") sidecar := filepath.Join(dir, "cover.jpg") if err := os.WriteFile(sidecar, []byte("s"), 0o644); err != nil { t.Fatal(err) } if _, err := pool.Exec(context.Background(), `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil { t.Fatalf("UPDATE albums: %v", err) } got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) if got != sidecar { t.Errorf("got %q, want %q", got, sidecar) } } func TestResolveAlbumCoverPath_NoArt(t *testing.T) { _, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album) if got != "" { t.Errorf("got %q, want empty string", got) } } // fetchAlbum reloads the album row so tests see updates made via raw SQL. func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album { t.Helper() a, err := dbq.New(pool).GetAlbumByID(context.Background(), id) if err != nil { t.Fatalf("GetAlbumByID: %v", err) } return a } func TestHandleGetCover_SidecarHappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { t.Errorf("Content-Type = %q", ct) } if !bytes.Equal(w.Body.Bytes(), coverBytes) { t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes) } } func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) dir := t.TempDir() explicit := filepath.Join(dir, "art.png") pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil { t.Fatal(err) } if _, err := pool.Exec(context.Background(), `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "image/png" { t.Errorf("Content-Type = %q", ct) } if !bytes.Equal(w.Body.Bytes(), pngBytes) { t.Errorf("body mismatch") } } func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") sidecarBytes := []byte("sidecar") if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil { t.Fatal(err) } if _, err := pool.Exec(context.Background(), `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } if !bytes.Equal(w.Body.Bytes(), sidecarBytes) { t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes) } } func TestHandleGetCover_NoArt(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d", w.Code) } var body errorBody _ = json.Unmarshal(w.Body.Bytes(), &body) if body.Error.Code != "not_found" || body.Error.Message != "cover not found" { t.Errorf("error body = %+v", body) } } func TestHandleGetCover_BadUUID(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleGetCover_UnknownAlbum(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/albums/00000000-0000-0000-0000-000000000001/cover", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d", w.Code) } var body errorBody _ = json.Unmarshal(w.Body.Bytes(), &body) if body.Error.Message != "album not found" { t.Errorf("message = %q, want \"album not found\"", body.Error.Message) } } // deterministicBytes returns n bytes whose values cycle 0..255. Using a // fixed pattern (not crypto/rand) makes assertions easy and the test // reproducible. func deterministicBytes(n int) []byte { b := make([]byte, n) for i := range b { b[i] = byte(i % 256) } return b } func TestHandleGetStream_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) body := deterministicBytes(32 * 1024) track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" { t.Errorf("Content-Type = %q", ct) } if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" { t.Errorf("Accept-Ranges = %q", ar) } if cl := w.Header().Get("Content-Length"); cl != "32768" { t.Errorf("Content-Length = %q, want 32768", cl) } if !bytes.Equal(w.Body.Bytes(), body) { t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body)) } } func TestHandleGetStream_RangeFromStart(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) body := deterministicBytes(32 * 1024) track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) req.Header.Set("Range", "bytes=0-99") w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusPartialContent { t.Fatalf("status = %d, want 206", w.Code) } if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" { t.Errorf("Content-Range = %q", cr) } if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) { t.Errorf("body mismatch: len=%d, want 100", len(got)) } } func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) body := deterministicBytes(32 * 1024) track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) req.Header.Set("Range", "bytes=32000-") w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusPartialContent { t.Fatalf("status = %d", w.Code) } if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" { t.Errorf("Content-Range = %q", cr) } if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) { t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000) } } func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) body := deterministicBytes(1024) track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3") info, err := os.Stat(track.FilePath) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil) req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat)) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotModified { t.Fatalf("status = %d, want 304", w.Code) } if got := w.Body.Len(); got != 0 { t.Errorf("body len = %d, want 0 for 304", got) } } func TestHandleGetStream_BadUUID(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleGetStream_UnknownTrack(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d", w.Code) } var body errorBody _ = json.Unmarshal(w.Body.Bytes(), &body) if body.Error.Message != "track not found" { t.Errorf("message = %q, want \"track not found\"", body.Error.Message) } } func TestHandleGetStream_VanishedFile(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") album := seedAlbum(t, pool, artist.ID, "X", 0) dir := t.TempDir() // Insert a track whose file_path points somewhere we never create. gone := filepath.Join(dir, "does-not-exist.mp3") if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "gone", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3", }); err != nil { t.Fatal(err) } // Look up the just-inserted track so we can address it by id. tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("status = %d", w.Code) } var body errorBody _ = json.Unmarshal(w.Body.Bytes(), &body) if body.Error.Message != "track file not found" { t.Errorf("message = %q, want \"track file not found\"", body.Error.Message) } }