package subsonic import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "slices" "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" ) // playHistoryFixture is a library of single-track albums and a listener // whose plays rank them differently by recency and by count. type playHistoryFixture struct { pool *pgxpool.Pool user dbq.User albums map[string]dbq.Album // keyed by title } // seedPlayHistory builds the fixture: // // A: one completed play 1h ago // B: three completed plays 5h ago // D: five skipped plays 30m ago (recent counts it, frequent does not) // C: played 10m ago, but its file is gone (in neither list) // E: played many times by another user (in neither list) func seedPlayHistory(t *testing.T) playHistoryFixture { 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) ctx := context.Background() q := dbq.New(pool) user, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "listener", PasswordHash: "x", ApiTokenHash: "x", }) if err != nil { t.Fatalf("create user: %v", err) } other, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "other", PasswordHash: "x", ApiTokenHash: "y", }) if err != nil { t.Fatalf("create other user: %v", err) } artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Artist", SortName: "Artist"}) if err != nil { t.Fatalf("artist: %v", err) } f := playHistoryFixture{pool: pool, user: user, albums: map[string]dbq.Album{}} tracks := map[string]dbq.Track{} for _, title := range []string{"A", "B", "C", "D", "E"} { al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID}) if err != nil { t.Fatalf("album %s: %v", title, err) } tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: title, AlbumID: al.ID, ArtistID: artist.ID, FilePath: "/tmp/albumlist-" + title + ".flac", DurationMs: 100_000, }) if err != nil { t.Fatalf("track %s: %v", title, err) } f.albums[title] = al tracks[title] = tr } if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE id = $1`, tracks["C"].ID); err != nil { t.Fatalf("mark missing: %v", err) } now := time.Now() play := func(u dbq.User, title string, ago time.Duration, skipped bool, times int) { t.Helper() at := now.Add(-ago) 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, 'albumlist-test') RETURNING id`, u.ID, at).Scan(&sessionID); err != nil { t.Fatalf("insert play_session: %v", err) } for i := 0; i < times; i++ { if _, err := pool.Exec(ctx, `INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped) VALUES ($1, $2, $3, $4, $5)`, u.ID, tracks[title].ID, sessionID, at, skipped); err != nil { t.Fatalf("insert play_event: %v", err) } } } play(user, "A", time.Hour, false, 1) play(user, "B", 5*time.Hour, false, 3) play(user, "D", 30*time.Minute, true, 5) play(user, "C", 10*time.Minute, false, 4) play(other, "E", time.Minute, false, 9) return f } // albumListTitles calls getAlbumList2 as the fixture's user and returns the // album titles in the order served. func (f playHistoryFixture) albumListTitles(t *testing.T, query string) []string { t.Helper() b := &browseHandlers{pool: f.pool} req := httptest.NewRequest(http.MethodGet, "/rest/getAlbumList2?f=json&"+query, nil) ctx := context.WithValue(req.Context(), userCtxKey, f.user) w := httptest.NewRecorder() b.getAlbumList2(w, req.WithContext(ctx)) if w.Code != http.StatusOK { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } var raw struct { Response struct { Status string `json:"status"` AlbumList2 AlbumList2Container `json:"albumList2"` } `json:"subsonic-response"` } if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("decode: %v body=%s", err, w.Body.String()) } if raw.Response.Status != "ok" { t.Fatalf("status %q body=%s", raw.Response.Status, w.Body.String()) } titles := make([]string, len(raw.Response.AlbumList2.Albums)) for i, a := range raw.Response.AlbumList2.Albums { titles[i] = a.Name } return titles } func TestGetAlbumList2_RecentOrdersByLatestPlay(t *testing.T) { f := seedPlayHistory(t) got := f.albumListTitles(t, "type=recent") if want := []string{"D", "A", "B"}; !slices.Equal(got, want) { t.Errorf("recent = %v, want %v", got, want) } } func TestGetAlbumList2_FrequentOrdersByCompletedPlays(t *testing.T) { f := seedPlayHistory(t) got := f.albumListTitles(t, "type=frequent") if want := []string{"B", "A"}; !slices.Equal(got, want) { t.Errorf("frequent = %v, want %v", got, want) } } func TestGetAlbumList2_RecentPages(t *testing.T) { f := seedPlayHistory(t) got := f.albumListTitles(t, "type=recent&size=1&offset=1") if want := []string{"A"}; !slices.Equal(got, want) { t.Errorf("recent page = %v, want %v", got, want) } }