package recommendation import ( "context" "fmt" "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 newPool(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 } func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-token", IsAdmin: false, }) if err != nil { t.Fatalf("seed user: %v", err) } return u } func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist { t.Helper() var mbidPtr *string if mbid != "" { mbidPtr = &mbid } a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: name, SortName: name, Mbid: mbidPtr, }) if err != nil { t.Fatalf("seed artist: %v", err) } return a } func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album { t.Helper() a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: artistID, }) if err != nil { t.Fatalf("seed album: %v", err) } return a } func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track { t.Helper() tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: albumID, ArtistID: artistID, DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3", FileSize: 1, FileFormat: "mp3", }) if err != nil { t.Fatalf("seed track: %v", err) } return tr } func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) { t.Helper() ctx := context.Background() var sessionID pgtype.UUID if err := pool.QueryRow(ctx, `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) VALUES ($1, $2, $2, 'm5c-test') RETURNING id`, userID, startedAt, ).Scan(&sessionID); err != nil { t.Fatalf("insert play_session: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped) VALUES ($1, $2, $3, $4, false)`, userID, trackID, sessionID, startedAt, ); err != nil { t.Fatalf("insert play_event: %v", err) } } func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) { t.Helper() if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, userID, artistID, ); err != nil { t.Fatalf("like artist: %v", err) } } func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) { t.Helper() if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ SeedArtistID: seedID, CandidateMbid: candMBID, CandidateName: candName, Score: score, Source: "listenbrainz", }); err != nil { t.Fatalf("seed unmatched: %v", err) } } func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seedA := seedArtist(t, pool, "Seed Liked", "") seedB := seedArtist(t, pool, "Seed Played", "") likeArtist(t, pool, user.ID, seedA.ID) seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B") seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B") insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour)) seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 1 { t.Fatalf("len = %d, want 1", len(out)) } s := out[0] if s.MBID != "out-mbid" || s.Name != "Outsider" { t.Errorf("got = %+v", s) } if s.Score <= 0 { t.Errorf("score = %v, want > 0", s.Score) } if len(s.Attribution) != 2 { t.Errorf("attribution len = %d, want 2", len(s.Attribution)) } } func TestSuggestArtists_Top12Cap(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seed := seedArtist(t, pool, "Seed", "") likeArtist(t, pool, user.ID, seed.ID) for i := 0; i < 30; i++ { seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) } out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 12 { t.Errorf("len = %d, want 12", len(out)) } if out[0].MBID != "mbid-00" { t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID) } } func TestSuggestArtists_AttributionTopThree(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seeds := make([]dbq.Artist, 5) for i := 0; i < 5; i++ { seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "") likeArtist(t, pool, user.ID, seeds[i].ID) seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) } out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 1 { t.Fatalf("len = %d, want 1 (shared candidate)", len(out)) } if got := len(out[0].Attribution); got != 3 { t.Errorf("attribution len = %d, want 3", got) } if out[0].Attribution[0].Name != "Seed 0" { t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name) } } func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") recentSeed := seedArtist(t, pool, "Recent", "") oldSeed := seedArtist(t, pool, "Old", "") rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album") rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track") insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour)) oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album") oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track") insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour)) seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 1 { t.Fatalf("len = %d, want 1", len(out)) } if len(out[0].Attribution) != 2 { t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution)) } if out[0].Attribution[0].Name != "Recent" { t.Errorf("top attribution = %q, want Recent", out[0].Attribution[0].Name) } if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution { t.Errorf("recent contribution (%v) should exceed old (%v)", out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution) } } func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seed := seedArtist(t, pool, "Seed", "") likeArtist(t, pool, user.ID, seed.ID) inLibMBID := "in-lib-mbid" seedArtist(t, pool, "InLib", inLibMBID) seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 0 { t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out)) } } func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seed := seedArtist(t, pool, "Seed", "") likeArtist(t, pool, user.ID, seed.ID) seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9) if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ UserID: user.ID, Kind: dbq.LidarrRequestKindArtist, LidarrArtistMbid: "req-mbid", ArtistName: "Pending Request", }); err != nil { t.Fatalf("CreateLidarrRequest: %v", err) } out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 0 { t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out)) } } func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") seed := seedArtist(t, pool, "Seed", "") likeArtist(t, pool, user.ID, seed.ID) seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9) req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{ UserID: user.ID, Kind: dbq.LidarrRequestKindArtist, LidarrArtistMbid: "rej-mbid", ArtistName: "Rejected Once", }) if err != nil { t.Fatalf("CreateLidarrRequest: %v", err) } rejNotes := "wrong artist" if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{ ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID, }); err != nil { t.Fatalf("RejectLidarrRequest: %v", err) } out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 1 { t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out)) } } func TestSuggestArtists_EmptyForNewUser(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "newbie") out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) if err != nil { t.Fatalf("SuggestArtists: %v", err) } if len(out) != 0 { t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) } }