// 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) }) }