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:
@@ -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) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ func NewRouter(h *Handlers) *chi.Mux {
|
|||||||
|
|
||||||
r.Post("/sync", h.Sync)
|
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.Route("/library", func(r chi.Router) {
|
||||||
r.Get("/artists", h.ListLibraryArtists)
|
r.Get("/artists", h.ListLibraryArtists)
|
||||||
r.Get("/albums", h.ListLibraryAlbums)
|
r.Get("/albums", h.ListLibraryAlbums)
|
||||||
|
|||||||
@@ -250,3 +250,41 @@ func (db *DB) CountAlbums(ctx context.Context) (int64, error) {
|
|||||||
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
|
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
|
||||||
return count, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -289,6 +289,96 @@ func parseUUID(s string) ([16]byte, error) {
|
|||||||
return id, nil
|
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 {
|
type NotFoundError struct {
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
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.
|
// DecodeJSON decodes the response body into the given value.
|
||||||
func (r *APIResponse) DecodeJSON(v any) error {
|
func (r *APIResponse) DecodeJSON(v any) error {
|
||||||
return json.Unmarshal(r.Body, v)
|
return json.Unmarshal(r.Body, v)
|
||||||
@@ -244,6 +249,26 @@ func (e *TestEnv) GetArtistByForeignID(ctx context.Context, foreignID string) (m
|
|||||||
return result, nil
|
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.
|
// GetAlbumsByArtistForeignID retrieves albums for an artist by foreign artist ID.
|
||||||
func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) {
|
func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) {
|
||||||
rows, err := e.DB.Query(ctx, `
|
rows, err := e.DB.Query(ctx, `
|
||||||
|
|||||||
Reference in New Issue
Block a user