feat(discover): time-boxed suggestion snooze, server side — #2374
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>
This commit is contained in:
@@ -472,3 +472,217 @@ func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user