feat: add Web UI artist detail, archive, and ignore actions
Implements Task 8: artist detail page (local albums + found-missing with ignore buttons), ignored-releases archive with restore, and POST handlers toggling ignore flags and ignore_singles. Adds ErrArtistNotFound sentinel so callers can distinguish missing artists, and wires routes via the enhanced ServeMux path wildcard.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -178,3 +179,287 @@ func TestDashboard_NotFoundForOtherPaths(t *testing.T) {
|
||||
t.Fatalf("expected 404 for non-root path, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistDetail_RendersLocalAndMissing(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "mbid-1", true)
|
||||
seedLocalAlbum(t, db, "l1", "a1", "OK Computer")
|
||||
seedLocalAlbum(t, db, "l2", "a1", "The Bends")
|
||||
// One missing release (no local album matches "Kid A").
|
||||
seedExternalRelease(t, db, "r1", "a1", "Kid A")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/artist/a1", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Radiohead", "OK Computer", "The Bends", "Kid A", "mbid-1"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected %q in artist page, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `name="rgid" value="r1"`) {
|
||||
t.Errorf("expected ignore form for r1, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistDetail_UnknownArtist404(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/artist/does-not-exist", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for unknown artist, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistDetail_BarePath404(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/artist/", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for bare /artist/, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistDetail_RequiresGet(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 for POST on detail, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_RendersIgnoredReleases(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
// An ignored external release.
|
||||
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
||||
RGID: "r-ignored",
|
||||
ArtistID: "a1",
|
||||
Title: "Ignored Album",
|
||||
IsIgnored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ignored release: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/archive", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Ignored Album") {
|
||||
t.Errorf("expected ignored release in archive, got:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `name="rgid" value="r-ignored"`) {
|
||||
t.Errorf("expected restore form for r-ignored, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_EmptyState(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/archive", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "No ignored releases") {
|
||||
t.Errorf("expected empty archive message, got:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_RequiresGet(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/archive", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 for POST on archive, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreAction_SetsFlagAndRemovesFromDashboard(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
seedExternalRelease(t, db, "r1", "a1", "Kid A")
|
||||
|
||||
// Before ignore: dashboard shows 1 missing.
|
||||
before := dashboardMissingCount(t, s, "Radiohead")
|
||||
if before != 1 {
|
||||
t.Fatalf("expected 1 missing before ignore, got %d", before)
|
||||
}
|
||||
|
||||
// POST ignore.
|
||||
form := strings.NewReader("rgid=r1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected 303 redirect, got %d", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/artist/a1" {
|
||||
t.Errorf("expected redirect to /artist/a1, got %q", loc)
|
||||
}
|
||||
|
||||
// Flag persisted.
|
||||
rel, err := database.GetExternalRelease(db, "r1")
|
||||
if err != nil {
|
||||
t.Fatalf("get release: %v", err)
|
||||
}
|
||||
if !rel.IsIgnored {
|
||||
t.Errorf("expected r1 to be ignored")
|
||||
}
|
||||
|
||||
// Dashboard missing count drops to 0.
|
||||
after := dashboardMissingCount(t, s, "Radiohead")
|
||||
if after != 0 {
|
||||
t.Fatalf("expected 0 missing after ignore, got %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreAction_RequiresAuth(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
seedExternalRelease(t, db, "r1", "a1", "Kid A")
|
||||
|
||||
form := strings.NewReader("rgid=r1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
||||
}
|
||||
// Flag must remain unset.
|
||||
rel, err := database.GetExternalRelease(db, "r1")
|
||||
if err != nil {
|
||||
t.Fatalf("get release: %v", err)
|
||||
}
|
||||
if rel.IsIgnored {
|
||||
t.Errorf("release must not be ignored without auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAction_ClearsFlag(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
||||
RGID: "r1",
|
||||
ArtistID: "a1",
|
||||
Title: "Kid A",
|
||||
IsIgnored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ignored release: %v", err)
|
||||
}
|
||||
|
||||
form := strings.NewReader("rgid=r1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1/restore", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected 303 redirect, got %d", rec.Code)
|
||||
}
|
||||
rel, err := database.GetExternalRelease(db, "r1")
|
||||
if err != nil {
|
||||
t.Fatalf("get release: %v", err)
|
||||
}
|
||||
if rel.IsIgnored {
|
||||
t.Errorf("expected r1 to be restored (not ignored)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreSingles_TogglesFlag(t *testing.T) {
|
||||
s, db := newServer(t, "admin", "secret")
|
||||
seedArtist(t, db, "a1", "Radiohead", "", true)
|
||||
|
||||
// Toggle on.
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected 303 on toggle, got %d", rec.Code)
|
||||
}
|
||||
settings, err := database.GetArtistSettings(db, "a1")
|
||||
if err != nil {
|
||||
t.Fatalf("get settings: %v", err)
|
||||
}
|
||||
if !settings.IgnoreSingles {
|
||||
t.Errorf("expected ignore_singles = true after first toggle")
|
||||
}
|
||||
|
||||
// Toggle off.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil)
|
||||
req2.SetBasicAuth("admin", "secret")
|
||||
rec2 := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec2, req2)
|
||||
settings, err = database.GetArtistSettings(db, "a1")
|
||||
if err != nil {
|
||||
t.Fatalf("get settings: %v", err)
|
||||
}
|
||||
if settings.IgnoreSingles {
|
||||
t.Errorf("expected ignore_singles = false after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreSingles_RequiresAuth(t *testing.T) {
|
||||
s, _ := newServer(t, "admin", "secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without auth, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardMissingCount returns the missing-release count for the artist with
|
||||
// the given name from the dashboard view model (0 if the artist is absent).
|
||||
func dashboardMissingCount(t *testing.T, s *Server, artistName string) int {
|
||||
t.Helper()
|
||||
data, err := s.buildDashboardData(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("build dashboard data: %v", err)
|
||||
}
|
||||
for _, a := range data.Artists {
|
||||
if a.Name == artistName {
|
||||
return a.MissingCount
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user