feat(api): GET /api/tracks/{id} handler with parent metadata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,12 @@ func writeErr(w http.ResponseWriter, status int, code, message string) {
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// writeJSON emits a 2xx response with the given body JSON-encoded. Mirrors
|
||||
// writeErr's shape so handlers have one shape for success and one for
|
||||
// failure.
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album +
|
||||
// artist names so the response is self-contained — clients should not need
|
||||
// a follow-up request to render a track outside an album view.
|
||||
func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
track, err := q.GetTrackByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get track failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: get track album failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: get track artist failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name))
|
||||
}
|
||||
|
||||
// handleGetAlbum is implemented in Task 7.
|
||||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||||
}
|
||||
|
||||
// handleGetArtist is implemented in Task 8.
|
||||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||||
}
|
||||
|
||||
// handleListArtists is implemented in Task 9.
|
||||
func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||||
}
|
||||
|
||||
// handleSearch is implemented in Task 10 (file: search.go).
|
||||
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// 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 — library handlers don't read user context.
|
||||
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/tracks/{id}", h.handleGetTrack)
|
||||
r.Get("/api/search", h.handleSearch)
|
||||
return r
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user