package api import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // newLibraryRouter builds a test-only chi router with the library handlers // mounted at their path-style routes. Tests hit this router rather than // calling handler methods directly so chi URL params populate correctly. // RequireUser is NOT applied to most routes — library handlers don't read // user context. // // The stream route is wrapped with a synthetic-user middleware because // handleGetStream now enforces its own auth check (streamAuthOk) after the // UPnP slice moved it out of the authed.Group. Real traffic carries either // a session cookie (resolved by auth.OptionalUser) or a signed query // token; tests get a fake user-in-context so the cookie path of // streamAuthOk succeeds without seeding a real session row. func newLibraryRouter(h *handlers) chi.Router { r := chi.NewRouter() r.Get("/api/artists", h.handleListArtists) r.Get("/api/artists/{id}", h.handleGetArtist) r.Get("/api/albums/{id}", h.handleGetAlbum) r.Get("/api/albums/{id}/cover", h.handleGetCover) r.Get("/api/tracks/{id}", h.handleGetTrack) r.With(injectFakeUserForTest).Get("/api/tracks/{id}/stream", h.handleGetStream) r.Get("/api/search", h.handleSearch) return r } // injectFakeUserForTest attaches an empty dbq.User to request context via // auth.UserCtxKeyForTest(), letting handleGetStream's streamAuthOk succeed // on the session path without the test having to seed a real session row. // Production traffic uses auth.OptionalUser instead; this is the test-only // equivalent that bypasses the DB lookup. func injectFakeUserForTest(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), dbq.User{}) next.ServeHTTP(w, r.WithContext(ctx)) }) } func TestHandleGetTrack_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "Beatles") album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID), 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()) } var got TrackRef if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v body=%s", err, w.Body.String()) } if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" { t.Errorf("ref = %+v", got) } if got.TrackNumber != 3 || got.DurationSec != 183 { t.Errorf("positions = %+v", got) } want := "/api/tracks/" + uuidToString(track.ID) + "/stream" if got.StreamURL != want { t.Errorf("stream_url = %q, want %q", got.StreamURL, want) } } func TestHandleGetTrack_BadUUID(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleGetTrack_NotFound(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/tracks/00000000-0000-0000-0000-000000000001", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestHandleGetAlbum_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "Beatles") album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) seedTrack(t, pool, album.ID, artist.ID, "Come Together", 1, 259_000) seedTrack(t, pool, album.ID, artist.ID, "Something", 2, 183_000) req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), 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()) } var got AlbumDetail if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v body=%s", err, w.Body.String()) } if got.Title != "Abbey Road" || got.ArtistName != "Beatles" || got.Year != 1969 { t.Errorf("album = %+v", got) } if len(got.Tracks) != 2 { t.Fatalf("tracks len = %d, want 2", len(got.Tracks)) } if got.Tracks[0].Title != "Come Together" || got.Tracks[1].Title != "Something" { t.Errorf("tracks = %+v", got.Tracks) } if got.TrackCount != 2 || got.DurationSec != 442 { // 259 + 183 t.Errorf("counts = track=%d duration=%d", got.TrackCount, got.DurationSec) } want := "/api/albums/" + uuidToString(album.ID) + "/cover" if got.CoverURL != want { t.Errorf("cover_url = %q", got.CoverURL) } // StreamURL on nested tracks must be set if got.Tracks[0].StreamURL == "" { t.Error("nested track missing stream_url") } } func TestHandleGetAlbum_NoTracks(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "Beatles") album := seedAlbum(t, pool, artist.ID, "Empty", 0) req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), 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()) } var got AlbumDetail _ = json.Unmarshal(w.Body.Bytes(), &got) if got.Tracks == nil { t.Error("tracks = nil, want [] for JSON encoding") } if len(got.Tracks) != 0 { t.Errorf("tracks len = %d, want 0", len(got.Tracks)) } // Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON if got.Year != 0 { t.Errorf("year = %d, want 0", got.Year) } } func TestHandleGetAlbum_BadUUID(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleGetAlbum_NotFound(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/albums/00000000-0000-0000-0000-000000000001", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestHandleGetArtist_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "Beatles") album1 := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) album2 := seedAlbum(t, pool, artist.ID, "Let It Be", 1970) seedTrack(t, pool, album1.ID, artist.ID, "Something", 3, 183_000) seedTrack(t, pool, album2.ID, artist.ID, "Get Back", 1, 189_000) req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), 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()) } var got ArtistDetail if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v body=%s", err, w.Body.String()) } if got.Name != "Beatles" { t.Errorf("name = %q", got.Name) } if got.AlbumCount != 2 { t.Errorf("album_count = %d, want 2", got.AlbumCount) } if len(got.Albums) != 2 { t.Fatalf("albums len = %d, want 2", len(got.Albums)) } // ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first. if got.Albums[0].Title != "Abbey Road" { t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title) } for _, a := range got.Albums { if a.ArtistName != "Beatles" { t.Errorf("album.artist_name = %q", a.ArtistName) } if a.CoverURL == "" { t.Error("album missing cover_url") } if a.TrackCount != 1 { t.Errorf("album.track_count = %d, want 1", a.TrackCount) } } } func TestHandleGetArtist_NoAlbums(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "Solo") req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got ArtistDetail _ = json.Unmarshal(w.Body.Bytes(), &got) if got.Albums == nil { t.Error("albums = nil, want []") } if got.AlbumCount != 0 { t.Errorf("album_count = %d, want 0", got.AlbumCount) } } func TestHandleGetArtist_BadUUID(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/artists/not-a-uuid", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleGetArtist_NotFound(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000001", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestHandleListArtists_DefaultAlpha(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) seedArtist(t, pool, "Beatles") seedArtist(t, pool, "ABBA") seedArtist(t, pool, "Zeppelin") req := httptest.NewRequest(http.MethodGet, "/api/artists", 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()) } var got Page[ArtistRef] if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if got.Total != 3 { t.Errorf("total = %d, want 3", got.Total) } if got.Limit != 50 || got.Offset != 0 { t.Errorf("pagination defaults = limit=%d offset=%d, want 50/0", got.Limit, got.Offset) } if len(got.Items) != 3 { t.Fatalf("items len = %d", len(got.Items)) } if got.Items[0].Name != "ABBA" || got.Items[2].Name != "Zeppelin" { t.Errorf("alpha order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name) } } func TestHandleListArtists_SortNewest(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) // Seeded in this order; created_at DESC means Third first. seedArtist(t, pool, "First") seedArtist(t, pool, "Second") seedArtist(t, pool, "Third") req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=newest", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got Page[ArtistRef] _ = json.Unmarshal(w.Body.Bytes(), &got) if len(got.Items) != 3 { t.Fatalf("items len = %d", len(got.Items)) } if got.Items[0].Name != "Third" || got.Items[2].Name != "First" { t.Errorf("newest order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name) } } func TestHandleListArtists_InvalidSort(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) seedArtist(t, pool, "Only") req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=garbage", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleListArtists_Pagination(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) for _, n := range []string{"A", "B", "C", "D", "E"} { seedArtist(t, pool, n) } req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=2&offset=2", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got Page[ArtistRef] _ = json.Unmarshal(w.Body.Bytes(), &got) if got.Total != 5 { t.Errorf("total = %d, want 5", got.Total) } if got.Limit != 2 || got.Offset != 2 { t.Errorf("page = limit=%d offset=%d", got.Limit, got.Offset) } if len(got.Items) != 2 { t.Fatalf("items len = %d", len(got.Items)) } if got.Items[0].Name != "C" || got.Items[1].Name != "D" { t.Errorf("slice = %q, %q", got.Items[0].Name, got.Items[1].Name) } } func TestHandleListArtists_LimitClamped(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) seedArtist(t, pool, "One") req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=9999", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got Page[ArtistRef] _ = json.Unmarshal(w.Body.Bytes(), &got) if got.Limit != 200 { t.Errorf("limit = %d, want 200 (max clamp)", got.Limit) } } func TestHandleListArtists_LimitNonNumeric(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=abc", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } func TestHandleListArtists_AlbumCount(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) artist := seedArtist(t, pool, "A") seedAlbum(t, pool, artist.ID, "X", 0) seedAlbum(t, pool, artist.ID, "Y", 0) req := httptest.NewRequest(http.MethodGet, "/api/artists", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) var got Page[ArtistRef] _ = json.Unmarshal(w.Body.Bytes(), &got) if len(got.Items) != 1 || got.Items[0].AlbumCount != 2 { t.Errorf("items = %+v", got.Items) } } func TestHandleListArtists_EmptyDB(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) req := httptest.NewRequest(http.MethodGet, "/api/artists", nil) w := httptest.NewRecorder() newLibraryRouter(h).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got Page[ArtistRef] _ = json.Unmarshal(w.Body.Bytes(), &got) if got.Items == nil { t.Error("items = nil, want []") } if got.Total != 0 { t.Errorf("total = %d", got.Total) } } // TestRoutesRegisteredInMount verifies the production Mount() wires every // library route. We hit each path through the real Mount (no RequireUser // stripped — we expect 401 from the middleware, which is fine — that // proves the route reached the authenticated group). func TestRoutesRegisteredInMount(t *testing.T) { h, _ := testHandlers(t) r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) paths := []string{ "/api/artists", "/api/artists/00000000-0000-0000-0000-000000000001", "/api/albums/00000000-0000-0000-0000-000000000001", "/api/albums/00000000-0000-0000-0000-000000000001/cover", "/api/tracks/00000000-0000-0000-0000-000000000001", "/api/tracks/00000000-0000-0000-0000-000000000001/stream", "/api/search?q=x", } for _, p := range paths { req := httptest.NewRequest(http.MethodGet, p, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) // Unauthenticated → 401. What we must NOT see is 404 (route missing). if w.Code == http.StatusNotFound { t.Errorf("path %s returned 404 — route not registered", p) } if w.Code != http.StatusUnauthorized { t.Errorf("path %s status = %d, want 401 (unauth)", p, w.Code) } } } func TestGetArtistTracks_HappyPath(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "pw", false) q := dbq.New(pool) artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: "ArtistA", SortName: "ArtistA", }) album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID, }) _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3", FileSize: 1, FileFormat: "mp3", }) _, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3", FileSize: 1, FileFormat: "mp3", }) r := chi.NewRouter() r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) req = withUser(req, user) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) } var got []TrackRef if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 2 { t.Fatalf("len = %d, want 2", len(got)) } if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" { t.Errorf("got = %+v", got[0]) } } func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "pw", false) q := dbq.New(pool) artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: "ArtistB", SortName: "ArtistB", }) album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID, }) track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "Q", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3", FileSize: 1, FileFormat: "mp3", }) if _, err := pool.Exec(context.Background(), `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, user.ID, track.ID, ); err != nil { t.Fatalf("quarantine insert: %v", err) } r := chi.NewRouter() r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil) req = withUser(req, user) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var got []TrackRef _ = json.Unmarshal(w.Body.Bytes(), &got) if len(got) != 0 { t.Errorf("len = %d, want 0 (quarantined)", len(got)) } } func TestGetArtistTracks_NotFound(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "pw", false) r := chi.NewRouter() r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks) // Random UUID — no such artist exists. req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil) req = withUser(req, user) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } _ = pool }