package web import ( "context" "net/http" "net/http/httptest" "strings" "testing" "naviwatcher/internal/config" "naviwatcher/internal/database" ) // seedArtist inserts an artist_settings row and returns its ID. func seedArtist(t *testing.T, db *database.DB, id, name, mbid string, monitored bool) { t.Helper() if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: id, Name: name, MBID: mbid, Monitored: monitored, }); err != nil { t.Fatalf("seed artist %s: %v", id, err) } } // seedLocalAlbum inserts a local_albums row. func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) { t.Helper() if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ID: id, ArtistID: artistID, Title: title}); err != nil { t.Fatalf("seed local album %s: %v", id, err) } } // seedExternalRelease inserts an external_releases row (not ignored). func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string) { t.Helper() if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: rgid, ArtistID: artistID, Title: title, }); err != nil { t.Fatalf("seed external release %s: %v", rgid, err) } } func newServer(t *testing.T, user, pass string) (*Server, *database.DB) { t.Helper() db, err := database.New(":memory:") if err != nil { t.Fatalf("open db: %v", err) } cfg := &config.ServerConfig{Host: "0.0.0.0", Port: 8080, Username: user, Password: pass} s := NewServer(cfg, db, "http://ui.example") return s, db } func TestDashboard_Authenticated200(t *testing.T) { s, db := newServer(t, "admin", "secret") seedArtist(t, db, "a1", "Radiohead", "mbid-1", true) seedLocalAlbum(t, db, "l1", "a1", "OK Computer") // One missing release (no local album matches "Kid A"). seedExternalRelease(t, db, "r1", "a1", "Kid A") req := httptest.NewRequest(http.MethodGet, "/", 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, "Radiohead") { t.Errorf("expected artist name in body, got:\n%s", body) } if !strings.Contains(body, "1") { t.Errorf("expected missing count rendered, got:\n%s", body) } if !strings.Contains(body, "mbid-1") { t.Errorf("expected MBID rendered, got:\n%s", body) } } func TestDashboard_Unauthenticated401(t *testing.T) { s, _ := newServer(t, "admin", "secret") req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") { t.Errorf("expected Basic auth challenge, got headers: %v", rec.Header()) } } func TestDashboard_WrongPassword401(t *testing.T) { s, _ := newServer(t, "admin", "secret") req := httptest.NewRequest(http.MethodGet, "/", nil) req.SetBasicAuth("admin", "wrong") rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for wrong password, got %d", rec.Code) } } func TestDashboard_NoAuthWhenDisabled(t *testing.T) { // When username or password is empty, auth is bypassed. s, db := newServer(t, "", "") seedArtist(t, db, "a1", "Boards of Canada", "", true) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 when auth disabled, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Boards of Canada") { t.Errorf("expected artist rendered, got:\n%s", rec.Body.String()) } } func TestDashboard_SkipsUnmonitored(t *testing.T) { s, db := newServer(t, "admin", "secret") seedArtist(t, db, "mon", "Monitored Artist", "", true) seedArtist(t, db, "unmon", "Unmonitored Artist", "", false) seedExternalRelease(t, db, "r1", "unmon", "Should not appear") seedExternalRelease(t, db, "r2", "mon", "Missing here") req := httptest.NewRequest(http.MethodGet, "/", 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, "Unmonitored Artist") { t.Errorf("unmonitored artist should not appear, got:\n%s", body) } if !strings.Contains(body, "Monitored Artist") { t.Errorf("monitored artist should appear, got:\n%s", body) } } func TestDashboard_EmptyState(t *testing.T) { s, _ := newServer(t, "admin", "secret") req := httptest.NewRequest(http.MethodGet, "/", 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 monitored artists") { t.Errorf("expected empty-state message, got:\n%s", rec.Body.String()) } } func TestDashboard_NotFoundForOtherPaths(t *testing.T) { s, _ := newServer(t, "admin", "secret") req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) req.SetBasicAuth("admin", "secret") rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { 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 }