diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 081db58..7766068 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -269,6 +269,60 @@ func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handlers) RefreshArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + result, err := services.RefreshArtist(r.Context(), artistID, h.MetadataClient, h.DB) + if err != nil { + if _, ok := err.(*services.NotFoundError); ok { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) DeleteArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if !deleted { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "deleted": true, + "message": "artist and related data deleted", + }) +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/api/router.go b/internal/api/router.go index 6e1aecb..f9c204b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -43,6 +43,11 @@ func NewRouter(h *Handlers) *chi.Mux { r.Post("/sync", h.Sync) + r.Route("/artists", func(r chi.Router) { + r.Post("/{id}/refresh", h.RefreshArtist) + r.Delete("/{id}", h.DeleteArtist) + }) + r.Route("/library", func(r chi.Router) { r.Get("/artists", h.ListLibraryArtists) r.Get("/albums", h.ListLibraryAlbums) diff --git a/internal/database/db.go b/internal/database/db.go index 6b84798..1731900 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -250,3 +250,41 @@ func (db *DB) CountAlbums(ctx context.Context) (int64, error) { err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) return count, err } + +func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) { + var a ArtistMetadataRow + err := db.pool.QueryRow(ctx, ` + SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at + FROM artist_metadata + WHERE foreign_artist_id = $1 + `, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + return &a, nil +} + +func (db *DB) CountAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) (int64, error) { + var count int64 + err := db.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM albums WHERE artist_metadata_id = $1 + `, artistMetadataID).Scan(&count) + return count, err +} + +func (db *DB) TouchArtistUpdatedAt(ctx context.Context, artistMetadataID uuid.UUID) error { + _, err := db.pool.Exec(ctx, ` + UPDATE artist_metadata SET updated_at = NOW() WHERE id = $1 + `, artistMetadataID) + return err +} + +func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID string) (bool, error) { + result, err := db.pool.Exec(ctx, ` + DELETE FROM artist_metadata WHERE foreign_artist_id = $1 + `, foreignArtistID) + if err != nil { + return false, err + } + return result.RowsAffected() > 0, nil +} diff --git a/internal/services/download.go b/internal/services/download.go index 96423e3..6bbc0a6 100644 --- a/internal/services/download.go +++ b/internal/services/download.go @@ -289,6 +289,96 @@ func parseUUID(s string) ([16]byte, error) { return id, nil } +type RefreshResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + AlbumsUpdated int `json:"albums_updated"` + AlbumsAdded int `json:"albums_added"` +} + +func RefreshArtist( + ctx context.Context, + foreignArtistID string, + metadataClient *metadata.Client, + db *database.DB, +) (*RefreshResult, error) { + if db == nil { + return nil, &NotFoundError{Message: "database not available"} + } + + existingArtist, err := db.GetArtistByForeignID(ctx, foreignArtistID) + if err != nil { + return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID} + } + + artist, err := metadataClient.GetArtist(ctx, foreignArtistID) + if err != nil { + return nil, err + } + + dbArtist := &database.Artist{ + ID: artist.Id, + Name: artist.Name, + SortName: artist.SortName, + ArtistType: artist.ArtistType, + Description: artist.Description, + } + for _, g := range artist.Genres { + dbArtist.Genres = append(dbArtist.Genres, database.Genre{ID: g.Id, Name: g.Name}) + } + for _, e := range artist.ExternalIds { + dbArtist.ExternalIDs = append(dbArtist.ExternalIDs, database.ExternalID{ + Source: e.Source, + SourceID: e.SourceId, + URL: e.Url, + }) + } + + artistMetadataID, err := db.UpsertArtistMetadata(ctx, dbArtist) + if err != nil { + return nil, err + } + + existingAlbumCount, _ := db.CountAlbumsByArtist(ctx, existingArtist.ID) + + albumsResponse, err := metadataClient.GetArtistAlbums(ctx, foreignArtistID, 500, 0) + if err != nil { + return nil, err + } + + var albumsUpdated int + for _, album := range albumsResponse.Albums { + dbAlbum := &database.Album{ + ID: album.Id, + Title: album.Title, + AlbumType: album.AlbumType, + ReleaseDate: album.ReleaseDate, + } + for _, g := range album.Genres { + dbAlbum.Genres = append(dbAlbum.Genres, database.Genre{ID: g.Id, Name: g.Name}) + } + + if _, err := db.UpsertAlbum(ctx, dbAlbum, artistMetadataID); err != nil { + log.Warn().Err(err).Str("album", album.Title).Msg("failed to upsert album during refresh") + } else { + albumsUpdated++ + } + } + + newAlbumCount, _ := db.CountAlbumsByArtist(ctx, artistMetadataID) + albumsAdded := int(newAlbumCount - existingAlbumCount) + if albumsAdded < 0 { + albumsAdded = 0 + } + + return &RefreshResult{ + ArtistID: foreignArtistID, + ArtistName: artist.Name, + AlbumsUpdated: albumsUpdated, + AlbumsAdded: albumsAdded, + }, nil +} + type NotFoundError struct { Message string } diff --git a/testing/e2e/delete_artist_test.go b/testing/e2e/delete_artist_test.go new file mode 100644 index 0000000..465ae57 --- /dev/null +++ b/testing/e2e/delete_artist_test.go @@ -0,0 +1,240 @@ +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/fujin/music-agregator/testing/e2e/testutil" +) + +// TestDeleteArtist_Flow covers section 1.3 of FLOWS.md: +// 1. User deletes artist +// 2. Cascading delete: artists → albums → album_releases → tracks, wanted_albums, download_queue entries +// 3. track_files records removed (no physical file deletion) +func TestDeleteArtist_Flow(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Air" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + AlbumsStored int `json:"albums_stored"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + artistID := syncResult.ArtistID + + albumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err != nil { + t.Fatalf("failed to count albums: %v", err) + } + if albumCount == 0 { + t.Fatal("expected albums to exist before delete") + } + + t.Run("DeleteArtist", func(t *testing.T) { + deleteResp, err := env.DELETE("/api/artists/" + artistID) + if err != nil { + t.Fatalf("delete request failed: %v", err) + } + deleteResp.AssertStatus(t, 200) + + var deleteResult struct { + Deleted bool `json:"deleted"` + Message string `json:"message"` + } + if err := deleteResp.DecodeJSON(&deleteResult); err != nil { + t.Fatalf("failed to decode delete response: %v", err) + } + + if !deleteResult.Deleted { + t.Error("expected deleted=true") + } + }) + + t.Run("ArtistNoLongerExists", func(t *testing.T) { + artist, err := env.GetArtistByForeignID(ctx, artistID) + if err == nil && artist != nil { + t.Error("expected artist to be deleted from database") + } + }) + + t.Run("AlbumsCascadeDeleted", func(t *testing.T) { + albumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err == nil && albumCount > 0 { + t.Errorf("expected albums to be cascade deleted, found %d", albumCount) + } + }) + + t.Run("ArtistNotInLibraryList", func(t *testing.T) { + artistsResp, err := env.GET("/api/library/artists") + if err != nil { + t.Fatalf("list artists failed: %v", err) + } + artistsResp.AssertStatus(t, 200) + + var artists []struct { + Name string `json:"name"` + } + if err := artistsResp.DecodeJSON(&artists); err != nil { + t.Fatalf("failed to decode artists: %v", err) + } + + for _, a := range artists { + if a.Name == syncResult.ArtistName { + t.Errorf("deleted artist %q still appears in library list", syncResult.ArtistName) + } + } + }) +} + +func TestDeleteArtist_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + deleteResp, err := env.DELETE("/api/artists/nonexistent-artist-id-99999") + if err != nil { + t.Fatalf("delete request failed: %v", err) + } + + deleteResp.AssertStatus(t, 404) + + var errorResp struct { + Error string `json:"error"` + } + if err := deleteResp.DecodeJSON(&errorResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if errorResp.Error == "" { + t.Error("expected error message in response") + } +} + +func TestDeleteArtist_VerifyStatsDecreased(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Lamb" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + } + syncResp.DecodeJSON(&syncResult) + + statsBeforeResp, err := env.GET("/api/library/stats") + if err != nil { + t.Fatalf("stats request failed: %v", err) + } + var statsBefore struct { + Artists int64 `json:"artists"` + Albums int64 `json:"albums"` + } + statsBeforeResp.DecodeJSON(&statsBefore) + + deleteResp, err := env.DELETE("/api/artists/" + syncResult.ArtistID) + if err != nil { + t.Fatalf("delete request failed: %v", err) + } + deleteResp.AssertStatus(t, 200) + + statsAfterResp, err := env.GET("/api/library/stats") + if err != nil { + t.Fatalf("stats request failed: %v", err) + } + var statsAfter struct { + Artists int64 `json:"artists"` + Albums int64 `json:"albums"` + } + statsAfterResp.DecodeJSON(&statsAfter) + + if statsAfter.Artists >= statsBefore.Artists { + t.Errorf("expected artist count to decrease: before=%d, after=%d", + statsBefore.Artists, statsAfter.Artists) + } + + if statsAfter.Albums >= statsBefore.Albums { + t.Errorf("expected album count to decrease: before=%d, after=%d", + statsBefore.Albums, statsAfter.Albums) + } +} + +func TestDeleteArtist_Idempotent(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Tricky" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + } + syncResp.DecodeJSON(&syncResult) + + firstDelete, err := env.DELETE("/api/artists/" + syncResult.ArtistID) + if err != nil { + t.Fatalf("first delete failed: %v", err) + } + firstDelete.AssertStatus(t, 200) + + secondDelete, err := env.DELETE("/api/artists/" + syncResult.ArtistID) + if err != nil { + t.Fatalf("second delete failed: %v", err) + } + secondDelete.AssertStatus(t, 404) +} diff --git a/testing/e2e/refresh_artist_test.go b/testing/e2e/refresh_artist_test.go new file mode 100644 index 0000000..d971e29 --- /dev/null +++ b/testing/e2e/refresh_artist_test.go @@ -0,0 +1,265 @@ +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/fujin/music-agregator/testing/e2e/testutil" +) + +// TestRefreshArtist_Flow covers section 1.2 of FLOWS.md: +// 1. Manual or scheduled trigger +// 2. Re-fetches artist + albums from MetadataService +// 3. Upserts new/changed albums, keeps existing +// 4. Updates artist_metadata.updated_at +func TestRefreshArtist_Flow(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Depeche Mode" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("initial sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + artistID := syncResult.ArtistID + actualArtistName := syncResult.ArtistName + + initialUpdatedAt, err := env.GetArtistUpdatedAt(ctx, artistID) + if err != nil { + t.Fatalf("failed to get initial updated_at: %v", err) + } + + initialAlbumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err != nil { + t.Fatalf("failed to count initial albums: %v", err) + } + + time.Sleep(1 * time.Second) + + t.Run("ManualRefreshTrigger", func(t *testing.T) { + refreshResp, err := env.POST("/api/artists/"+artistID+"/refresh", nil) + if err != nil { + t.Fatalf("refresh request failed: %v", err) + } + refreshResp.AssertStatus(t, 200) + + var refreshResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + AlbumsUpdated int `json:"albums_updated"` + AlbumsAdded int `json:"albums_added"` + } + if err := refreshResp.DecodeJSON(&refreshResult); err != nil { + t.Fatalf("failed to decode refresh response: %v", err) + } + + if refreshResult.ArtistID != artistID { + t.Errorf("expected artist_id=%q, got %q", artistID, refreshResult.ArtistID) + } + if refreshResult.ArtistName != actualArtistName { + t.Errorf("expected artist_name=%q, got %q", actualArtistName, refreshResult.ArtistName) + } + }) + + t.Run("UpdatedAtIncreased", func(t *testing.T) { + newUpdatedAt, err := env.GetArtistUpdatedAt(ctx, artistID) + if err != nil { + t.Fatalf("failed to get new updated_at: %v", err) + } + + if !newUpdatedAt.After(initialUpdatedAt) { + t.Errorf("expected updated_at to increase: was %v, now %v", initialUpdatedAt, newUpdatedAt) + } + }) + + t.Run("AlbumsPreserved", func(t *testing.T) { + newAlbumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err != nil { + t.Fatalf("failed to count albums after refresh: %v", err) + } + + if newAlbumCount < initialAlbumCount { + t.Errorf("expected albums to be preserved: was %d, now %d", initialAlbumCount, newAlbumCount) + } + }) + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), actualArtistName) + }) +} + +func TestRefreshArtist_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + refreshResp, err := env.POST("/api/artists/nonexistent-artist-id-12345/refresh", nil) + if err != nil { + t.Fatalf("refresh request failed: %v", err) + } + + refreshResp.AssertStatus(t, 404) + + var errorResp struct { + Error string `json:"error"` + } + if err := refreshResp.DecodeJSON(&errorResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if errorResp.Error == "" { + t.Error("expected error message in response") + } +} + +func TestRefreshArtist_UpsertsNewAlbums(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Massive Attack" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "album": "Mezzanine", + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("initial sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + TotalAlbums int `json:"total_albums"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + artistID := syncResult.ArtistID + actualArtistName := syncResult.ArtistName + + initialAlbumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err != nil { + t.Fatalf("failed to count initial albums: %v", err) + } + + refreshResp, err := env.POST("/api/artists/"+artistID+"/refresh", nil) + if err != nil { + t.Fatalf("refresh request failed: %v", err) + } + refreshResp.AssertStatus(t, 200) + + var refreshResult struct { + AlbumsAdded int `json:"albums_added"` + } + if err := refreshResp.DecodeJSON(&refreshResult); err != nil { + t.Fatalf("failed to decode refresh response: %v", err) + } + + finalAlbumCount, err := env.CountAlbumsByArtist(ctx, artistID) + if err != nil { + t.Fatalf("failed to count final albums: %v", err) + } + + if finalAlbumCount <= initialAlbumCount { + t.Logf("no new albums added (initial sync had filtered subset)") + } + + if refreshResult.AlbumsAdded > 0 && finalAlbumCount != initialAlbumCount+int64(refreshResult.AlbumsAdded) { + t.Errorf("album count mismatch: initial=%d, added=%d, final=%d", + initialAlbumCount, refreshResult.AlbumsAdded, finalAlbumCount) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), actualArtistName) + }) +} + +func TestRefreshArtist_IdempotentRefresh(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Portishead" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("initial sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + } + syncResp.DecodeJSON(&syncResult) + artistID := syncResult.ArtistID + actualArtistName := syncResult.ArtistName + + firstRefresh, err := env.POST("/api/artists/"+artistID+"/refresh", nil) + if err != nil { + t.Fatalf("first refresh failed: %v", err) + } + firstRefresh.AssertStatus(t, 200) + + countAfterFirst, _ := env.CountAlbumsByArtist(ctx, artistID) + + secondRefresh, err := env.POST("/api/artists/"+artistID+"/refresh", nil) + if err != nil { + t.Fatalf("second refresh failed: %v", err) + } + secondRefresh.AssertStatus(t, 200) + + countAfterSecond, _ := env.CountAlbumsByArtist(ctx, artistID) + + if countAfterSecond != countAfterFirst { + t.Errorf("expected idempotent refresh: album count was %d after first, %d after second", + countAfterFirst, countAfterSecond) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), actualArtistName) + }) +} diff --git a/testing/e2e/testutil/testutil.go b/testing/e2e/testutil/testutil.go index 2108a08..4471f34 100644 --- a/testing/e2e/testutil/testutil.go +++ b/testing/e2e/testutil/testutil.go @@ -183,6 +183,11 @@ func (e *TestEnv) GET(path string) (*APIResponse, error) { return e.Do(APIRequest{Method: http.MethodGet, Path: path}) } +// DELETE is a convenience method for DELETE requests. +func (e *TestEnv) DELETE(path string) (*APIResponse, error) { + return e.Do(APIRequest{Method: http.MethodDelete, Path: path}) +} + // DecodeJSON decodes the response body into the given value. func (r *APIResponse) DecodeJSON(v any) error { return json.Unmarshal(r.Body, v) @@ -244,6 +249,26 @@ func (e *TestEnv) GetArtistByForeignID(ctx context.Context, foreignID string) (m return result, nil } +// GetArtistUpdatedAt retrieves the updated_at timestamp for an artist. +func (e *TestEnv) GetArtistUpdatedAt(ctx context.Context, foreignArtistID string) (time.Time, error) { + var updatedAt time.Time + err := e.DB.QueryRow(ctx, ` + SELECT updated_at FROM artist_metadata WHERE foreign_artist_id = $1 + `, foreignArtistID).Scan(&updatedAt) + return updatedAt, err +} + +// CountAlbumsByArtist returns the number of albums for a specific artist. +func (e *TestEnv) CountAlbumsByArtist(ctx context.Context, foreignArtistID string) (int64, error) { + var count int64 + err := e.DB.QueryRow(ctx, ` + SELECT COUNT(*) FROM albums a + JOIN artist_metadata am ON a.artist_metadata_id = am.id + WHERE am.foreign_artist_id = $1 + `, foreignArtistID).Scan(&count) + return count, err +} + // GetAlbumsByArtistForeignID retrieves albums for an artist by foreign artist ID. func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) { rows, err := e.DB.Query(ctx, `