package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" ) func newTuningRouter(h *handlers) chi.Router { r := chi.NewRouter() r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning) r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning) r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning) return r } func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot { t.Helper() var snap tuningSnapshot if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil { t.Fatalf("decode: %v", err) } return snap } func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil) rec := httptest.NewRecorder() newTuningRouter(h).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } snap := decodeTuning(t, rec) if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 { t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles) } if snap.Taste.HalfLifeDays != 75 { t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays) } if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] { t.Error("untouched values must equal shipped defaults") } } func TestRecommendationTuning_PatchAndReset(t *testing.T) { h, _ := testHandlers(t) r := newTuningRouter(h) req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio", strings.NewReader(`{"values":{"taste_weight": 2.5}}`)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String()) } snap := decodeTuning(t, rec) if snap.Profiles["radio"].TasteWeight != 2.5 { t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight) } // The change is live for the radio scoring path. if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 { t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got) } req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil) rec = httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("reset status = %d, want 200", rec.Code) } snap = decodeTuning(t, rec) if snap.Profiles["radio"].TasteWeight != 1.0 { t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight) } } func TestRecommendationTuning_PatchErrors(t *testing.T) { h, _ := testHandlers(t) r := newTuningRouter(h) cases := []struct { name, path, body string want int }{ {"unknown scope", "/api/admin/recommendation-tuning/banana", `{"values":{"taste_weight":1}}`, http.StatusNotFound}, {"unknown field", "/api/admin/recommendation-tuning/radio", `{"values":{"vibes":1}}`, http.StatusBadRequest}, {"out of range", "/api/admin/recommendation-tuning/taste", `{"values":{"engagement_neutral":2}}`, http.StatusBadRequest}, {"empty values", "/api/admin/recommendation-tuning/radio", `{"values":{}}`, http.StatusBadRequest}, {"bad json", "/api/admin/recommendation-tuning/radio", `{`, http.StatusBadRequest}, } for _, c := range cases { req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != c.want { t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want) } } }