From 22622f3065dc327d563223b6eaef570d2a9df2d1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 21:20:01 -0400 Subject: [PATCH] test(api): end-to-end contextual ranking test for /api/radio Co-Authored-By: Claude Sonnet 4.6 --- internal/api/library_fixtures_test.go | 66 ++++++++++++++++++++++++ internal/api/radio_test.go | 73 +++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/internal/api/library_fixtures_test.go b/internal/api/library_fixtures_test.go index dd685fb2..6c6a7d2f 100644 --- a/internal/api/library_fixtures_test.go +++ b/internal/api/library_fixtures_test.go @@ -89,6 +89,72 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, return tr } +// seedTrackWithGenre is seedTrack but lets the caller set Genre. Used by +// tests that exercise the contextual recommendation path, since session +// vectors collect tags from tracks.genre. +func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32, genre string) dbq.Track { + t.Helper() + var tn *int32 + if trackNumber > 0 { + v := int32(trackNumber) + tn = &v + } + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, + AlbumID: albumID, + ArtistID: artistID, + TrackNumber: tn, + DiscNumber: nil, + DurationMs: durationMs, + FilePath: "/seed/" + title + ".flac", + FileSize: 1024, + FileFormat: "flac", + Bitrate: nil, + Mbid: nil, + Genre: &genre, + }) + if err != nil { + t.Fatalf("UpsertTrack(%s): %v", title, err) + } + return tr +} + +// insertOpenSessionWithVector creates a play_session with ended_at NULL and +// inserts a play_event in it whose session_vector_at_play is the given JSON. +// Used to simulate "user is mid-listen" for the radio handler's current-vector +// lookup. The play_event references a placeholder track inserted on the fly +// so the FK is valid; the placeholder is not used for radio scoring. +func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { + t.Helper() + q := dbq.New(pool) + al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, + }) + if err != nil { + t.Fatalf("placeholder album: %v", err) + } + ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, + FilePath: "/seed/placeholder.flac", DurationMs: 100_000, FileSize: 1024, FileFormat: "flac", + }) + if err != nil { + t.Fatalf("placeholder track: %v", err) + } + var sessionID pgtype.UUID + if err := pool.QueryRow(context.Background(), + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, + userID).Scan(&sessionID); err != nil { + t.Fatalf("insert session: %v", err) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) + VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, + userID, ph.ID, sessionID, vectorJSON); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + // seedTrackWithFile creates a fresh temp directory, writes fileBody to // /.<ext>, and inserts a Track row whose file_path points at it. // Returns the inserted Track and the directory (callers drop sidecar covers diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 732ec5bd..9002efeb 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { @@ -128,3 +130,74 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) { t.Errorf("len = %d, want 6", len(resp.Tracks)) } } + +func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + + // Two artists in distinct genres, so we can construct a "rock vibe" session + // and a contextual match. + rockArtist := seedArtist(t, pool, "RockArtist") + rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020) + jazzArtist := seedArtist(t, pool, "JazzArtist") + jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020) + + // Seed track is unrelated to both (don't want it to dominate scoring). + popArtist := seedArtist(t, pool, "PopArtist") + popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020) + seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000) + + // Target: a rock track. Control: a jazz track. Both will be scored. + target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock") + control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz") + _ = control // present in DB; we look it up by title in the response + + // Build the user's "rock vibe" current context: insert an open play_session + // with a play_event whose session_vector_at_play matches the rock vibe. + rockVec := recommendation.SessionVector{ + Artists: []string{rockArtist.ID.String()}, + Tags: map[string]int{"rock": 3}, + } + rockVecJSON, err := json.Marshal(rockVec) + if err != nil { + t.Fatalf("marshal rockVec: %v", err) + } + insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON) + + // Insert a contextual_like on the target track whose stored vector matches + // the rock vibe. Direct DB insert — we want full control over the vector + // for this test. + if _, err := pool.Exec(context.Background(), + `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, + user.ID, target.ID, rockVecJSON); err != nil { + t.Fatalf("insert contextual_like: %v", err) + } + + // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0) + // means rankings are reproducible for this test. + 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 resp RadioResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + targetIdx, controlIdx := -1, -1 + for i, tr := range resp.Tracks { + if tr.Title == "Target" { + targetIdx = i + } + if tr.Title == "Control" { + controlIdx = i + } + } + if targetIdx == -1 || controlIdx == -1 { + t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks) + } + if targetIdx >= controlIdx { + t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx) + } +}