feat(api): add GET /api/home composite handler
Wires recommendation.HomeData into handleGetHome; maps dbq row types to the five HomePayload wire-type slices (AlbumRef/ArtistRef/TrackRef). All slices are non-nil so the SPA never receives JSON null for empty sections. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// handleGetHome implements GET /api/home. Returns five sections in one
|
||||
// payload; sections that have no rows render as []. 500 on any underlying
|
||||
// DB failure — the SPA should render either empty states or full content,
|
||||
// not a half-broken page.
|
||||
func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
data, err := recommendation.HomeData(r.Context(), h.pool, user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: home data", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "failed to load home")
|
||||
return
|
||||
}
|
||||
|
||||
out := HomePayload{
|
||||
RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)),
|
||||
RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)),
|
||||
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
for _, row := range data.RediscoverAlbums {
|
||||
out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
for _, row := range data.RediscoverArtists {
|
||||
out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
for _, row := range data.MostPlayedTracks {
|
||||
out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Compile-time guard that dbq has the row types we need; if the SQL is
|
||||
// regenerated with a different name, this fails fast at build time.
|
||||
var _ = dbq.ListMostPlayedTracksForUserRow{}.Track
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func newHomeRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/home", h.handleGetHome)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGetHome(h *handlers, user dbq.User) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/home", nil)
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
newHomeRouter(h).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHome_EmptyForNewUser(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
w := doGetHome(h, user)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got HomePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// All slices must be non-nil so the SPA branches consistently on
|
||||
// length rather than dealing with null.
|
||||
if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil ||
|
||||
got.RediscoverArtists == nil || got.MostPlayedTracks == nil ||
|
||||
got.LastPlayedArtists == nil {
|
||||
t.Errorf("payload has nil slice: %+v", got)
|
||||
}
|
||||
if len(got.RecentlyAddedAlbums) != 0 {
|
||||
t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHome_RecentlyAddedSurfacesAlbums(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: "ArtistOne", SortName: "ArtistOne",
|
||||
})
|
||||
_, _ = q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID,
|
||||
})
|
||||
|
||||
w := doGetHome(h, user)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got HomePayload
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if len(got.RecentlyAddedAlbums) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums))
|
||||
}
|
||||
if got.RecentlyAddedAlbums[0].Title != "Newest" {
|
||||
t.Errorf("title = %q, want Newest", got.RecentlyAddedAlbums[0].Title)
|
||||
}
|
||||
if got.RecentlyAddedAlbums[0].ArtistName != "ArtistOne" {
|
||||
t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user