package coplay 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" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) func testPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping 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 discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID { t.Helper() u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false, }) if err != nil { t.Fatalf("seed user %s: %v", name, err) } return u.ID } // seedArtistTrack creates an artist + album + one track, returning both ids. func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) { t.Helper() ctx := context.Background() a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name}) if err != nil { t.Fatalf("seed artist: %v", err) } al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID}) if err != nil { t.Fatalf("seed album: %v", err) } tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: name, AlbumID: al.ID, ArtistID: a.ID, DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3", FileSize: 1, FileFormat: "mp3", }) if err != nil { t.Fatalf("seed track: %v", err) } return a.ID, tr.ID } func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) { t.Helper() ctx := context.Background() at := time.Now() 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, 'coplay-test') RETURNING id`, userID, at).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, at); err != nil { t.Fatalf("insert play_event: %v", err) } } func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int { t.Helper() var n int if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil { t.Fatalf("count edges: %v", err) } return n } // TestCoplayWorker_BuildsEdges: two users who both play two artists produce a // perfect-overlap co-play edge (Jaccard 1.0) in each direction. func TestCoplayWorker_BuildsEdges(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) u1 := seedUser(t, q, "cop1") u2 := seedUser(t, q, "cop2") aA, tA := seedArtistTrack(t, q, "ArtA") aB, tB := seedArtistTrack(t, q, "ArtB") for _, u := range []pgtype.UUID{u1, u2} { play(t, pool, u, tA) play(t, pool, u, tB) } if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil { t.Fatalf("rebuild: %v", err) } var score float64 if err := pool.QueryRow(ctx, `SELECT score FROM artist_similarity WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`, aA, aB).Scan(&score); err != nil { t.Fatalf("expected co-play edge ArtA→ArtB: %v", err) } // Jaccard of perfectly-overlapping player sets (both users play both): // coplayers=2, players each=2 → 2/(2+2-2) = 1.0. if score != 1.0 { t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score) } if got := coplayEdgeCount(t, pool); got != 2 { t.Errorf("edge count = %d, want 2 (A→B and B→A)", got) } } // TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no // edges — the >= 2 distinct-co-player gate keeps single-user servers empty. func TestCoplayWorker_SingleUserNoEdges(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) u := seedUser(t, q, "solo") _, tA := seedArtistTrack(t, q, "SoloA") _, tB := seedArtistTrack(t, q, "SoloB") play(t, pool, u, tA) play(t, pool, u, tB) if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil { t.Fatalf("rebuild: %v", err) } if got := coplayEdgeCount(t, pool); got != 0 { t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got) } }