release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m35s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token_hash is unique, so the second seeded user needs its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
5.5 KiB
Go
179 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|