Files
music-agregator/testing/e2e/add_artist_test.go
T
Alexander 25deaf4621 test: add e2e tests for Add Artist flow (section 1.1)
- Add testutil package with DB/HTTP helpers and cleanup utilities
- Add e2e tests covering artist search, album fetch, sync persistence
- Test idempotent sync, album filtering, library list endpoints
- Requires running PostgreSQL, MetadataService, and API server
2026-04-29 11:25:30 +02:00

601 lines
15 KiB
Go

// Package e2e contains end-to-end tests for the music aggregator.
//
// Prerequisites:
// - PostgreSQL running (docker-compose up -d postgres)
// - MetadataService gRPC server running
// - API server running (go run ./cmd/server)
//
// Run tests:
//
// go test -v ./testing/e2e/... -tags=e2e
package e2e
import (
"context"
"testing"
"time"
"github.com/fujin/music-agregator/testing/e2e/testutil"
)
// TestAddArtist_Flow covers section 1.1 of FLOWS.md:
// 1. User searches for artist by name
// 2. MetadataService returns matching artists
// 3. User selects artist (picks quality profile, root folder, monitoring options)
// 4. System fetches all albums from MetadataService
// 5. Persists artist_metadata, artists (library entry), albums to DB
// 6. Albums marked monitored/unmonitored based on monitoring preset
// 7. If "search on add": triggers search for all monitored albums
func TestAddArtist_Flow(t *testing.T) {
env := testutil.NewTestEnv(t)
defer env.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
t.Run("Step1_SearchArtistByName", func(t *testing.T) {
testSearchArtistByName(t, env, ctx)
})
t.Run("Step2_MetadataServiceReturnsArtists", func(t *testing.T) {
testMetadataServiceReturnsArtists(t, env, ctx)
})
t.Run("Step3_GetArtistAlbums", func(t *testing.T) {
testGetArtistAlbums(t, env, ctx)
})
t.Run("Step4_SyncPersistsArtistAndAlbums", func(t *testing.T) {
testSyncPersistsArtistAndAlbums(t, env, ctx)
})
t.Run("Step5_SyncWithDownloadOption", func(t *testing.T) {
testSyncWithDownloadOption(t, env, ctx)
})
}
func testSearchArtistByName(t *testing.T, env *testutil.TestEnv, ctx context.Context) {
resp, err := env.POST("/api/metadata/artists/search", map[string]any{
"query": "Radiohead",
"limit": 5,
})
if err != nil {
t.Fatalf("search request failed: %v", err)
}
resp.AssertStatus(t, 200)
var result struct {
Artists []struct {
ID string `json:"id"`
Name string `json:"name"`
ArtistType string `json:"artistType"`
} `json:"artists"`
Total int `json:"total"`
}
if err := resp.DecodeJSON(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(result.Artists) == 0 {
t.Fatal("expected at least one artist in search results")
}
found := false
for _, artist := range result.Artists {
if artist.Name == "Radiohead" {
found = true
if artist.ID == "" {
t.Error("artist ID should not be empty")
}
break
}
}
if !found {
t.Errorf("expected to find 'Radiohead' in results, got: %+v", result.Artists)
}
}
func testMetadataServiceReturnsArtists(t *testing.T, env *testutil.TestEnv, ctx context.Context) {
resp, err := env.POST("/api/metadata/artists/search", map[string]any{
"query": "The Beatles",
"limit": 10,
"offset": 0,
})
if err != nil {
t.Fatalf("search request failed: %v", err)
}
resp.AssertStatus(t, 200)
var result struct {
Artists []struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sortName"`
ArtistType string `json:"artistType"`
Country string `json:"country"`
Description string `json:"description"`
Genres []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"genres"`
ExternalIds []struct {
Source string `json:"source"`
SourceId string `json:"sourceId"`
} `json:"externalIds"`
} `json:"artists"`
Total int `json:"total"`
}
if err := resp.DecodeJSON(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result.Total == 0 {
t.Fatal("expected total > 0")
}
if len(result.Artists) == 0 {
t.Fatal("expected artists in response")
}
artist := result.Artists[0]
if artist.ID == "" {
t.Error("artist ID should not be empty")
}
if artist.Name == "" {
t.Error("artist name should not be empty")
}
}
func testGetArtistAlbums(t *testing.T, env *testutil.TestEnv, ctx context.Context) {
searchResp, err := env.POST("/api/metadata/artists/search", map[string]any{
"query": "Pink Floyd",
"limit": 1,
})
if err != nil {
t.Fatalf("search request failed: %v", err)
}
searchResp.AssertStatus(t, 200)
var searchResult struct {
Artists []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"artists"`
}
if err := searchResp.DecodeJSON(&searchResult); err != nil {
t.Fatalf("failed to decode search response: %v", err)
}
if len(searchResult.Artists) == 0 {
t.Skip("Pink Floyd not found in metadata service")
}
artistID := searchResult.Artists[0].ID
albumsResp, err := env.GET("/api/metadata/artists/" + artistID + "/albums")
if err != nil {
t.Fatalf("get albums request failed: %v", err)
}
albumsResp.AssertStatus(t, 200)
var albumsResult struct {
Albums []struct {
ID string `json:"id"`
Title string `json:"title"`
AlbumType string `json:"albumType"`
ReleaseDate string `json:"releaseDate"`
TotalTracks int `json:"totalTracks"`
} `json:"albums"`
Total int `json:"total"`
}
if err := albumsResp.DecodeJSON(&albumsResult); err != nil {
t.Fatalf("failed to decode albums response: %v", err)
}
if len(albumsResult.Albums) == 0 {
t.Fatal("expected at least one album")
}
album := albumsResult.Albums[0]
if album.ID == "" {
t.Error("album ID should not be empty")
}
if album.Title == "" {
t.Error("album title should not be empty")
}
}
func testSyncPersistsArtistAndAlbums(t *testing.T, env *testutil.TestEnv, ctx context.Context) {
artistName := "Nirvana"
if err := env.CleanupArtistByName(ctx, artistName); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
initialArtistCount, _ := env.CountArtists(ctx)
initialAlbumCount, _ := env.CountAlbums(ctx)
syncResp, err := env.POST("/api/sync", map[string]any{
"artist": artistName,
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("sync request 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"`
AlbumsStored int `json:"albums_stored"`
Results []struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
Stored bool `json:"stored"`
} `json:"results"`
}
if err := syncResp.DecodeJSON(&syncResult); err != nil {
t.Fatalf("failed to decode sync response: %v", err)
}
if syncResult.ArtistID == "" {
t.Error("artist_id should not be empty")
}
if syncResult.ArtistName != artistName {
t.Errorf("expected artist_name=%q, got %q", artistName, syncResult.ArtistName)
}
if syncResult.TotalAlbums == 0 {
t.Error("expected total_albums > 0")
}
if syncResult.AlbumsStored == 0 {
t.Error("expected albums_stored > 0")
}
finalArtistCount, _ := env.CountArtists(ctx)
finalAlbumCount, _ := env.CountAlbums(ctx)
if finalArtistCount <= initialArtistCount {
t.Errorf("expected artist count to increase, was %d, now %d", initialArtistCount, finalArtistCount)
}
if finalAlbumCount <= initialAlbumCount {
t.Errorf("expected album count to increase, was %d, now %d", initialAlbumCount, finalAlbumCount)
}
artist, err := env.GetArtistByForeignID(ctx, syncResult.ArtistID)
if err != nil {
t.Fatalf("failed to get artist from DB: %v", err)
}
if artist["name"] != artistName {
t.Errorf("expected artist name=%q in DB, got %q", artistName, artist["name"])
}
albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID)
if err != nil {
t.Fatalf("failed to get albums from DB: %v", err)
}
if len(albums) != syncResult.AlbumsStored {
t.Errorf("expected %d albums in DB, got %d", syncResult.AlbumsStored, len(albums))
}
t.Cleanup(func() {
env.CleanupArtistByName(context.Background(), artistName)
})
}
func testSyncWithDownloadOption(t *testing.T, env *testutil.TestEnv, ctx context.Context) {
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,
"album": "Dummy",
"store": true,
"download": true,
})
if err != nil {
t.Fatalf("sync request failed: %v", err)
}
if syncResp.StatusCode != 200 {
t.Logf("sync with download returned status %d (may be expected if torrent client unavailable): %s",
syncResp.StatusCode, string(syncResp.Body))
}
var syncResult struct {
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TotalAlbums int `json:"total_albums"`
AlbumsStored int `json:"albums_stored"`
AlbumsDownloaded int `json:"albums_downloaded"`
AlbumsNoResults int `json:"albums_no_results"`
AlbumsFailed int `json:"albums_failed"`
Results []struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
Stored bool `json:"stored"`
DownloadStatus *string `json:"download_status"`
TorrentHash *string `json:"torrent_hash"`
Indexer *string `json:"indexer"`
Error *string `json:"error"`
} `json:"results"`
}
if err := syncResp.DecodeJSON(&syncResult); err != nil {
t.Fatalf("failed to decode sync response: %v", err)
}
if syncResult.ArtistName != artistName {
t.Errorf("expected artist_name=%q, got %q", artistName, syncResult.ArtistName)
}
for _, result := range syncResult.Results {
if result.DownloadStatus == nil {
t.Errorf("expected download_status for album %q", result.AlbumTitle)
continue
}
status := *result.DownloadStatus
switch status {
case "added":
if result.Indexer == nil || *result.Indexer == "" {
t.Errorf("expected indexer for downloaded album %q", result.AlbumTitle)
}
case "noresults":
t.Logf("no results for album %q (expected if indexers have no matches)", result.AlbumTitle)
case "failed":
t.Logf("download failed for album %q: %v", result.AlbumTitle, result.Error)
}
}
t.Cleanup(func() {
env.CleanupArtistByName(context.Background(), artistName)
})
}
func TestAddArtist_SearchNotFound(t *testing.T) {
env := testutil.NewTestEnv(t)
defer env.Close()
resp, err := env.POST("/api/sync", map[string]any{
"artist": "ThisArtistDefinitelyDoesNotExist12345XYZ",
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.AssertStatus(t, 404)
var errorResp struct {
Error string `json:"error"`
}
if err := resp.DecodeJSON(&errorResp); err != nil {
t.Fatalf("failed to decode error response: %v", err)
}
if errorResp.Error == "" {
t.Error("expected error message in response")
}
}
func TestAddArtist_FilterByAlbum(t *testing.T) {
env := testutil.NewTestEnv(t)
defer env.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
artistName := "Massive Attack"
albumFilter := "Mezzanine"
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": albumFilter,
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("sync request failed: %v", err)
}
syncResp.AssertStatus(t, 200)
var syncResult struct {
TotalAlbums int `json:"total_albums"`
AlbumsStored int `json:"albums_stored"`
Results []struct {
AlbumTitle string `json:"album_title"`
} `json:"results"`
}
if err := syncResp.DecodeJSON(&syncResult); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if syncResult.TotalAlbums == 0 {
t.Skip("no albums matched filter (metadata service may not have this album)")
}
for _, result := range syncResult.Results {
if result.AlbumTitle == "" {
continue
}
t.Logf("stored album: %s", result.AlbumTitle)
}
t.Cleanup(func() {
env.CleanupArtistByName(context.Background(), artistName)
})
}
func TestAddArtist_LibraryListsAfterSync(t *testing.T) {
env := testutil.NewTestEnv(t)
defer env.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
searchQuery := "Bjork"
if err := env.CleanupArtistByName(ctx, "%jork%"); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
syncResp, err := env.POST("/api/sync", map[string]any{
"artist": searchQuery,
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("sync request failed: %v", err)
}
syncResp.AssertStatus(t, 200)
var syncResult struct {
ArtistName string `json:"artist_name"`
}
if err := syncResp.DecodeJSON(&syncResult); err != nil {
t.Fatalf("failed to decode sync response: %v", err)
}
actualArtistName := syncResult.ArtistName
artistsResp, err := env.GET("/api/library/artists")
if err != nil {
t.Fatalf("list artists request failed: %v", err)
}
artistsResp.AssertStatus(t, 200)
var artists []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := artistsResp.DecodeJSON(&artists); err != nil {
t.Fatalf("failed to decode artists: %v", err)
}
found := false
for _, a := range artists {
if a.Name == actualArtistName {
found = true
break
}
}
if !found {
t.Errorf("expected %q in library artists list", actualArtistName)
}
albumsResp, err := env.GET("/api/library/albums")
if err != nil {
t.Fatalf("list albums request failed: %v", err)
}
albumsResp.AssertStatus(t, 200)
var albums []struct {
Title string `json:"title"`
ArtistName string `json:"artist_name"`
}
if err := albumsResp.DecodeJSON(&albums); err != nil {
t.Fatalf("failed to decode albums: %v", err)
}
foundAlbum := false
for _, a := range albums {
if a.ArtistName == actualArtistName {
foundAlbum = true
break
}
}
if !foundAlbum {
t.Errorf("expected albums by %q in library albums list", actualArtistName)
}
statsResp, err := env.GET("/api/library/stats")
if err != nil {
t.Fatalf("stats request failed: %v", err)
}
statsResp.AssertStatus(t, 200)
var stats struct {
Artists int64 `json:"artists"`
Albums int64 `json:"albums"`
}
if err := statsResp.DecodeJSON(&stats); err != nil {
t.Fatalf("failed to decode stats: %v", err)
}
if stats.Artists == 0 {
t.Error("expected artists > 0 in stats")
}
if stats.Albums == 0 {
t.Error("expected albums > 0 in stats")
}
t.Cleanup(func() {
env.CleanupArtistByName(context.Background(), "%jork%")
})
}
func TestAddArtist_IdempotentSync(t *testing.T) {
env := testutil.NewTestEnv(t)
defer env.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
artistName := "Aphex Twin"
if err := env.CleanupArtistByName(ctx, artistName); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
firstResp, err := env.POST("/api/sync", map[string]any{
"artist": artistName,
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("first sync request failed: %v", err)
}
firstResp.AssertStatus(t, 200)
var firstResult struct {
AlbumsStored int `json:"albums_stored"`
}
firstResp.DecodeJSON(&firstResult)
countAfterFirst, _ := env.CountAlbums(ctx)
secondResp, err := env.POST("/api/sync", map[string]any{
"artist": artistName,
"store": true,
"download": false,
})
if err != nil {
t.Fatalf("second sync request failed: %v", err)
}
secondResp.AssertStatus(t, 200)
countAfterSecond, _ := env.CountAlbums(ctx)
if countAfterSecond != countAfterFirst {
t.Errorf("expected idempotent sync: album count was %d after first, %d after second",
countAfterFirst, countAfterSecond)
}
t.Cleanup(func() {
env.CleanupArtistByName(context.Background(), artistName)
})
}