feat(server/m7-365): GET /api/me/history handler + integration test
Implements the listening-history endpoint for M7 #365: returns a user's play events newest-first, excluding skipped events and quarantined tracks, with offset/limit pagination and a has_more heuristic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// handleGetMyHistory implements GET /api/me/history?limit=&offset=.
|
||||
// Returns the authenticated user's listening history (newest first),
|
||||
// excluding skipped events and quarantined tracks. Pagination is
|
||||
// offset/limit; has_more = (len(rows) == limit) is a cheap heuristic
|
||||
// that errs on the "true" side at the exact-end boundary.
|
||||
func (h *handlers) handleGetMyHistory(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
limit, offset, perr := parsePaging(r.URL.Query())
|
||||
if perr != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_paging", "invalid limit or offset")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbq.New(h.pool).ListUserHistory(r.Context(), dbq.ListUserHistoryParams{
|
||||
UserID: user.ID,
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list user history", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "failed to load history")
|
||||
return
|
||||
}
|
||||
|
||||
events := make([]HistoryEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
events = append(events, HistoryEvent{
|
||||
ID: uuidToString(row.EventID),
|
||||
PlayedAt: row.StartedAt.Time,
|
||||
Track: trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName),
|
||||
})
|
||||
}
|
||||
|
||||
resp := HistoryResponse{
|
||||
Events: events,
|
||||
HasMore: len(rows) == limit,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// newHistoryRouter mounts ONLY /api/me/history, injecting the user directly
|
||||
// into context (bypassing RequireUser), mirroring the pattern used by peer
|
||||
// tests such as library_albums_test.go and quarantine_test.go.
|
||||
func newHistoryRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/history", h.handleGetMyHistory)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGetHistory(h *handlers, pool *pgxpool.Pool, user dbq.User, query string) *httptest.ResponseRecorder {
|
||||
url := "/api/me/history"
|
||||
if query != "" {
|
||||
url += "?" + query
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
newHistoryRouter(h).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID, sessionID pgtype.UUID, startedAt time.Time, skipped bool) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
userID, trackID, sessionID, startedAt, skipped,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seedPlayEvent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, startedAt time.Time) pgtype.UUID {
|
||||
t.Helper()
|
||||
var id pgtype.UUID
|
||||
err := pool.QueryRow(context.Background(),
|
||||
`INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||
VALUES ($1, $2, $2) RETURNING id`,
|
||||
userID, startedAt,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seedPlaySession: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
||||
userID, trackID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("quarantineTrack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory_FilteringAndPagination(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
|
||||
userA := seedUser(t, pool, "alice", "pw", false)
|
||||
userB := seedUser(t, pool, "bob", "pw", false)
|
||||
|
||||
artist := seedArtist(t, pool, "Artist 1")
|
||||
album := seedAlbum(t, pool, artist.ID, "Album 1", 2024)
|
||||
t1 := seedTrack(t, pool, album.ID, artist.ID, "Track 1", 1, 180000)
|
||||
t2 := seedTrack(t, pool, album.ID, artist.ID, "Track 2", 2, 180000)
|
||||
t3 := seedTrack(t, pool, album.ID, artist.ID, "Track 3", 3, 180000)
|
||||
t4 := seedTrack(t, pool, album.ID, artist.ID, "Track 4", 4, 180000)
|
||||
tSkipped := seedTrack(t, pool, album.ID, artist.ID, "Skipped Track", 5, 60000)
|
||||
tQuar := seedTrack(t, pool, album.ID, artist.ID, "Quarantined Track", 6, 120000)
|
||||
tB := seedTrack(t, pool, album.ID, artist.ID, "Bob Only Track", 7, 200000)
|
||||
|
||||
quarantineTrack(t, pool, userA.ID, tQuar.ID)
|
||||
|
||||
now := time.Now().UTC()
|
||||
sessA := seedPlaySession(t, pool, userA.ID, now.Add(-5*time.Hour))
|
||||
sessB := seedPlaySession(t, pool, userB.ID, now.Add(-1*time.Hour))
|
||||
|
||||
seedPlayEvent(t, pool, userA.ID, t1.ID, sessA, now.Add(-4*time.Hour), false)
|
||||
seedPlayEvent(t, pool, userA.ID, t2.ID, sessA, now.Add(-3*time.Hour), false)
|
||||
seedPlayEvent(t, pool, userA.ID, t3.ID, sessA, now.Add(-2*time.Hour), false)
|
||||
seedPlayEvent(t, pool, userA.ID, t4.ID, sessA, now.Add(-1*time.Hour), false)
|
||||
seedPlayEvent(t, pool, userA.ID, tSkipped.ID, sessA, now.Add(-30*time.Minute), true)
|
||||
seedPlayEvent(t, pool, userA.ID, tQuar.ID, sessA, now.Add(-15*time.Minute), false)
|
||||
seedPlayEvent(t, pool, userB.ID, tB.ID, sessB, now.Add(-10*time.Minute), false)
|
||||
|
||||
t.Run("filters skipped + quarantined, returns DESC", func(t *testing.T) {
|
||||
w := doGetHistory(h, pool, userA, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp HistoryResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 4 {
|
||||
t.Fatalf("len: got %d, want 4 (skipped + quarantined excluded); body=%s",
|
||||
len(resp.Events), w.Body.String())
|
||||
}
|
||||
// Newest first: t4, t3, t2, t1.
|
||||
want := []pgtype.UUID{t4.ID, t3.ID, t2.ID, t1.ID}
|
||||
for i, e := range resp.Events {
|
||||
if e.Track.ID != uuidToString(want[i]) {
|
||||
t.Errorf("event[%d].track.id: got %s, want %s",
|
||||
i, e.Track.ID, uuidToString(want[i]))
|
||||
}
|
||||
}
|
||||
if resp.HasMore {
|
||||
t.Errorf("has_more: got true, want false (4 events < limit 50)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pagination via offset/limit", func(t *testing.T) {
|
||||
w := doGetHistory(h, pool, userA, "offset=2&limit=2")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d", w.Code)
|
||||
}
|
||||
var resp HistoryResponse
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Events) != 2 {
|
||||
t.Fatalf("len: got %d, want 2", len(resp.Events))
|
||||
}
|
||||
if resp.Events[0].Track.ID != uuidToString(t2.ID) || resp.Events[1].Track.ID != uuidToString(t1.ID) {
|
||||
t.Errorf("page-2 ids: got %s,%s; want %s,%s",
|
||||
resp.Events[0].Track.ID, resp.Events[1].Track.ID,
|
||||
uuidToString(t2.ID), uuidToString(t1.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("has_more=true when len(rows) == limit", func(t *testing.T) {
|
||||
w := doGetHistory(h, pool, userA, "limit=1")
|
||||
var resp HistoryResponse
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp.HasMore {
|
||||
t.Errorf("has_more: got false, want true (4 events, limit 1)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user B sees only their own play", func(t *testing.T) {
|
||||
w := doGetHistory(h, pool, userB, "")
|
||||
var resp HistoryResponse
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("len: got %d, want 1", len(resp.Events))
|
||||
}
|
||||
if resp.Events[0].Track.ID != uuidToString(tB.ID) {
|
||||
t.Errorf("user B's event: got track %s, want %s",
|
||||
resp.Events[0].Track.ID, uuidToString(tB.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
// No withUser wrapper — exercises the handler's own auth guard.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/history", nil)
|
||||
w := httptest.NewRecorder()
|
||||
newHistoryRouter(h).ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("unauth: got %d, want 401", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
+19
-1
@@ -1,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "github.com/jackc/pgx/v5/pgtype"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
|
||||
// web and Flutter both send the same payload.
|
||||
@@ -112,3 +116,17 @@ type HomePayload struct {
|
||||
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
||||
}
|
||||
|
||||
// HistoryEvent is one play in a user's listening history. Used by
|
||||
// /api/me/history; one event per row from play_events.
|
||||
type HistoryEvent struct {
|
||||
ID string `json:"id"` // play_events.id
|
||||
PlayedAt time.Time `json:"played_at"` // play_events.started_at
|
||||
Track TrackRef `json:"track"`
|
||||
}
|
||||
|
||||
// HistoryResponse is the body of GET /api/me/history.
|
||||
type HistoryResponse struct {
|
||||
Events []HistoryEvent `json:"events"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user