feat(api): /api/playlists* handlers for M7 #352 slice 1
9 handlers covering create / get / list / update / delete / append /
remove / reorder / cover. Permissions enforced in the service layer
(ErrForbidden -> 403 not_authorized) so the handler is a thin
HTTP-shape adapter. /cover serves the cached collage from disk via
http.ServeFile; 404 when cover_path is nil.
Server gained a DataDir field so the playlists service can find the
collage cache; api.Mount picks up two new params (playlistsSvc and
dataDir). The stale TestRoutesRegisteredInMount Mount() call in
library_test.go was missing the tracks.Service argument added by an
earlier slice — fixed in passing.
Wire codes follow the project's existing nested errorBody envelope:
not_found, not_authorized, unauthenticated, bad_request, server_error.
The plan called for a flat envelope, but the api package's writeErr
already produces {"error":{"code":"...","message":"..."}} and
deviating here would break every existing client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -17,13 +17,14 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
)
|
||||
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, dataDir string) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -32,6 +33,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
lidarrRequests: lidarrReqs,
|
||||
lidarrQuarantine: lidarrQuar,
|
||||
tracks: tracksSvc,
|
||||
playlists: playlistsSvc,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -98,6 +101,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
||||
})
|
||||
|
||||
authed.Get("/playlists", h.handleListPlaylists)
|
||||
authed.Post("/playlists", h.handleCreatePlaylist)
|
||||
authed.Get("/playlists/{id}", h.handleGetPlaylist)
|
||||
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
|
||||
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
|
||||
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
|
||||
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -112,4 +125,6 @@ type handlers struct {
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
tracks *tracks.Service
|
||||
playlists *playlists.Service
|
||||
dataDir string
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
)
|
||||
|
||||
@@ -65,7 +66,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
||||
// when they need a stubbed Lidarr.
|
||||
tracksSvc := tracks.NewService(pool, logger, nil)
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc}
|
||||
dataDir := t.TempDir()
|
||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ func TestRoutesRegisteredInMount(t *testing.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.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.dataDir)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// playlistRowView is the wire shape for a playlist header. Mirrors
|
||||
// playlists.PlaylistRow but renders UUIDs / timestamps as strings and
|
||||
// derives the cover_url from cover_path so clients don't have to know
|
||||
// the on-disk layout.
|
||||
type playlistRowView struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
DurationSec int32 `json:"duration_sec"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// playlistTrackView is one row of a playlist's track list on the wire.
|
||||
// TrackID / AlbumID / ArtistID / StreamURL are pointers so they encode as
|
||||
// JSON null when the upstream library row has been removed (ON DELETE
|
||||
// SET NULL semantics from the snapshot table).
|
||||
type playlistTrackView struct {
|
||||
Position int32 `json:"position"`
|
||||
TrackID *string `json:"track_id"`
|
||||
AlbumID *string `json:"album_id"`
|
||||
ArtistID *string `json:"artist_id"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
DurationSec int32 `json:"duration_sec"`
|
||||
StreamURL *string `json:"stream_url"`
|
||||
AddedAt string `json:"added_at"`
|
||||
}
|
||||
|
||||
// playlistDetailView extends playlistRowView with the ordered track list.
|
||||
// Returned by Get / Append / Remove / Reorder so the SPA always has a
|
||||
// fresh authoritative state after a mutation.
|
||||
type playlistDetailView struct {
|
||||
playlistRowView
|
||||
Tracks []playlistTrackView `json:"tracks"`
|
||||
}
|
||||
|
||||
// listPlaylistsResponse splits owned vs. public so the SPA can render
|
||||
// "your playlists" and "browse public" without re-filtering. Both
|
||||
// arrays are guaranteed non-nil for stable JSON output.
|
||||
type listPlaylistsResponse struct {
|
||||
Owned []playlistRowView `json:"owned"`
|
||||
Public []playlistRowView `json:"public"`
|
||||
}
|
||||
|
||||
// handleListPlaylists implements GET /api/playlists. The service returns
|
||||
// every playlist visible to the caller (own + others' public); we split
|
||||
// the result here so the wire shape carries the distinction the UI cares
|
||||
// about without a second DB query.
|
||||
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
rows, err := h.playlists.List(r.Context(), caller.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list playlists failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||
return
|
||||
}
|
||||
resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}}
|
||||
for i := range rows {
|
||||
v := playlistRowToView(&rows[i])
|
||||
if uuidEqual(rows[i].UserID, caller.ID) {
|
||||
resp.Owned = append(resp.Owned, v)
|
||||
} else {
|
||||
resp.Public = append(resp.Public, v)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type createPlaylistBody struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// handleCreatePlaylist implements POST /api/playlists. Empty Name is
|
||||
// rejected by the service (ErrInvalidInput → 400 bad_request).
|
||||
func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
var body createPlaylistBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "create")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||
}
|
||||
|
||||
// handleGetPlaylist implements GET /api/playlists/{id}. Visibility is
|
||||
// owner-or-public; the service returns ErrForbidden when a non-owner
|
||||
// asks for a private playlist.
|
||||
func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||
}
|
||||
|
||||
// updatePlaylistBody uses pointers so omitted fields stay untouched
|
||||
// (PATCH semantics). The service rejects an explicitly-empty Name.
|
||||
type updatePlaylistBody struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
}
|
||||
|
||||
// handleUpdatePlaylist implements PATCH /api/playlists/{id}. Owner only;
|
||||
// non-owners get 403 not_authorized.
|
||||
func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
var body updatePlaylistBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{
|
||||
Name: body.Name,
|
||||
Description: body.Description,
|
||||
IsPublic: body.IsPublic,
|
||||
})
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "update")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||
}
|
||||
|
||||
// handleDeletePlaylist implements DELETE /api/playlists/{id}. Owner only;
|
||||
// returns 204 on success.
|
||||
func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil {
|
||||
h.writePlaylistErr(w, err, "delete")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type appendTracksBody struct {
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
}
|
||||
|
||||
// handleAppendTracks implements POST /api/playlists/{id}/tracks. Owner
|
||||
// only. Returns the playlist detail (with the new track rows) so the
|
||||
// SPA never has to re-GET after a mutation.
|
||||
func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
var body appendTracksBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
|
||||
for _, s := range body.TrackIDs {
|
||||
u, ok := parseUUID(s)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("invalid track id %q", s))
|
||||
return
|
||||
}
|
||||
trackIDs = append(trackIDs, u)
|
||||
}
|
||||
if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil {
|
||||
h.writePlaylistErr(w, err, "append tracks")
|
||||
return
|
||||
}
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-append")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||
}
|
||||
|
||||
// handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}.
|
||||
// Owner only. Returns the post-mutation detail like AppendTracks.
|
||||
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
posStr := chi.URLParam(r, "position")
|
||||
pos64, err := strconv.ParseInt(posStr, 10, 32)
|
||||
if err != nil || pos64 < 0 {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid position")
|
||||
return
|
||||
}
|
||||
if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil {
|
||||
h.writePlaylistErr(w, err, "remove track")
|
||||
return
|
||||
}
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-remove")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||
}
|
||||
|
||||
type reorderTracksBody struct {
|
||||
OrderedPositions []int32 `json:"ordered_positions"`
|
||||
}
|
||||
|
||||
// handleReorderPlaylist implements PUT /api/playlists/{id}/tracks. Owner
|
||||
// only. The body carries a permutation of every existing position; the
|
||||
// service validates length and uniqueness.
|
||||
func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
var body reorderTracksBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil {
|
||||
h.writePlaylistErr(w, err, "reorder")
|
||||
return
|
||||
}
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-reorder")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||
}
|
||||
|
||||
// handleGetPlaylistCover serves the cached collage from disk. The
|
||||
// playlist visibility check (owner-or-public) runs in the service Get
|
||||
// call before we look at cover_path, so we don't leak the existence of
|
||||
// private playlists to non-owners.
|
||||
func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||
return
|
||||
}
|
||||
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||
return
|
||||
}
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get cover")
|
||||
return
|
||||
}
|
||||
if detail.CoverPath == nil || *detail.CoverPath == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "no cover")
|
||||
return
|
||||
}
|
||||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
// writePlaylistErr maps the typed errors out of the playlists service to
|
||||
// the project's standard error envelope. Unknown errors are logged at
|
||||
// the handler boundary and surfaced as 500 server_error so callers don't
|
||||
// see service-internal wrap text.
|
||||
func (h *handlers) writePlaylistErr(w http.ResponseWriter, err error, op string) {
|
||||
switch {
|
||||
case errors.Is(err, playlists.ErrNotFound):
|
||||
writeErr(w, http.StatusNotFound, "not_found", "playlist not found")
|
||||
case errors.Is(err, playlists.ErrForbidden):
|
||||
writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist")
|
||||
case errors.Is(err, playlists.ErrInvalidInput):
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
case errors.Is(err, playlists.ErrTrackNotFound):
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "one of the supplied track ids does not exist")
|
||||
default:
|
||||
h.logger.Error("api: playlist op failed", "op", op, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", op+" failed")
|
||||
}
|
||||
}
|
||||
|
||||
// playlistRowToView projects a service-layer PlaylistRow onto the wire
|
||||
// shape. CoverURL is derived from the presence of cover_path — the
|
||||
// /cover endpoint serves the file from disk.
|
||||
func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
|
||||
v := playlistRowView{
|
||||
ID: uuidToString(r.ID),
|
||||
UserID: uuidToString(r.UserID),
|
||||
OwnerUsername: r.OwnerUsername,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
IsPublic: r.IsPublic,
|
||||
TrackCount: r.TrackCount,
|
||||
DurationSec: r.DurationSec,
|
||||
CreatedAt: formatTimestamp(r.CreatedAt),
|
||||
UpdatedAt: formatTimestamp(r.UpdatedAt),
|
||||
}
|
||||
if r.CoverPath != nil && *r.CoverPath != "" {
|
||||
v.CoverURL = "/api/playlists/" + v.ID + "/cover"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// playlistDetailToView extends playlistRowToView with the ordered track
|
||||
// list. Empty playlists get a non-nil empty slice so the JSON encoder
|
||||
// renders `"tracks": []` rather than `null`.
|
||||
func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
|
||||
out := playlistDetailView{
|
||||
playlistRowView: playlistRowToView(&d.PlaylistRow),
|
||||
Tracks: make([]playlistTrackView, 0, len(d.Tracks)),
|
||||
}
|
||||
for _, t := range d.Tracks {
|
||||
v := playlistTrackView{
|
||||
Position: t.Position,
|
||||
Title: t.Title,
|
||||
ArtistName: t.ArtistName,
|
||||
AlbumTitle: t.AlbumTitle,
|
||||
DurationSec: t.DurationSec,
|
||||
AddedAt: formatTimestamp(t.AddedAt),
|
||||
}
|
||||
if t.TrackID != nil {
|
||||
s := uuidToString(*t.TrackID)
|
||||
v.TrackID = &s
|
||||
streamURL := "/api/tracks/" + s + "/stream"
|
||||
v.StreamURL = &streamURL
|
||||
}
|
||||
if t.AlbumID != nil {
|
||||
s := uuidToString(*t.AlbumID)
|
||||
v.AlbumID = &s
|
||||
}
|
||||
if t.ArtistID != nil {
|
||||
s := uuidToString(*t.ArtistID)
|
||||
v.ArtistID = &s
|
||||
}
|
||||
out.Tracks = append(out.Tracks, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uuidEqual compares two pgtype.UUID values for identity. Two
|
||||
// invalid/zero UUIDs never compare equal — the caller's user id is
|
||||
// always a real value, and we don't want a malformed playlist row to
|
||||
// accidentally land in the "owned" bucket.
|
||||
func uuidEqual(a, b pgtype.UUID) bool {
|
||||
if !a.Valid || !b.Valid {
|
||||
return false
|
||||
}
|
||||
return a.Bytes == b.Bytes
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi
|
||||
// instance. RequireUser is NOT applied — tests inject the user into
|
||||
// context manually so we exercise the handler-owned auth defense
|
||||
// directly. Mirrors the pattern from newAdminTracksRouter.
|
||||
func newPlaylistsRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/playlists", h.handleListPlaylists)
|
||||
r.Post("/api/playlists", h.handleCreatePlaylist)
|
||||
r.Get("/api/playlists/{id}", h.handleGetPlaylist)
|
||||
r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist)
|
||||
r.Delete("/api/playlists/{id}", h.handleDeletePlaylist)
|
||||
r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks)
|
||||
r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||
r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||
r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||
return r
|
||||
}
|
||||
|
||||
// doPlaylistsReq fires an HTTP request with the given user injected into
|
||||
// the request context. Body may be nil.
|
||||
func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
newPlaylistsRouter(h).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// seedSimpleTrack inserts an artist + album + track triple under unique
|
||||
// titles. Used by the append/reorder test that doesn't care about
|
||||
// genre / file content. seedTrack from library_fixtures_test.go takes
|
||||
// IDs the caller already has; we collapse the three calls here.
|
||||
func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track {
|
||||
t.Helper()
|
||||
a := seedArtist(t, pool, artist)
|
||||
al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0)
|
||||
return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000)
|
||||
}
|
||||
|
||||
// TestPlaylists_NoSession401 verifies the handler-owned defensive 401
|
||||
// path on each route. Real traffic hits RequireUser upstream, but the
|
||||
// handler also defends against routing misconfigurations.
|
||||
func TestPlaylists_NoSession401(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
r := newPlaylistsRouter(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil)
|
||||
w := httptest.NewRecorder()
|
||||
// Note: NO user in context.
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var env errorBody
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if env.Error.Code != "unauthenticated" {
|
||||
t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_CreateThenGet exercises the happy-path end-to-end:
|
||||
// POST creates a row, the response carries the id, GET round-trips
|
||||
// the same fields.
|
||||
func TestPlaylists_CreateThenGet(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
|
||||
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("created.id empty")
|
||||
}
|
||||
if created.Name != "Saturday morning" {
|
||||
t.Errorf("name = %q, want Saturday morning", created.Name)
|
||||
}
|
||||
if created.IsPublic {
|
||||
t.Error("is_public = true, want false")
|
||||
}
|
||||
if created.OwnerUsername != user.Username {
|
||||
t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username)
|
||||
}
|
||||
|
||||
w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var got playlistDetailView
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode get: %v; body = %s", err, w2.Body.String())
|
||||
}
|
||||
if got.Name != "Saturday morning" {
|
||||
t.Errorf("get name = %q", got.Name)
|
||||
}
|
||||
if got.Tracks == nil {
|
||||
t.Error("tracks is nil; want empty slice")
|
||||
}
|
||||
if len(got.Tracks) != 0 {
|
||||
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_CreateEmptyName400 verifies the service's
|
||||
// ErrInvalidInput surfaces as bad_request on the wire.
|
||||
func TestPlaylists_CreateEmptyName400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
body := []byte(`{"name":"","description":""}`)
|
||||
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var env errorBody
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Error.Code != "bad_request" {
|
||||
t.Errorf("error.code = %q, want bad_request", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403
|
||||
// not_authorized path. Bob creates nothing; Alice creates a playlist;
|
||||
// Bob tries to PATCH it.
|
||||
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
bob := seedUser(t, pool, "bob", "pw", false)
|
||||
|
||||
createBody := []byte(`{"name":"Mine","is_public":true}`)
|
||||
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody)
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
patchBody := []byte(`{"name":"Hijacked"}`)
|
||||
pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
|
||||
if pw.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String())
|
||||
}
|
||||
var env errorBody
|
||||
if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Error.Code != "not_authorized" {
|
||||
t.Errorf("error.code = %q, want not_authorized", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_GetNotFound verifies a well-formed UUID with no row
|
||||
// surfaces as 404 not_found.
|
||||
func TestPlaylists_GetNotFound(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
missing := "00000000-0000-0000-0000-000000000099"
|
||||
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries
|
||||
// owned vs. public buckets correctly. Alice owns one private + one
|
||||
// public; Bob sees only Alice's public.
|
||||
func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
bob := seedUser(t, pool, "bob", "pw", false)
|
||||
|
||||
for _, body := range [][]byte{
|
||||
[]byte(`{"name":"AlicePrivate","is_public":false}`),
|
||||
[]byte(`{"name":"AlicePublic","is_public":true}`),
|
||||
} {
|
||||
w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Bob lists: should see exactly one public playlist owned by Alice.
|
||||
bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil)
|
||||
if bw.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d %s", bw.Code, bw.Body.String())
|
||||
}
|
||||
var bobResp listPlaylistsResponse
|
||||
if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(bobResp.Owned) != 0 {
|
||||
t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned))
|
||||
}
|
||||
if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" {
|
||||
t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public)
|
||||
}
|
||||
|
||||
// Alice lists: sees both as owned, public bucket empty.
|
||||
aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil)
|
||||
if aw.Code != http.StatusOK {
|
||||
t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
var aliceResp listPlaylistsResponse
|
||||
if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(aliceResp.Owned) != 2 {
|
||||
t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned))
|
||||
}
|
||||
if len(aliceResp.Public) != 0 {
|
||||
t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_AppendThenReorder appends three tracks and then reverses
|
||||
// their order via the reorder endpoint. The post-mutation detail body
|
||||
// must show Track 3 before Track 1.
|
||||
func TestPlaylists_AppendThenReorder(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
t1 := seedSimpleTrack(t, pool, "Track 1", "Artist")
|
||||
t2 := seedSimpleTrack(t, pool, "Track 2", "Artist")
|
||||
t3 := seedSimpleTrack(t, pool, "Track 3", "Artist")
|
||||
|
||||
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||
[]byte(`{"name":"R"}`))
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
||||
uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID),
|
||||
}})
|
||||
aw := doPlaylistsReq(h, user, http.MethodPost,
|
||||
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
||||
if aw.Code != http.StatusOK {
|
||||
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
var afterAppend playlistDetailView
|
||||
if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil {
|
||||
t.Fatalf("decode append: %v", err)
|
||||
}
|
||||
if len(afterAppend.Tracks) != 3 {
|
||||
t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks))
|
||||
}
|
||||
|
||||
// Reverse: positions [2, 1, 0].
|
||||
rw := doPlaylistsReq(h, user, http.MethodPut,
|
||||
"/api/playlists/"+created.ID+"/tracks",
|
||||
[]byte(`{"ordered_positions":[2,1,0]}`))
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
gw := doPlaylistsReq(h, user, http.MethodGet,
|
||||
"/api/playlists/"+created.ID, nil)
|
||||
if gw.Code != http.StatusOK {
|
||||
t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String())
|
||||
}
|
||||
body := gw.Body.String()
|
||||
pos3 := strings.Index(body, `"title":"Track 3"`)
|
||||
pos1 := strings.Index(body, `"title":"Track 1"`)
|
||||
if pos3 == -1 || pos1 == -1 {
|
||||
t.Fatalf("titles missing from body: %s", body)
|
||||
}
|
||||
if pos3 > pos1 {
|
||||
t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and
|
||||
// verifies the surviving track sits at position 0 with the renumbered
|
||||
// list length 1.
|
||||
func TestPlaylists_RemoveTrack(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
t1 := seedSimpleTrack(t, pool, "First", "Artist")
|
||||
t2 := seedSimpleTrack(t, pool, "Second", "Artist")
|
||||
|
||||
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||
[]byte(`{"name":"X"}`))
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||
|
||||
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
||||
uuidToString(t1.ID), uuidToString(t2.ID),
|
||||
}})
|
||||
aw := doPlaylistsReq(h, user, http.MethodPost,
|
||||
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
||||
if aw.Code != http.StatusOK {
|
||||
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
|
||||
rw := doPlaylistsReq(h, user, http.MethodDelete,
|
||||
"/api/playlists/"+created.ID+"/tracks/0", nil)
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("remove: %d %s", rw.Code, rw.Body.String())
|
||||
}
|
||||
var afterRemove playlistDetailView
|
||||
if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(afterRemove.Tracks) != 1 {
|
||||
t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks))
|
||||
}
|
||||
if afterRemove.Tracks[0].Title != "Second" {
|
||||
t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title)
|
||||
}
|
||||
if afterRemove.Tracks[0].Position != 0 {
|
||||
t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and
|
||||
// owner DELETE is 204.
|
||||
func TestPlaylists_DeleteOwnerOnly(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
bob := seedUser(t, pool, "bob", "pw", false)
|
||||
|
||||
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists",
|
||||
[]byte(`{"name":"D","is_public":true}`))
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||
|
||||
bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
||||
if bw.Code != http.StatusForbidden {
|
||||
t.Errorf("bob delete status = %d, want 403", bw.Code)
|
||||
}
|
||||
|
||||
aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
||||
if aw.Code != http.StatusNoContent {
|
||||
t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
|
||||
// Subsequent GET must 404.
|
||||
gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||||
if gw.Code != http.StatusNotFound {
|
||||
t.Errorf("get-after-delete status = %d, want 404", gw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_BadUUID400 verifies a malformed playlist id returns
|
||||
// bad_request rather than reaching the service layer.
|
||||
func TestPlaylists_BadUUID400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id
|
||||
// in the append body returns bad_request before reaching the DB.
|
||||
func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||
[]byte(`{"name":"A"}`))
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||
|
||||
body := []byte(`{"track_ids":["not-a-uuid"]}`)
|
||||
w := doPlaylistsReq(h, user, http.MethodPost,
|
||||
"/api/playlists/"+created.ID+"/tracks", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created
|
||||
// (empty) playlist has no cached cover and the /cover endpoint returns
|
||||
// 404 not_found rather than serving a missing file.
|
||||
func TestPlaylists_CoverNoneReturns404(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||
[]byte(`{"name":"NoCover"}`))
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||
}
|
||||
var created playlistRowView
|
||||
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||
|
||||
w := doPlaylistsReq(h, user, http.MethodGet,
|
||||
"/api/playlists/"+created.ID+"/cover", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||
@@ -62,6 +63,11 @@ type Server struct {
|
||||
SubsonicCfg subsonic.Config
|
||||
EventsCfg config.EventsConfig
|
||||
RecommendationCfg config.RecommendationConfig
|
||||
// DataDir is the on-disk root for cached artifacts (currently
|
||||
// playlist cover collages under <DataDir>/playlist_covers/). Empty
|
||||
// strings are tolerated by tests that don't exercise persisted-cover
|
||||
// codepaths; production callers should pass a writable directory.
|
||||
DataDir string
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
||||
@@ -98,7 +104,8 @@ func (s *Server) Router() http.Handler {
|
||||
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn})
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc)
|
||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.DataDir)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
Reference in New Issue
Block a user