package api import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // truncateLibrary clears the music tables between tests. Sessions/users are // cleared by testHandlers. CASCADE covers tracks→albums→artists FK chain. func truncateLibrary(t *testing.T, pool *pgxpool.Pool) { t.Helper() if _, err := pool.Exec(context.Background(), "TRUNCATE tracks, albums, artists, scrobble_queue RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } } // seedArtist inserts an artist with deterministic sort_name = name. func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist { t.Helper() a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: name, SortName: name, Mbid: nil, }) if err != nil { t.Fatalf("UpsertArtist(%s): %v", name, err) } return a } // seedAlbum inserts an album belonging to artistID. releaseYear=0 leaves // release_date unset. func seedAlbum(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string, releaseYear int) dbq.Album { t.Helper() var rel pgtype.Date if releaseYear > 0 { rel = pgtype.Date{Time: time.Date(releaseYear, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true} } a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: artistID, ReleaseDate: rel, Mbid: nil, CoverArtPath: nil, }) if err != nil { t.Fatalf("UpsertAlbum(%s): %v", title, err) } return a } // seedTrack inserts a track. trackNumber<=0 leaves it NULL. filePath is // synthesized from title to stay unique across seeds. func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32) dbq.Track { t.Helper() var tn *int32 if trackNumber > 0 { v := int32(trackNumber) tn = &v } tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: albumID, ArtistID: artistID, TrackNumber: tn, DiscNumber: nil, DurationMs: durationMs, FilePath: "/seed/" + title + ".flac", FileSize: 1024, FileFormat: "flac", Bitrate: nil, Mbid: nil, Genre: nil, }) if err != nil { t.Fatalf("UpsertTrack(%s): %v", title, err) } return tr } // seedTrackWithGenre is seedTrack but lets the caller set Genre. Used by // tests that exercise the contextual recommendation path, since session // vectors collect tags from tracks.genre. func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32, genre string) dbq.Track { t.Helper() var tn *int32 if trackNumber > 0 { v := int32(trackNumber) tn = &v } tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: albumID, ArtistID: artistID, TrackNumber: tn, DiscNumber: nil, DurationMs: durationMs, FilePath: "/seed/" + title + ".flac", FileSize: 1024, FileFormat: "flac", Bitrate: nil, Mbid: nil, Genre: &genre, }) if err != nil { t.Fatalf("UpsertTrack(%s): %v", title, err) } return tr } // insertOpenSessionWithVector creates a play_session with ended_at NULL and // inserts a play_event in it whose session_vector_at_play is the given JSON. // Used to simulate "user is mid-listen" for the radio handler's current-vector // lookup. The play_event references a placeholder track inserted on the fly // so the FK is valid; the placeholder is not used for radio scoring. func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { t.Helper() q := dbq.New(pool) al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, }) if err != nil { t.Fatalf("placeholder album: %v", err) } ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, FilePath: "/seed/placeholder.flac", DurationMs: 100_000, FileSize: 1024, FileFormat: "flac", }) if err != nil { t.Fatalf("placeholder track: %v", err) } var sessionID pgtype.UUID if err := pool.QueryRow(context.Background(), `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, userID).Scan(&sessionID); err != nil { t.Fatalf("insert session: %v", err) } if _, err := pool.Exec(context.Background(), `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, userID, ph.ID, sessionID, vectorJSON); err != nil { t.Fatalf("insert play_event: %v", err) } } // seedTrackWithFile creates a fresh temp directory, writes fileBody to //