package musicbrainz import ( "context" "errors" "net/http" "testing" "time" "naviwatcher/internal/database" ) // stubResolver is a configurable MBIDResolver for tests. type stubResolver struct { byName map[string]string // name -> mbid calls []string // names requested, in order err error // optional error to return for any resolve } func (s *stubResolver) ResolveArtistMBID(ctx context.Context, name string) (string, error) { s.calls = append(s.calls, name) if s.err != nil { return "", s.err } if mbid, ok := s.byName[name]; ok { return mbid, nil } return "", errors.New("no match") } // stubDiscography records per-artist discography syncs. type stubDiscography struct { synced []string // artistIDs err error } func (s *stubDiscography) SyncArtistDiscography( ctx context.Context, db *database.DB, artistID, artistMBID string, ttl time.Duration, ) ([]database.ExternalRelease, error) { if s.err != nil { return nil, s.err } s.synced = append(s.synced, artistID) return nil, nil } // stubAlbums records album-sync invocations. type stubAlbums struct { called int err error } func (s *stubAlbums) SyncAlbums(ctx context.Context, db *database.DB) error { s.called++ return s.err } func seedArtistRow(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: %v", err) } } func TestSyncAll_NewArtistGetsMBID(t *testing.T) { db := newTestDB(t) seedArtistRow(t, db, "ar1", "Radiohead", "", true) resolver := &stubResolver{byName: map[string]string{"Radiohead": "mbid-radiohead"}} disco := &stubDiscography{} albs := &stubAlbums{} err := SyncAll(context.Background(), db, resolver, disco, albs, 24*time.Hour) if err != nil { t.Fatalf("SyncAll() error = %v", err) } // Resolver must have been called for the new artist. if len(resolver.calls) != 1 || resolver.calls[0] != "Radiohead" { t.Fatalf("resolver calls = %v, want [Radiohead]", resolver.calls) } // MBID persisted on the row. got, gerr := database.GetArtistSettings(db, "ar1") if gerr != nil { t.Fatalf("GetArtistSettings() error = %v", gerr) } if got.MBID != "mbid-radiohead" { t.Errorf("persisted MBID = %q, want %q", got.MBID, "mbid-radiohead") } // Discography and albums synced. if len(disco.synced) != 1 || disco.synced[0] != "ar1" { t.Errorf("discography synced = %v, want [ar1]", disco.synced) } if albc := albs.called; albc != 1 { t.Errorf("albums sync called = %d, want 1", albc) } } func TestSyncAll_ExistingMBIDReused(t *testing.T) { db := newTestDB(t) seedArtistRow(t, db, "ar1", "Radiohead", "preset-mbid", true) resolver := &stubResolver{byName: map[string]string{"Radiohead": "resolved-mbid"}} disco := &stubDiscography{} albs := &stubAlbums{} if err := SyncAll(context.Background(), db, resolver, disco, albs, 24*time.Hour); err != nil { t.Fatalf("SyncAll() error = %v", err) } // Resolver must NOT be called when MBID already present. if len(resolver.calls) != 0 { t.Errorf("resolver calls = %v, want none (MBID reused)", resolver.calls) } got, _ := database.GetArtistSettings(db, "ar1") if got.MBID != "preset-mbid" { t.Errorf("MBID = %q, want preserved preset-mbid", got.MBID) } if len(disco.synced) != 1 { t.Errorf("discography synced = %v, want [ar1]", disco.synced) } } func TestSyncAll_UnmonitoredSkipped(t *testing.T) { db := newTestDB(t) seedArtistRow(t, db, "ar1", "Radiohead", "", false) // unmonitored resolver := &stubResolver{byName: map[string]string{"Radiohead": "mbid-x"}} disco := &stubDiscography{} albs := &stubAlbums{} if err := SyncAll(context.Background(), db, resolver, disco, albs, 24*time.Hour); err != nil { t.Fatalf("SyncAll() error = %v", err) } if len(resolver.calls) != 0 { t.Errorf("resolver calls = %v, want none (unmonitored skipped)", resolver.calls) } if len(disco.synced) != 0 { t.Errorf("discography synced = %v, want none", disco.synced) } // Album sync still runs (it internally skips unmonitored too), but no // discography work should have happened for the skipped artist. } func TestSyncAll_ResolutionErrorSkipsArtist(t *testing.T) { db := newTestDB(t) seedArtistRow(t, db, "ar1", "Unknown", "", true) resolver := &stubResolver{err: errors.New("mb down")} disco := &stubDiscography{} albs := &stubAlbums{} err := SyncAll(context.Background(), db, resolver, disco, albs, 24*time.Hour) if err == nil { t.Fatal("SyncAll() expected error when resolution fails") } if len(disco.synced) != 0 { t.Errorf("discography synced = %v, want none (resolution failed)", disco.synced) } // MBID must remain empty since persistence was skipped. got, _ := database.GetArtistSettings(db, "ar1") if got.MBID != "" { t.Errorf("MBID = %q, want empty after failed resolution", got.MBID) } } func TestSyncAll_ContextCancel(t *testing.T) { db := newTestDB(t) seedArtistRow(t, db, "ar1", "Radiohead", "", true) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := SyncAll(ctx, db, &stubResolver{}, &stubDiscography{}, &stubAlbums{}, 24*time.Hour); err == nil { t.Fatal("SyncAll() expected context error, got nil") } } // TestDiscographySyncer_AdapterForwards verifies the adapter produced by // NewDiscographySyncer forwards to the real SyncArtistDiscography so that the // App's wiring uses the actual MusicBrainz client. func TestDiscographySyncer_AdapterForwards(t *testing.T) { db := newTestDB(t) artistID := "nav-adapter" artistMBID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" seedArtistRow(t, db, artistID, "Adapter Artist", "", true) server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg1", "Adapter Album", "Album", "", artistMBID, "Adapter Artist", "2020-01-01"), 1, ) w.Write([]byte(resp)) }) defer server.Close() syncer := NewDiscographySyncer(newTestClient(server.URL)) releases, err := syncer.SyncArtistDiscography(context.Background(), db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("adapter SyncArtistDiscography() error = %v", err) } if len(releases) != 1 { t.Fatalf("adapter expected 1 release, got %d", len(releases)) } if releases[0].RGID != "rg1" { t.Errorf("adapter release RGID = %q, want rg1", releases[0].RGID) } }