package playlists_test import ( "context" "fmt" "path/filepath" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // seedTrackForArtist creates another track under an existing artist + // album pair. seedTrack's mbid-less upsert never dedupes artists, and // Deep Cuts' affinity rule needs several tracks sharing one artist_id. func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track { t.Helper() track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: albumID, ArtistID: artistID, DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("seed track for artist: %v", err) } return track } // seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by // the Rediscover cooldown, which keys off rediscover-sourced skips. func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) { 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, was_skipped, source) SELECT $1, $2, s.id, $3, $4, $5 FROM s `, userID, trackID, startedAt, wasSkipped, source) if err != nil { t.Fatalf("seed sourced play_event: %v", err) } } // idSet collapses candidate rows into a lookup by track id. func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool { out := make(map[pgtype.UUID]bool, len(rows)) for _, r := range rows { out[id(r)] = true } return out } // seedPlayWithDuration inserts a completed play with an explicit // duration_played_ms — the "attempted >= 30s" queries key off it. func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) { 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, duration_played_ms) SELECT $1, $2, s.id, $3, $4 FROM s `, userID, trackID, startedAt, durationMs) if err != nil { t.Fatalf("seed play with duration: %v", err) } } // seedAlbumForArtist creates a fresh album under an existing artist — // the tiered album queries need several albums sharing one artist_id. func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID { t.Helper() al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: artistID, }) if err != nil { t.Fatalf("seed album for artist: %v", err) } return al.ID } func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) { t.Helper() if _, err := pool.Exec(context.Background(), `UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil { t.Fatalf("backdate album: %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)`, userID, artistID); err != nil { t.Fatalf("like artist: %v", err) } } func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) { // #1257: eligibility used to count only unskipped plays, so a track // skipped repeatedly (and never finished) read as "barely heard". pool := newPool(t) q := dbq.New(pool) u := seedUser(t, pool, "dc1") now := time.Now().UTC() // Affinity artist: 5 non-skip plays on the heavy track. heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist") for p := 0; p < 5; p++ { seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false) } // Actively rejected: two skips, zero completed plays. rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID) seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true) seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true) // Genuinely unexplored. unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID) // One accidental skip stays below the threshold. oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID) seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true) rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{ UserID: u.ID, Column2: "2026-07-03", }) if err != nil { t.Fatalf("ListDeepCutsTracks: %v", err) } got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID }) if got[rejected.ID] { t.Error("twice-skipped track qualified as a deep cut; skips must disqualify") } if !got[unheard.ID] { t.Error("unexplored track missing from deep cuts") } if !got[oneSkip.ID] { t.Error("single accidental skip must not banish a track") } if got[heavy.ID] { t.Error("heavily-played track must not be a deep cut") } } func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) { // #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359 // days apart. The build date parameter anchors "today" so the wrap // is testable at any run date. pool := newPool(t) q := dbq.New(pool) u := seedUser(t, pool, "otd1") // Played Dec 28, 2025 — 6 circular days from the Jan 3 build date. winter := seedTrack(t, pool, "otd1-winter", "otd1-artist") seedPlayEvent(t, pool, u.ID, winter.ID, time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false) // Played mid-March — far outside the ±10-day window. spring := seedTrack(t, pool, "otd1-spring", "otd1-artist") seedPlayEvent(t, pool, u.ID, spring.ID, time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false) rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{ UserID: u.ID, Column2: "2026-01-03", }) if err != nil { t.Fatalf("ListOnThisDayTracks: %v", err) } got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID }) if !got[winter.ID] { t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)") } if got[spring.ID] { t.Error("play ~70 days away must not qualify") } } func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) { // #1267: tiered eligibility with consume-on-play. Tier 1 = fresh // affinity albums; tier 2 = affinity albums from the wider 90-day // window; tier 3 = fresh additions without affinity; any attempted // (>=30s) track consumes the album out of the mix entirely. pool := newPool(t) q := dbq.New(pool) u := seedUser(t, pool, "nfy1") now := time.Now().UTC() // Affinity artist via a like; anchor track establishes the artist. anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX") likeArtist(t, pool, u.ID, anchor.ArtistID) backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window // Fresh affinity album → tier 1. freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID) freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID) // Affinity album added 60 days ago → tier 2. olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID) olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID) backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour)) // Fresh album by an unrelated artist → tier 3. otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY") // Fresh affinity album with one attempted track → consumed, absent. consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID) consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID) seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000) // Affinity album added 120 days ago → outside every window, absent. ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID) ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID) backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour)) rows, err := q.ListNewForYouTracks(context.Background(), u.ID) if err != nil { t.Fatalf("ListNewForYouTracks: %v", err) } tiers := map[pgtype.UUID]int32{} for _, r := range rows { tiers[r.ID] = r.Tier } if got := tiers[freshTrack.ID]; got != 1 { t.Errorf("fresh affinity album tier = %d, want 1", got) } if got := tiers[olderTrack.ID]; got != 2 { t.Errorf("60-day affinity album tier = %d, want 2", got) } if got := tiers[otherTrack.ID]; got != 3 { t.Errorf("fresh non-affinity album tier = %d, want 3", got) } if _, in := tiers[consumedTrack.ID]; in { t.Error("album with an attempted track must be consumed out of the mix") } if _, in := tiers[ancientTrack.ID]; in { t.Error("album added 120 days ago must be outside every window") } } func TestListFirstListensTracks_AttemptThreshold(t *testing.T) { // #1268: "attempted" is track-level at >=30s. A 2-second brush no // longer disqualifies an album; barely-attempted albums step back // to tier 2 with the attempted tracks themselves excluded. pool := newPool(t) q := dbq.New(pool) u := seedUser(t, pool, "fl1") now := time.Now().UTC() // Never touched → tier 1. untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA") // One 2-second accidental brush → still tier 1 (the headline bug). brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB") seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000) // 4-track album with one attempted (25%) → tier 2, attempted track // excluded, siblings present. barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC") barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID) barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID) barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID) seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000) // 2-track album with one attempted (50%) → past the brush // threshold, absent entirely. explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD") explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID) seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000) rows, err := q.ListFirstListensTracks(context.Background(), u.ID) if err != nil { t.Fatalf("ListFirstListensTracks: %v", err) } tiers := map[pgtype.UUID]int32{} for _, r := range rows { tiers[r.ID] = r.Tier } if got := tiers[untouched.ID]; got != 1 { t.Errorf("untouched album tier = %d, want 1", got) } if got := tiers[brushed.ID]; got != 1 { t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got) } if _, in := tiers[barely1.ID]; in { t.Error("the attempted track itself is not a first listen") } for _, tr := range []dbq.Track{barely2, barely3, barely4} { if got := tiers[tr.ID]; got != 2 { t.Errorf("sibling of attempted track tier = %d, want 2", got) } } if _, in := tiers[explored1.ID]; in { t.Error("half-attempted album must be excluded (attempted track)") } if _, in := tiers[explored2.ID]; in { t.Error("half-attempted album must be excluded (sibling)") } } func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) { // #1258: a skip on a rediscover-sourced play is a declined // invitation — the track sits out ~90 days instead of re-qualifying // the next day. Skips from other surfaces don't cool down, and the // cooldown expires. pool := newPool(t) q := dbq.New(pool) u := seedUser(t, pool, "rd1") now := time.Now().UTC() eligible := func(name string, playsAge time.Duration) dbq.Track { tk := seedTrack(t, pool, name, name+"-artist") for p := 0; p < 3; p++ { seedPlayEvent(t, pool, u.ID, tk.ID, now.Add(-playsAge-time.Duration(p)*time.Hour), false) } return tk } // Declined 5 days ago from Rediscover itself → cooling down. declined := eligible("rd1-declined", 60*24*time.Hour) seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover") // Skipped 5 days ago, but from a different surface → still eligible. otherSkip := eligible("rd1-otherskip", 60*24*time.Hour) seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio") // Declined 100 days ago → cooldown expired, eligible again. lapsed := eligible("rd1-lapsed", 120*24*time.Hour) seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover") rows, err := q.ListRediscoverTracks(context.Background(), u.ID) if err != nil { t.Fatalf("ListRediscoverTracks: %v", err) } got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID }) if got[declined.ID] { t.Error("track declined from rediscover 5 days ago must be cooling down") } if !got[otherSkip.ID] { t.Error("skip from a non-rediscover surface must not cool the track down") } if !got[lapsed.ID] { t.Error("cooldown must expire after ~90 days") } }