Files
minstrel/internal/api/library_test.go
T
bvandeusen 1126bfcf78
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s
feat(library): genre + year browse queries and endpoints — #367
Server half of #367. Web UI follows.

Genres are exposed AS-IS per the operator: split on the delimiter, trimmed,
but no case folding and no synonym mapping. So "Rock" and "rock" appear as
separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread
has to be visible before anyone can judge whether it needs normalising, and
the alternative is a mapping table to invent and then maintain.

Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and
showing that as a genre distinct from "Pop" would be a bug in OUR splitting,
not fidelity to the operator's tags.

## The correctness trap this had to avoid

ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and
discover.sql have always split it on [;,]. Building the browse index by
splitting while matching exactly would have listed genres whose pages are
empty — every multi-genre track unreachable from either of its genres.

So ListAlbumsByGenre now splits too. That also fixes Subsonic
getAlbumList?type=byGenre, its only caller, which silently missed every
multi-genre track. Its Genre param went *string → string as a result.

EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one
row per (track, fragment), so a join multiplies rows per album and needs
DISTINCT to undo itself. EXISTS asks the question directly, and the count
query then matches the list query by construction rather than by coincidence.

## Genre is a query parameter, not a path segment

Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a
slash cannot survive a path segment: Go normalises %2F and the router would
split the value in two. So filtering rides GET /api/library/albums?genre=,
which also reuses the existing paged album surface instead of adding a
parallel one.

Endpoints:

  GET /api/library/genres                          unpaged index + track counts
  GET /api/library/years                           unpaged index + album counts
  GET /api/library/albums?genre=                   filtered page
  GET /api/library/albums?year_from=&year_to=      filtered page, either edge open

The indexes are unpaged deliberately: a client needs the whole set to render a
browsable picker, and paging would let it show only a prefix of an ordering
the user didn't choose.

Two refusals rather than guesses: genre+year together is a 400 (the UI browses
them as separate axes, and quietly dropping half a filter would report a
narrower result than it returned), and an inverted year range is a 400 rather
than being silently swapped.

Undated albums are absent from the year axis rather than bucketed under 0 —
"unknown" is not a year, and a 0 row would sort to one end of a chronological
list looking like data.

Tests: parseYearFilter is pure and runs in the fast lane. The integration
tests assert the thing that would otherwise be silently broken — that a
"Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a
filter value, that fragment whitespace is trimmed, and that undated albums
stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre
fixtures, which already took exactly the year and genre arguments needed.
2026-08-05 13:22:30 -04:00

595 lines
19 KiB
Go

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.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings)
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",
// Browse indexes (#367).
"/api/library/genres",
"/api/library/years",
}
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
}