Migration 0049 adds suggestion_snoozes(user_id, candidate_mbid, candidate_name, snoozed_until), and SuggestArtistsForUser excludes rows whose snooze hasn't expired. This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down UI; a snooze is the approved shape instead because it records no verdict on the music, expires on its own (~90d), and never reaches the taste profile. It's acquisition triage — "not right now" — so the filter sits at the candidate stage rather than in the score, where it would become a ranking signal by the back door. Per-user throughout (rule #47): one household member parking a candidate leaves everyone else's deck untouched. candidate_name is denormalized because suggestions are out-of-library by definition — there is no artists row to resolve a display name from, and the un-snooze list has to show something. That list is why GET /discover/snoozes exists at all: a parked candidate is by definition absent from the deck, so without it the DELETE would be unreachable. Also fixes a hole in the codegen check from #2380: `git diff` ignores untracked paths, so a brand-new generated file would have passed it silently. `git add -N` first. This commit is the first to add one. Endpoints: POST /api/discover/suggestions/{mbid}/snooze (body: name, days) DELETE /api/discover/suggestions/{mbid}/snooze GET /api/discover/snoozes UI lands in slice 4 (#2375) before any of this merges — rule #27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
689 lines
23 KiB
Go
689 lines
23 KiB
Go
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))
|
|
}
|
|
}
|
|
|
|
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
|
|
|
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
|
userID, artistID, weight,
|
|
); err != nil {
|
|
t.Fatalf("set taste weight: %v", err)
|
|
}
|
|
}
|
|
|
|
func insertSkippedPlayEvent(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, 'skip-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, true)`,
|
|
userID, trackID, sessionID, startedAt,
|
|
); err != nil {
|
|
t.Fatalf("insert skipped play_event: %v", err)
|
|
}
|
|
}
|
|
|
|
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
|
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
|
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
seed := seedArtist(t, pool, "Profile Seed", "")
|
|
|
|
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
|
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
|
|
|
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 (taste weight alone should seed)", len(out))
|
|
}
|
|
if out[0].MBID != "out-mbid" {
|
|
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
|
}
|
|
if out[0].Score <= 0 {
|
|
t.Errorf("score = %v, want > 0", out[0].Score)
|
|
}
|
|
}
|
|
|
|
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
|
// the user played but that the taste engine did not keep does NOT seed. That is
|
|
// the point of the rewrite: the taste engine decides what counts as affinity.
|
|
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
kept := seedArtist(t, pool, "Kept By Taste", "")
|
|
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
|
|
|
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
|
|
|
// `dropped` has real completed plays but no profile row.
|
|
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
|
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
|
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
|
|
|
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
|
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
|
|
|
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 (tier 2 must not run alongside tier 1)", len(out))
|
|
}
|
|
if out[0].MBID != "kept-cand" {
|
|
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
|
}
|
|
}
|
|
|
|
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
|
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
|
// tier 2 and the assertion would pass for the wrong reason.
|
|
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
positive := seedArtist(t, pool, "Still Liked", "")
|
|
abandoned := seedArtist(t, pool, "Abandoned", "")
|
|
|
|
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
|
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
|
|
|
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
|
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
|
|
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
|
if err != nil {
|
|
t.Fatalf("SuggestArtists: %v", err)
|
|
}
|
|
for _, s := range out {
|
|
if s.MBID == "neg-cand" {
|
|
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
|
}
|
|
}
|
|
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
|
t.Errorf("out = %+v, want only pos-cand", out)
|
|
}
|
|
}
|
|
|
|
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
|
// skipping an artist repeatedly increased its signal and pushed more of its
|
|
// neighbours at the user (#2367 mechanism 3).
|
|
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
skipped := seedArtist(t, pool, "Only Skipped", "")
|
|
|
|
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
|
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
|
for i := 0; i < 5; i++ {
|
|
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
|
}
|
|
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 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 (skips are not affinity): %+v", len(out), out)
|
|
}
|
|
}
|
|
|
|
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
|
|
//
|
|
// The defining property under test is that a snooze EXPIRES. A permanent
|
|
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
|
|
// distinguishes the two, and it is the reason this shape was approved over an
|
|
// exclusion UI (rule #101).
|
|
|
|
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
|
|
// test can place it in the past without depending on the duration arithmetic
|
|
// in SnoozeSuggestion.
|
|
func snoozeUntil(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string, until time.Time) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(context.Background(),
|
|
`INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (user_id, candidate_mbid) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until`,
|
|
userID, mbid, name, until,
|
|
); err != nil {
|
|
t.Fatalf("snoozeUntil: %v", err)
|
|
}
|
|
}
|
|
|
|
// seedOneCandidate wires the minimum that puts exactly one candidate in the
|
|
// deck: a liked seed artist with one unmatched neighbour.
|
|
func seedOneCandidate(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string) {
|
|
t.Helper()
|
|
seed := seedArtist(t, pool, "Seed", "")
|
|
likeArtist(t, pool, userID, seed.ID)
|
|
seedUnmatched(t, pool, seed.ID, mbid, name, 0.9)
|
|
}
|
|
|
|
func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
seedOneCandidate(t, pool, user.ID, "snoozed-mbid", "Parked Artist")
|
|
|
|
if err := dbq.New(pool).SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
|
UserID: user.ID,
|
|
CandidateMbid: "snoozed-mbid",
|
|
CandidateName: "Parked Artist",
|
|
Column4: 90,
|
|
}); err != nil {
|
|
t.Fatalf("SnoozeSuggestion: %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 (active snooze should hide the candidate): %+v", len(out), out)
|
|
}
|
|
}
|
|
|
|
// The whole point of a snooze over a dismissal: it comes back on its own.
|
|
func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
|
|
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
|
|
|
|
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 (an expired snooze must not hide anything): %+v", len(out), out)
|
|
}
|
|
if out[0].MBID != "expired-mbid" {
|
|
t.Errorf("mbid = %q, want expired-mbid", out[0].MBID)
|
|
}
|
|
}
|
|
|
|
// Rule #47: one household member parking a suggestion must not remove it
|
|
// from anyone else's deck.
|
|
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
|
|
pool := newPool(t)
|
|
alice := seedUser(t, pool, "alice")
|
|
bob := seedUser(t, pool, "bob")
|
|
|
|
seed := seedArtist(t, pool, "Seed", "")
|
|
likeArtist(t, pool, alice.ID, seed.ID)
|
|
likeArtist(t, pool, bob.ID, seed.ID)
|
|
seedUnmatched(t, pool, seed.ID, "shared-mbid", "Shared Candidate", 0.9)
|
|
|
|
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
|
|
|
|
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12)
|
|
if err != nil {
|
|
t.Fatalf("SuggestArtists(alice): %v", err)
|
|
}
|
|
if len(aliceOut) != 0 {
|
|
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
|
|
}
|
|
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12)
|
|
if err != nil {
|
|
t.Fatalf("SuggestArtists(bob): %v", err)
|
|
}
|
|
if len(bobOut) != 1 {
|
|
t.Errorf("bob len = %d, want 1 (alice's snooze is not his)", len(bobOut))
|
|
}
|
|
}
|
|
|
|
func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
seedOneCandidate(t, pool, user.ID, "undo-mbid", "Undo Artist")
|
|
snoozeUntil(t, pool, user.ID, "undo-mbid", "Undo Artist", time.Now().Add(90*24*time.Hour))
|
|
|
|
q := dbq.New(pool)
|
|
rows, err := q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UnsnoozeSuggestion: %v", err)
|
|
}
|
|
if rows != 1 {
|
|
t.Errorf("rows = %d, want 1", rows)
|
|
}
|
|
// A second delete affects nothing — the handler turns this into a 404
|
|
// rather than reporting success for a no-op.
|
|
rows, err = q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UnsnoozeSuggestion (repeat): %v", err)
|
|
}
|
|
if rows != 0 {
|
|
t.Errorf("repeat rows = %d, want 0", rows)
|
|
}
|
|
|
|
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 (unsnooze restores the candidate)", len(out))
|
|
}
|
|
}
|
|
|
|
// Re-snoozing must extend, not conflict on the PK.
|
|
func TestSnoozeSuggestion_UpsertExtends(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
q := dbq.New(pool)
|
|
|
|
snoozeUntil(t, pool, user.ID, "extend-mbid", "Old Name", time.Now().Add(time.Hour))
|
|
if err := q.SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
|
UserID: user.ID,
|
|
CandidateMbid: "extend-mbid",
|
|
CandidateName: "New Name",
|
|
Column4: 90,
|
|
}); err != nil {
|
|
t.Fatalf("SnoozeSuggestion (re-snooze): %v", err)
|
|
}
|
|
|
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("len = %d, want 1 (upsert, not a second row)", len(rows))
|
|
}
|
|
if rows[0].CandidateName != "New Name" {
|
|
t.Errorf("name = %q, want New Name (upsert refreshes it)", rows[0].CandidateName)
|
|
}
|
|
if got := time.Until(rows[0].SnoozedUntil.Time); got < 80*24*time.Hour {
|
|
t.Errorf("snoozed_until is %v away, want ~90d (re-snooze should extend)", got)
|
|
}
|
|
}
|
|
|
|
// ListActiveSuggestionSnoozes filters expired rows itself rather than
|
|
// trusting the hourly gc sweep to have run.
|
|
func TestListActiveSuggestionSnoozes_ExcludesExpired(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
|
|
|
rows, err := dbq.New(pool).ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("len = %d, want 1 (expired row must not be listed)", len(rows))
|
|
}
|
|
if rows[0].CandidateMbid != "live-mbid" {
|
|
t.Errorf("mbid = %q, want live-mbid", rows[0].CandidateMbid)
|
|
}
|
|
}
|
|
|
|
func TestGcDeleteExpiredSuggestionSnoozes_KeepsActive(t *testing.T) {
|
|
pool := newPool(t)
|
|
user := seedUser(t, pool, "alice")
|
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
|
|
|
q := dbq.New(pool)
|
|
deleted, err := q.GcDeleteExpiredSuggestionSnoozes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GcDeleteExpiredSuggestionSnoozes: %v", err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted = %d, want 1 (only the expired row)", deleted)
|
|
}
|
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Errorf("len = %d, want 1 (active snooze survives the sweep)", len(rows))
|
|
}
|
|
}
|