From fc608fb36eedb53082a9b7cd91c50ce1671c69fb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 05:52:17 -0400 Subject: [PATCH] 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 --- internal/api/api.go | 1 + internal/api/me_history.go | 56 ++++++++++ internal/api/me_history_test.go | 181 ++++++++++++++++++++++++++++++++ internal/api/types.go | 20 +++- 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 internal/api/me_history.go create mode 100644 internal/api/me_history_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 23215d03..0c3a274b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/me_history.go b/internal/api/me_history.go new file mode 100644 index 00000000..e57e9fde --- /dev/null +++ b/internal/api/me_history.go @@ -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) +} diff --git a/internal/api/me_history_test.go b/internal/api/me_history_test.go new file mode 100644 index 00000000..58fdb3d9 --- /dev/null +++ b/internal/api/me_history_test.go @@ -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) + } + }) +} diff --git a/internal/api/types.go b/internal/api/types.go index 3e4e4f4a..8fa0ec5c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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"` +}