diff --git a/internal/api/errors.go b/internal/api/errors.go index f2df9ec0..3380f851 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -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) +} diff --git a/internal/api/library.go b/internal/api/library.go new file mode 100644 index 00000000..655d476d --- /dev/null +++ b/internal/api/library.go @@ -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") +} diff --git a/internal/api/library_test.go b/internal/api/library_test.go new file mode 100644 index 00000000..c939f5eb --- /dev/null +++ b/internal/api/library_test.go @@ -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) + } +}