package taste import ( "context" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "sync/atomic" "testing" "time" "github.com/jackc/pgx/v5" "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" ) var seedCounter uint64 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } 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, discardLogger()); 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 string) dbq.Artist { t.Helper() a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: name, SortName: name, }) if err != nil { t.Fatalf("seed artist: %v", err) } return a } // seedTrack creates an album + track under the given artist with a genre. func seedTrack(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, genre string) dbq.Track { t.Helper() q := dbq.New(pool) n := atomic.AddUint64(&seedCounter, 1) al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: fmt.Sprintf("Album %d", n), SortTitle: fmt.Sprintf("Album %d", n), ArtistID: artistID, }) if err != nil { t.Fatalf("seed album: %v", err) } g := genre tk, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: fmt.Sprintf("Track %d", n), AlbumID: al.ID, ArtistID: artistID, DurationMs: 200000, FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("t%d.mp3", n)), FileSize: 100, FileFormat: "mp3", Genre: &g, }) if err != nil { t.Fatalf("seed track: %v", err) } return tk } // seedPlay inserts a play with an explicit completion_ratio at startedAt. func seedPlay(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, completion float64) { t.Helper() _, err := pool.Exec(context.Background(), ` WITH s AS ( INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, $3, $3) RETURNING id ) INSERT INTO play_events (user_id, track_id, session_id, started_at, completion_ratio, was_skipped) SELECT $1, $2, s.id, $3, $4, false FROM s `, userID, trackID, startedAt, completion) if err != nil { t.Fatalf("seed play: %v", err) } } func artistWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) (float64, bool) { t.Helper() var w float64 err := pool.QueryRow(context.Background(), `SELECT weight FROM taste_profile_artists WHERE user_id = $1 AND artist_id = $2`, userID, artistID).Scan(&w) if errors.Is(err, pgx.ErrNoRows) { return 0, false } if err != nil { t.Fatalf("artist weight: %v", err) } return w, true } func tagWeight(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string) (float64, bool) { t.Helper() var w float64 err := pool.QueryRow(context.Background(), `SELECT weight FROM taste_profile_tags WHERE user_id = $1 AND tag = $2`, userID, tag).Scan(&w) if errors.Is(err, pgx.ErrNoRows) { return 0, false } if err != nil { t.Fatalf("tag weight: %v", err) } return w, true } // TestBuildTasteProfile_PositiveVsNegative: a completed-track artist earns a // positive weight; an early-skipped artist earns a negative one. Genre tags // follow the same sign. func TestBuildTasteProfile_PositiveVsNegative(t *testing.T) { pool := newPool(t) now := time.Now().UTC() u := seedUser(t, pool, "tp1") loved := seedArtist(t, pool, "Loved") lovedTrack := seedTrack(t, pool, loved.ID, "Rock") skipped := seedArtist(t, pool, "Skipped") skippedTrack := seedTrack(t, pool, skipped.ID, "Noise") for i := 0; i < 3; i++ { seedPlay(t, pool, u.ID, lovedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0) seedPlay(t, pool, u.ID, skippedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 0.02) } if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } lw, ok := artistWeight(t, pool, u.ID, loved.ID) if !ok || lw <= 0 { t.Errorf("loved artist weight = %.3f (present=%v), want > 0", lw, ok) } sw, ok := artistWeight(t, pool, u.ID, skipped.ID) if !ok || sw >= 0 { t.Errorf("skipped artist weight = %.3f (present=%v), want < 0", sw, ok) } if rw, ok := tagWeight(t, pool, u.ID, "Rock"); !ok || rw <= 0 { t.Errorf("Rock tag weight = %.3f (present=%v), want > 0", rw, ok) } } // TestBuildTasteProfile_AggregationProtectsArtist: one early-skip among many // completed plays must NOT make a loved artist net-negative (the user's // concern that a track-dislike shouldn't tank the artist). func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) { pool := newPool(t) now := time.Now().UTC() u := seedUser(t, pool, "tp2") fav := seedArtist(t, pool, "Fav") for i := 0; i < 4; i++ { tk := seedTrack(t, pool, fav.ID, "Indie") seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0) seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+5)*time.Hour), 0.95) } // One early-skipped track by the same artist. skipTrack := seedTrack(t, pool, fav.ID, "Indie") seedPlay(t, pool, u.ID, skipTrack.ID, now.Add(-2*time.Hour), 0.02) if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } w, ok := artistWeight(t, pool, u.ID, fav.ID) if !ok || w <= 0 { t.Errorf("fav artist weight = %.3f (present=%v), want > 0 despite one skip", w, ok) } } // TestBuildTasteProfile_ArtistLikeBonus: an explicitly-liked artist with no // plays still earns a positive weight from the like bonus. func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) { pool := newPool(t) u := seedUser(t, pool, "tp3") liked := seedArtist(t, pool, "LikedOnly") if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`, u.ID, liked.ID); err != nil { t.Fatalf("seed artist like: %v", err) } if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } w, ok := artistWeight(t, pool, u.ID, liked.ID) if !ok || w <= 0 { t.Errorf("liked-only artist weight = %.3f (present=%v), want > 0 from like bonus", w, ok) } } // TestBuildTasteProfile_AtomicReplace: a second build replaces, not appends. func TestBuildTasteProfile_AtomicReplace(t *testing.T) { pool := newPool(t) ctx := context.Background() now := time.Now().UTC() u := seedUser(t, pool, "tp4") a := seedArtist(t, pool, "A") tk := seedTrack(t, pool, a.ID, "Rock") seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Hour), 1.0) count := func() int { var n int if err := pool.QueryRow(ctx, `SELECT count(*) FROM taste_profile_artists WHERE user_id = $1`, u.ID).Scan(&n); err != nil { t.Fatalf("count: %v", err) } return n } for i := 0; i < 2; i++ { if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build %d: %v", i, err) } } if got := count(); got != 1 { t.Errorf("artist rows after two builds = %d, want 1 (atomic replace)", got) } }