feat: add refresh and delete artist endpoints (sections 1.2, 1.3)
- Add POST /api/artists/{id}/refresh to re-fetch metadata from gRPC service
- Add DELETE /api/artists/{id} with cascade delete via PostgreSQL
- Add e2e tests for both flows covering happy path, not found, idempotency
- Extend testutil with GetArtistUpdatedAt, CountAlbumsByArtist, DELETE helper
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user