feat(api): rewrite /api/radio with weighted shuffle v1

Validates seed_track + optional limit (default cfg.Recommendation.RadioSize,
clamped to RadioSizeMax). Calls recommendation.LoadCandidates +
recommendation.Shuffle. Prepends seed to result. Server seeds a
math/rand source at startup; handlers package threads that as a
func() float64 so tests inject deterministic RNGs.

Mount + server.New gain a RecommendationConfig parameter.
This commit is contained in:
2026-04-27 08:08:10 -04:00
parent 9426dc2eeb
commit 08591debee
8 changed files with 186 additions and 82 deletions
+95 -55
View File
@@ -1,90 +1,130 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func newRadioRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/radio", h.handleRadio)
return r
func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleRadio(w, req)
return w
}
func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) {
func TestHandleRadio_ColdStart_OnlySeedReturned(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/radio?seed_track="+uuidToString(track.ID), nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var got struct {
Tracks []TrackRef `json:"tracks"`
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Tracks) != 1 {
t.Fatalf("len = %d, want 1", len(resp.Tracks))
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("len=%d, want 1", len(got.Tracks))
}
if got.Tracks[0].Title != "Something" {
t.Errorf("title=%q", got.Tracks[0].Title)
}
if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" {
t.Errorf("ref = %+v", got.Tracks[0])
if resp.Tracks[0].Title != "Seed" {
t.Errorf("seed not first: %v", resp.Tracks[0].Title)
}
}
func TestHandleRadio_NotFound(t *testing.T) {
func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
for i := 2; i <= 6; i++ {
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
}
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
}
func TestHandleRadio_MissingSeed(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Tracks) != 6 {
t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks))
}
if resp.Tracks[0].Title != "Seed" {
t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title)
}
}
func TestHandleRadio_BlankSeed(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
func TestHandleRadio_UnknownSeedIs404(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "x", false)
w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000")
if w.Code != http.StatusNotFound {
t.Errorf("status = %d", w.Code)
}
}
func TestHandleRadio_BadUUID(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil)
w := httptest.NewRecorder()
newRadioRouter(h).ServeHTTP(w, req)
func TestHandleRadio_BadSeedIs400(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "x", false)
w := callRadio(h, user, "seed_track=not-a-uuid")
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d, want 400", w.Code)
t.Errorf("status = %d", w.Code)
}
}
func TestHandleRadio_MissingSeedIs400(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "x", false)
w := callRadio(h, user, "")
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d", w.Code)
}
}
func TestHandleRadio_BadLimitIs400(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0")
if w.Code != http.StatusBadRequest {
t.Errorf("limit=0 status = %d", w.Code)
}
w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1")
if w.Code != http.StatusBadRequest {
t.Errorf("limit=-1 status = %d", w.Code)
}
}
func TestHandleRadio_LimitClampedToMax(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
for i := 2; i <= 6; i++ {
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
}
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
// We only have 6 tracks; clamped limit (max 200) returns all 6.
if len(resp.Tracks) != 6 {
t.Errorf("len = %d, want 6", len(resp.Tracks))
}
}