package recommendation import ( "context" "encoding/json" "io" "log/slog" "os" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) func testPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) return pool } type fixture struct { pool *pgxpool.Pool q *dbq.Queries user pgtype.UUID tracks []dbq.Track // tracks[0] is the canonical seed } func newFixture(t *testing.T, n int) fixture { t.Helper() pool := testPool(t) q := dbq.New(pool) u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) } a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID}) tracks := make([]dbq.Track, 0, n) for i := 0; i < n; i++ { tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: string(rune('A' + i)), AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac", DurationMs: 120_000, }) if err != nil { t.Fatalf("track[%d]: %v", i, err) } tracks = append(tracks, tr) } return fixture{pool: pool, q: q, user: u.ID, tracks: tracks} } func TestLoadCandidates_ExcludesSeed(t *testing.T) { f := newFixture(t, 5) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 4 { t.Fatalf("len = %d, want 4 (5 minus seed)", len(got)) } for _, c := range got { if c.Track.ID == f.tracks[0].ID { t.Errorf("seed appeared in candidate set") } } } func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { f := newFixture(t, 3) // Seed = tracks[0]. Other tracks are tracks[1], tracks[2]. // Insert a play_session and a play_event for tracks[1] 30 minutes ago. now := time.Now().UTC() thirtyMinAgo := now.Add(-30 * time.Minute) sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ UserID: f.user, StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, ClientID: nil, }) _, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, ClientID: nil, }) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } // Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played). if len(got) != 1 { t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got)) } if got[0].Track.ID != f.tracks[2].ID { t.Errorf("expected tracks[2], got %v", got[0].Track.Title) } } func TestLoadCandidates_StatJoin(t *testing.T) { f := newFixture(t, 2) // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { t.Fatalf("like: %v", err) } twoH := time.Now().UTC().Add(-2 * time.Hour) sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, }) // First play: completed (was_skipped=false). pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, }) dur := int32(120_000) ratio := 1.0 _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true}, DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false, }) // Second play: skipped (was_skipped=true). pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true}, }) dur2 := int32(5_000) ratio2 := 0.04 _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true}, DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, }) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } c := got[0] if !c.Inputs.IsGeneralLiked { t.Errorf("IsGeneralLiked = false, want true") } if c.Inputs.PlayCount != 2 { t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount) } if c.Inputs.SkipCount != 1 { t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount) } if c.Inputs.LastPlayedAt == nil { t.Errorf("LastPlayedAt = nil, want non-nil") } } func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { f := newFixture(t, 2) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } if got[0].Inputs.LastPlayedAt != nil { t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt) } if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 { t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs) } } func TestLoadCandidates_CrossUserIsolation(t *testing.T) { f := newFixture(t, 2) bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, }) // Alice likes tracks[1]; Bob shouldn't see it. _, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } for _, c := range got { if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked { t.Errorf("bob sees alice's like on track %v", c.Track.Title) } } } func trackTitles(cs []Candidate) []string { out := make([]string, 0, len(cs)) for _, c := range cs { out = append(out, c.Track.Title) } return out } // helperInsertContextualLike inserts a contextual_like row with the given // session_vector marshaled to JSON. Bypasses playevents.CaptureContextualLikeIfPlaying // because we want full control over the stored vector for these unit tests. func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) { t.Helper() raw, err := json.Marshal(vec) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := f.pool.Exec(context.Background(), `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, f.user, trackID, raw); err != nil { t.Fatalf("insert: %v", err) } } func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) { f := newFixture(t, 5) current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Inputs.ContextualMatchScore != 0 { t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore) } } } func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) { f := newFixture(t, 3) target := f.tracks[1] likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} helperInsertContextualLike(t, f, target.ID, likeVec) current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) if err != nil { t.Fatalf("load: %v", err) } var found bool for _, c := range got { if c.Track.ID == target.ID { found = true if c.Inputs.ContextualMatchScore < 0.99 { t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore) } } else { if c.Inputs.ContextualMatchScore != 0 { t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore) } } } if !found { t.Error("target track missing from candidate list") } } func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) { f := newFixture(t, 3) target := f.tracks[1] helperInsertContextualLike(t, f, target.ID, SessionVector{ Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}, }) helperInsertContextualLike(t, f, target.ID, SessionVector{ Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, }) helperInsertContextualLike(t, f, target.ID, SessionVector{ Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}, }) current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 { t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore) } } } func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) { f := newFixture(t, 3) target := f.tracks[1] likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} helperInsertContextualLike(t, f, target.ID, likeVec) if _, err := f.pool.Exec(context.Background(), `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil { t.Fatalf("soft-delete: %v", err) } current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Inputs.ContextualMatchScore != 0 { t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) } } } func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) { f := newFixture(t, 3) target := f.tracks[1] helperInsertContextualLike(t, f, target.ID, SessionVector{ Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, }) current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Inputs.ContextualMatchScore != 0 { t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) } } } func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) { f := newFixture(t, 3) target := f.tracks[1] likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} helperInsertContextualLike(t, f, target.ID, likeVec) currentSeed := SessionVector{Seed: true} got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Inputs.ContextualMatchScore != 0 { t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) } } }