feat(server/m7-352): kind filter on GET /api/playlists + lazy fallback

Adds ?kind= filter (user|system|all, default user) to GET /api/playlists
so system mixes don't leak into slice-1 SPA views. Lazy background build
fires when kind=system|all and the caller's run row is stale (>24h).
Propagates Kind/SystemVariant/SeedArtistID through PlaylistRow and
playlistRowView wire types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 09:24:27 -04:00
parent 391f5f16a1
commit a90ff11f0f
3 changed files with 212 additions and 11 deletions
+115
View File
@@ -454,3 +454,118 @@ func TestPlaylists_CoverNoneReturns404(t *testing.T) {
t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
}
// TestListPlaylists_KindFilterDefaultsToUser verifies that an absent ?kind=
// parameter defaults to "user" and excludes system-kind playlists.
func TestListPlaylists_KindFilterDefaultsToUser(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistdefault", "pw", false)
// Create one user-kind playlist via the service.
userPL, err := h.playlists.Create(context.Background(), u.ID, "My PL", "", false)
if err != nil {
t.Fatalf("create user playlist: %v", err)
}
// Insert a system-kind playlist directly via SQL.
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 1 {
t.Fatalf("default should return 1 user-kind playlist; got %d", len(resp.Owned))
}
if resp.Owned[0].Kind != "user" {
t.Errorf("expected kind=user; got %q", resp.Owned[0].Kind)
}
if resp.Owned[0].ID != uuidToString(userPL.ID) {
t.Errorf("returned wrong playlist id")
}
}
// TestListPlaylists_KindSystem verifies ?kind=system returns only
// system-generated playlists and excludes user-created ones.
func TestListPlaylists_KindSystem(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistsys", "pw", false)
if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
t.Fatalf("create user playlist: %v", err)
}
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=system", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 1 {
t.Fatalf("kind=system should return 1 system playlist; got %d", len(resp.Owned))
}
if resp.Owned[0].Kind != "system" {
t.Errorf("expected kind=system; got %q", resp.Owned[0].Kind)
}
}
// TestListPlaylists_KindAll verifies ?kind=all returns all playlists
// regardless of kind.
func TestListPlaylists_KindAll(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistall", "pw", false)
if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
t.Fatalf("create user playlist: %v", err)
}
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=all", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 2 {
t.Fatalf("kind=all should return both playlists; got %d", len(resp.Owned))
}
}
// TestListPlaylists_BadKindReturns400 verifies an unrecognised kind value
// is rejected with 400 bad_kind.
func TestListPlaylists_BadKindReturns400(t *testing.T) {
h, pool := testHandlers(t)
u := seedUser(t, pool, "klistbad", "pw", false)
_ = pool
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=garbage", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400; got %d, body=%s", w.Code, w.Body.String())
}
}