feat(recommendation): add LoadCandidates DB-backed loader
Wraps the LoadRadioCandidates sqlc query, projects rows to []Candidate. Live-DB tests verify seed exclusion, recently-played exclusion, stat-join correctness (likes + plays + skips + last_played_at), and cross-user isolation.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func LoadCandidates(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, seedID pgtype.UUID,
|
||||
recentlyPlayedHours int,
|
||||
) ([]Candidate, error) {
|
||||
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
||||
UserID: userID,
|
||||
ID: seedID,
|
||||
Column3: float64(recentlyPlayedHours),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
if r.LastPlayedAt.Valid {
|
||||
t := r.LastPlayedAt.Time
|
||||
lpt = &t
|
||||
}
|
||||
out = append(out, Candidate{
|
||||
Track: r.Track,
|
||||
Inputs: ScoringInputs{
|
||||
IsGeneralLiked: r.IsLiked,
|
||||
LastPlayedAt: lpt,
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
func testPool(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)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
pool *pgxpool.Pool
|
||||
q *dbq.Queries
|
||||
user pgtype.UUID
|
||||
tracks []dbq.Track // tracks[0] is the canonical seed
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, n int) fixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
q := dbq.New(pool)
|
||||
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
|
||||
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID})
|
||||
tracks := make([]dbq.Track, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: string(rune('A' + i)),
|
||||
AlbumID: al.ID,
|
||||
ArtistID: a.ID,
|
||||
FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac",
|
||||
DurationMs: 120_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("track[%d]: %v", i, err)
|
||||
}
|
||||
tracks = append(tracks, tr)
|
||||
}
|
||||
return fixture{pool: pool, q: q, user: u.ID, tracks: tracks}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
||||
f := newFixture(t, 5)
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len = %d, want 4 (5 minus seed)", len(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == f.tracks[0].ID {
|
||||
t.Errorf("seed appeared in candidate set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
|
||||
f := newFixture(t, 3)
|
||||
// Seed = tracks[0]. Other tracks are tracks[1], tracks[2].
|
||||
// Insert a play_session and a play_event for tracks[1] 30 minutes ago.
|
||||
now := time.Now().UTC()
|
||||
thirtyMinAgo := now.Add(-30 * time.Minute)
|
||||
sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
|
||||
UserID: f.user,
|
||||
StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
|
||||
ClientID: nil,
|
||||
})
|
||||
_, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user,
|
||||
TrackID: f.tracks[1].ID,
|
||||
SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
|
||||
ClientID: nil,
|
||||
})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
// Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played).
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got))
|
||||
}
|
||||
if got[0].Track.ID != f.tracks[2].ID {
|
||||
t.Errorf("expected tracks[2], got %v", got[0].Track.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_StatJoin(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
// Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago.
|
||||
if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil {
|
||||
t.Fatalf("like: %v", err)
|
||||
}
|
||||
twoH := time.Now().UTC().Add(-2 * time.Hour)
|
||||
sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
|
||||
UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
|
||||
})
|
||||
// First play: completed (was_skipped=false).
|
||||
pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
|
||||
})
|
||||
dur := int32(120_000)
|
||||
ratio := 1.0
|
||||
_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
|
||||
ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true},
|
||||
DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false,
|
||||
})
|
||||
// Second play: skipped (was_skipped=true).
|
||||
pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true},
|
||||
})
|
||||
dur2 := int32(5_000)
|
||||
ratio2 := 0.04
|
||||
_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
|
||||
ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true},
|
||||
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
|
||||
})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
if !c.Inputs.IsGeneralLiked {
|
||||
t.Errorf("IsGeneralLiked = false, want true")
|
||||
}
|
||||
if c.Inputs.PlayCount != 2 {
|
||||
t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount)
|
||||
}
|
||||
if c.Inputs.SkipCount != 1 {
|
||||
t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount)
|
||||
}
|
||||
if c.Inputs.LastPlayedAt == nil {
|
||||
t.Errorf("LastPlayedAt = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Inputs.LastPlayedAt != nil {
|
||||
t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt)
|
||||
}
|
||||
if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 {
|
||||
t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||||
})
|
||||
// Alice likes tracks[1]; Bob shouldn't see it.
|
||||
_ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked {
|
||||
t.Errorf("bob sees alice's like on track %v", c.Track.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trackTitles(cs []Candidate) []string {
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
out = append(out, c.Track.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user