fix: address code review findings
- Honor ignore_singles/ignore_compilations at scanner read time so toggles take effect immediately on the dashboard, artist page, and digest instead of waiting for the MusicBrainz cache to expire and prune rows. - Run notifier notify synchronously in the scheduler loop to avoid overlapping read-send-mark runs double-sending the digest. - Show artist name (with ID fallback) on the archive page instead of raw IDs. - Select last_synced in GetAllArtistSettings for contract consistency. - Fix stale startPeriodicSync comment and remove redundant error var. - Remove dead ignored-branch from the artist template (never rendered). - Add tests: CSRF sameOrigin, ArtistCacheFresh, secondary_types round-trip, and scanner type-toggle filtering. - Update Specification.md schema/config to reflect mbid, last_synced, secondary_types, sync.interval, and server.public_url.
This commit is contained in:
@@ -89,7 +89,7 @@ func nullIfEmpty(s string) interface{} {
|
||||
// GetAllArtistSettings returns all rows from artist_settings.
|
||||
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored FROM artist_settings",
|
||||
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all artist settings: %w", err)
|
||||
@@ -100,10 +100,14 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
for rows.Next() {
|
||||
var s ArtistSettings
|
||||
var mbid sql.NullString
|
||||
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored); err != nil {
|
||||
var lastSynced sql.NullTime
|
||||
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil {
|
||||
return nil, fmt.Errorf("scan artist settings: %w", err)
|
||||
}
|
||||
s.MBID = mbid.String
|
||||
if lastSynced.Valid {
|
||||
s.LastSynced = lastSynced.Time
|
||||
}
|
||||
results = append(results, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction.
|
||||
@@ -378,3 +379,108 @@ func TestSetReleaseIgnored_NotFound(t *testing.T) {
|
||||
t.Error("expected error for nonexistent RGID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecondaryTypesRoundTrip verifies that the comma-joined secondary_types
|
||||
// column round-trips through save + read with the same slice, so the cache-hit
|
||||
// type filtering (ignore_singles / ignore_compilations) sees the same data as
|
||||
// the cache-miss path.
|
||||
func TestSecondaryTypesRoundTrip(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"empty", nil, nil},
|
||||
{"single", []string{"Compilation"}, []string{"Compilation"}},
|
||||
{"multiple", []string{"Compilation", "Live", "EP"}, []string{"Compilation", "Live", "EP"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &ExternalRelease{
|
||||
RGID: "rgid-" + c.name,
|
||||
ArtistID: "artist-1",
|
||||
Title: "Title " + c.name,
|
||||
Type: "Album",
|
||||
SecondaryTypes: c.in,
|
||||
}
|
||||
if err := SaveExternalRelease(db, r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
got, err := GetExternalRelease(db, r.RGID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
if len(got.SecondaryTypes) != len(c.want) {
|
||||
t.Fatalf("secondary types = %v, want %v", got.SecondaryTypes, c.want)
|
||||
}
|
||||
for i := range c.want {
|
||||
if got.SecondaryTypes[i] != c.want[i] {
|
||||
t.Errorf("secondary types[%d] = %q, want %q", i, got.SecondaryTypes[i], c.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtistCacheFresh covers the two-signal freshness logic: a fresh
|
||||
// external_releases row, a fresh last_synced marker (empty discography), a stale
|
||||
// state, and the ttl<=0 guard.
|
||||
func TestArtistCacheFresh(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
const ttl = 24 * time.Hour
|
||||
|
||||
// No rows at all => not fresh.
|
||||
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh {
|
||||
t.Fatalf("empty state: fresh=%v err=%v, want (false, nil)", fresh, err)
|
||||
}
|
||||
|
||||
// ttl <= 0 => never fresh.
|
||||
if fresh, err := ArtistCacheFresh(db, "artist-1", 0); err != nil || fresh {
|
||||
t.Fatalf("ttl=0: fresh=%v err=%v, want (false, nil)", fresh, err)
|
||||
}
|
||||
|
||||
// Fresh release row => fresh, even with an empty last_synced.
|
||||
if _, err := db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
|
||||
"rgid-1", "artist-1", "Album", FormatCachedAt(time.Now().UTC()),
|
||||
); err != nil {
|
||||
t.Fatalf("insert release: %v", err)
|
||||
}
|
||||
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh {
|
||||
t.Fatalf("fresh release: fresh=%v err=%v, want (true, nil)", fresh, err)
|
||||
}
|
||||
|
||||
// Remove the release row, set a fresh last_synced (empty discography still cached).
|
||||
if _, err := db.Conn().Exec("DELETE FROM external_releases WHERE artist_id = ?", "artist-1"); err != nil {
|
||||
t.Fatalf("delete releases: %v", err)
|
||||
}
|
||||
if _, err := db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC()), "artist-1"); err != nil {
|
||||
t.Fatalf("touch synced: %v", err)
|
||||
}
|
||||
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh {
|
||||
t.Fatalf("fresh last_synced: fresh=%v err=%v, want (true, nil)", fresh, err)
|
||||
}
|
||||
|
||||
// Stale both signals => not fresh.
|
||||
db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC().Add(-2*ttl)), "artist-1")
|
||||
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh {
|
||||
t.Fatalf("stale both: fresh=%v err=%v, want (false, nil)", fresh, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user