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:
@@ -75,7 +75,7 @@ func run() error {
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events)
|
||||
}, cfg.Events, cfg.Recommendation)
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
|
||||
+7
-2
@@ -6,19 +6,22 @@ package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
h := &handlers{pool: pool, logger: logger, events: events}
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
@@ -54,4 +57,6 @@ type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
@@ -49,7 +50,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||
return &handlers{pool: pool, logger: logger, events: w}, pool
|
||||
recCfg := config.RecommendationConfig{
|
||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||
}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
@@ -440,7 +441,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)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1})
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
+59
-9
@@ -3,11 +3,15 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// RadioResponse is the body of GET /api/radio.
|
||||
@@ -15,24 +19,40 @@ type RadioResponse struct {
|
||||
Tracks []TrackRef `json:"tracks"`
|
||||
}
|
||||
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>.
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
|
||||
//
|
||||
// M6 stub: returns a queue containing only the seed track. The full M4
|
||||
// implementation (similarity-based candidate pool + scoring) replaces the
|
||||
// body without changing the request/response shape.
|
||||
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
|
||||
// picks from the user's library, scored by recommendation.Score.
|
||||
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
|
||||
if raw == "" {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(raw)
|
||||
seedID, ok := parseUUID(raw)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id")
|
||||
return
|
||||
}
|
||||
limit := h.recCfg.RadioSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
if limit > h.recCfg.RadioSizeMax {
|
||||
limit = h.recCfg.RadioSizeMax
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
track, err := q.GetTrackByID(r.Context(), id)
|
||||
track, err := q.GetTrackByID(r.Context(), seedID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "seed_track not found")
|
||||
@@ -54,7 +74,37 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RadioResponse{
|
||||
Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)},
|
||||
})
|
||||
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: load candidates", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
|
||||
return
|
||||
}
|
||||
weights := recommendation.ScoringWeights{
|
||||
BaseWeight: h.recCfg.BaseWeight,
|
||||
LikeBoost: h.recCfg.LikeBoost,
|
||||
RecencyWeight: h.recCfg.RecencyWeight,
|
||||
SkipPenalty: h.recCfg.SkipPenalty,
|
||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
||||
}
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||
|
||||
out := make([]TrackRef, 0, len(picks)+1)
|
||||
out = append(out, trackRefFrom(track, album.Title, artist.Name))
|
||||
for _, p := range picks {
|
||||
al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: resolve album", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed")
|
||||
return
|
||||
}
|
||||
ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: resolve artist", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed")
|
||||
return
|
||||
}
|
||||
out = append(out, trackRefFrom(p.Track, al.Title, ar.Name))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RadioResponse{Tracks: out})
|
||||
}
|
||||
|
||||
+95
-55
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,16 @@ type ScanTrigger interface {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Logger *slog.Logger
|
||||
Pool *pgxpool.Pool
|
||||
Scanner ScanTrigger
|
||||
SubsonicCfg subsonic.Config
|
||||
EventsCfg config.EventsConfig
|
||||
Logger *slog.Logger
|
||||
Pool *pgxpool.Pool
|
||||
Scanner ScanTrigger
|
||||
SubsonicCfg subsonic.Config
|
||||
EventsCfg config.EventsConfig
|
||||
RecommendationCfg config.RecommendationConfig
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg}
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -54,7 +55,7 @@ func (s *Server) Router() http.Handler {
|
||||
s.EventsCfg.SkipMaxCompletionRatio,
|
||||
s.EventsCfg.SkipMaxDurationPlayedMs,
|
||||
)
|
||||
api.Mount(r, s.Pool, s.Logger, writer)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg)
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin(s.Pool))
|
||||
if s.Scanner != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user