package recommendation import ( "context" "fmt" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // TestHomeData_YouMightLike_FallbackFillsFromLikedArtists: when the taste // roll-up is empty/thin (no you_might_like_artists rows here), the section // fills from the user's liked artists. Rediscover takes its share first; the // You-might-like fallback fills the rest disjointly so no artist is shown // twice. 20 liked artists, none played → Rediscover 10 + You-might-like 10. func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-yml-fallback") liked := make(map[pgtype.UUID]bool, 20) for i := 0; i < 20; i++ { a := seedArtist(t, pool, fmt.Sprintf("YmlArtist%02d", i), "") likeArtist(t, pool, user.ID, a.ID) liked[a.ID] = true } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit { t.Fatalf("you-might-like artists = %d, want %d (filled by fallback)", len(got.YouMightLikeArtists), HomeYouMightLikeLimit) } inRediscover := map[pgtype.UUID]bool{} for _, r := range got.RediscoverArtists { inRediscover[r.Artist.ID] = true } for _, r := range got.YouMightLikeArtists { if !liked[r.Artist.ID] { t.Errorf("you-might-like artist %s not from the liked pool", r.Artist.Name) } if inRediscover[r.Artist.ID] { t.Errorf("artist %s shown in both Rediscover and You-might-like", r.Artist.Name) } } } // TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback // must surface the artists of LIKED TRACKS even with no explicit artist-likes // (most users like tracks/albums, rarely artists). 12 liked tracks by distinct // artists, no artist-likes, no plays → the artist row fills from them. func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-yml-trackartists") fromLiked := make(map[pgtype.UUID]bool, 12) for i := 0; i < 12; i++ { a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "") al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i)) tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i)) if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil { t.Fatalf("like track: %v", err) } fromLiked[a.ID] = true } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit { t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)", len(got.YouMightLikeArtists), HomeYouMightLikeLimit) } for _, r := range got.YouMightLikeArtists { if !fromLiked[r.Artist.ID] { t.Errorf("artist %s not from the liked-track pool", r.Artist.Name) } } } func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-newuser") got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if got == nil { t.Fatal("HomeData returned nil payload") } if len(got.RecentlyAddedAlbums) != 0 { t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) } if len(got.RediscoverAlbums) != 0 { t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums)) } if len(got.RediscoverArtists) != 0 { t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists)) } if len(got.MostPlayedTracks) != 0 { t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks)) } if len(got.LastPlayedArtists) != 0 { t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists)) } } // Compile-time assert that pgtype.UUID is a valid map key — used by the // service when de-duping rediscover fallback rows. If this changes, the // service needs to switch to keying by string. var _ map[pgtype.UUID]struct{} func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-recents") a1 := seedArtist(t, pool, "ArtistA", "") // Three albums, oldest first — created_at increases naturally with each insert. al1 := seedAlbumForArtist(t, pool, a1.ID, "Older") al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle") al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest") got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.RecentlyAddedAlbums) != 3 { t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums)) } if got.RecentlyAddedAlbums[0].Album.ID != al3.ID || got.RecentlyAddedAlbums[1].Album.ID != al2.ID || got.RecentlyAddedAlbums[2].Album.ID != al1.ID { t.Errorf("order = [%s, %s, %s], want newest→oldest", got.RecentlyAddedAlbums[0].Album.Title, got.RecentlyAddedAlbums[1].Album.Title, got.RecentlyAddedAlbums[2].Album.Title) } } func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-mostplayed") artist := seedArtist(t, pool, "ArtistB", "") album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB") tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High") tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low") tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined") now := time.Now() for i := 0; i < 3; i++ { insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute)) } insertPlayEvent(t, pool, user.ID, tLow.ID, now) for i := 0; i < 5; i++ { insertPlayEvent(t, pool, user.ID, tQuar.ID, now) } if _, err := pool.Exec(context.Background(), `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, user.ID, tQuar.ID, ); err != nil { t.Fatalf("insert quarantine: %v", err) } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.MostPlayedTracks) != 2 { t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks)) } if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID { t.Errorf("ranking wrong: got [%s, %s], want [High, Low]", got.MostPlayedTracks[0].Track.Title, got.MostPlayedTracks[1].Track.Title) } } func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-rediscover") artist := seedArtist(t, pool, "ArtistC", "") // Like one album NOW (won't satisfy >30d eligibility) — should still // surface via fallback because primary returns 0 rows. al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike") if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`, user.ID, al.ID, ); err != nil { t.Fatalf("insert like: %v", err) } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.RediscoverAlbums) != 1 { t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums)) } if got.RediscoverAlbums[0].Album.ID != al.ID { t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) } } // TestHomeData_Rediscover_AlbumFromTrackLikes verifies the track-derived // path: 2+ liked tracks from an album (with the earliest like >30 days // ago) is enough for the album to appear in Rediscover, even with no // explicit general_likes_albums entry. func TestHomeData_Rediscover_AlbumFromTrackLikes(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-redis-tracklike") artist := seedArtist(t, pool, "ArtistTL", "") al := seedAlbumForArtist(t, pool, artist.ID, "TrackLiked") t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T1") t2 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T2") // Two track-likes, both >30 days old; no explicit album-like. if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes (user_id, track_id, liked_at) VALUES ($1, $2, now() - interval '60 days'), ($1, $3, now() - interval '45 days')`, user.ID, t1.ID, t2.ID, ); err != nil { t.Fatalf("insert track likes: %v", err) } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.RediscoverAlbums) != 1 { t.Fatalf("len = %d, want 1 (track-derived)", len(got.RediscoverAlbums)) } if got.RediscoverAlbums[0].Album.ID != al.ID { t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title) } } // TestHomeData_Rediscover_AlbumTrackLikeThreshold verifies that ONE // liked track from an album is not enough — threshold is 2 for the // track-derived path. The fallback should also miss this since no // album-like exists. func TestHomeData_Rediscover_AlbumTrackLikeThreshold(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-redis-threshold") artist := seedArtist(t, pool, "ArtistTh", "") al := seedAlbumForArtist(t, pool, artist.ID, "OneTrackLiked") t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "Solo") if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes (user_id, track_id, liked_at) VALUES ($1, $2, now() - interval '60 days')`, user.ID, t1.ID, ); err != nil { t.Fatalf("insert track like: %v", err) } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.RediscoverAlbums) != 0 { t.Errorf("len = %d, want 0 (below 2-track threshold)", len(got.RediscoverAlbums)) } } // TestHomeData_Rediscover_DiversityCap verifies that no more than 2 // albums from the same artist appear in Rediscover even when more are // eligible. func TestHomeData_Rediscover_DiversityCap(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-redis-cap") artist := seedArtist(t, pool, "ArtistDom", "") albums := []dbq.Album{ seedAlbumForArtist(t, pool, artist.ID, "A1"), seedAlbumForArtist(t, pool, artist.ID, "A2"), seedAlbumForArtist(t, pool, artist.ID, "A3"), seedAlbumForArtist(t, pool, artist.ID, "A4"), } // Explicit album-likes >30 days old on all four. for _, al := range albums { if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now() - interval '40 days')`, user.ID, al.ID, ); err != nil { t.Fatalf("insert like: %v", err) } } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } if len(got.RediscoverAlbums) != maxAlbumsPerArtistInRediscover { t.Errorf("len = %d, want %d (diversity cap)", len(got.RediscoverAlbums), maxAlbumsPerArtistInRediscover) } } // TestHomeData_Rediscover_DedupVsMostPlayed verifies that an otherwise- // eligible album whose tracks appear in the Most Played section is // dropped from Rediscover (the "you forgot about this" contract is // broken if the user is actively spinning it). func TestHomeData_Rediscover_DedupVsMostPlayed(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-redis-dedup") artist := seedArtist(t, pool, "ArtistD", "") // "Forgotten" album: liked >30d ago, no recent plays — would be // eligible on its own. forgotten := seedAlbumForArtist(t, pool, artist.ID, "Forgotten") if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now() - interval '40 days')`, user.ID, forgotten.ID, ); err != nil { t.Fatalf("insert forgotten like: %v", err) } // "Active" album: liked >30d ago BUT not played in last 14 days at // the album level (uses one >30-day-old track-like, no recent plays // — qualifies for Rediscover). Its track ALSO appears in MostPlayed // via many old plays >14 days ago... wait, we need the track to be // in MostPlayed, which means it needs play_events that aren't // skipped. MostPlayed has no recency filter; any plays count. active := seedAlbumForArtist(t, pool, artist.ID, "Active") activeT1 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT1") activeT2 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT2") if _, err := pool.Exec(context.Background(), `INSERT INTO general_likes (user_id, track_id, liked_at) VALUES ($1, $2, now() - interval '60 days'), ($1, $3, now() - interval '60 days')`, user.ID, activeT1.ID, activeT2.ID, ); err != nil { t.Fatalf("insert active track likes: %v", err) } // Many plays >14 days ago (so still Rediscover-eligible by the // "not played in last 14 days" rule) but enough to put it in // MostPlayed. for i := 0; i < 5; i++ { insertPlayEvent(t, pool, user.ID, activeT1.ID, time.Now().Add(-30*24*time.Hour)) } got, err := HomeData(context.Background(), pool, user.ID) if err != nil { t.Fatalf("HomeData: %v", err) } // MostPlayed must include the active track. foundInMP := false for _, mp := range got.MostPlayedTracks { if mp.Track.ID == activeT1.ID { foundInMP = true } } if !foundInMP { t.Fatalf("active track not in MostPlayed; test fixture broken") } // Rediscover must contain forgotten but NOT active. if len(got.RediscoverAlbums) != 1 { t.Fatalf("RediscoverAlbums len = %d, want 1 (active deduped)", len(got.RediscoverAlbums)) } if got.RediscoverAlbums[0].Album.ID != forgotten.ID { t.Errorf("got %s, want %s (active should have been deduped)", got.RediscoverAlbums[0].Album.Title, forgotten.Title) } }