// Package e2e contains end-to-end tests for the music aggregator. // // This file covers Section 2 of FLOWS.md: Album Management // - 2.1 Album Monitoring // - 2.2 Album Search (Manual) // - 2.3 Artist Search package e2e import ( "context" "testing" "time" "github.com/fujin/music-agregator/testing/e2e/testutil" ) // TestAlbumMonitoring_Flow covers section 2.1 of FLOWS.md: // 1. Toggle albums.monitored per album or bulk per artist // 2. Only monitored albums eligible for search/download // 3. Toggling monitored ON adds to wanted_albums if no track_files exist func TestAlbumMonitoring_Flow(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) } if err := env.CleanupWantedAlbums(ctx); err != nil { t.Fatalf("cleanup wanted_albums failed: %v", err) } 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"` Results []struct { AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` } `json:"results"` } if err := syncResp.DecodeJSON(&syncResult); err != nil { t.Fatalf("failed to decode sync response: %v", err) } if len(syncResult.Results) == 0 { t.Skip("no albums synced for test") } t.Cleanup(func() { env.CleanupArtistByName(context.Background(), artistName) env.CleanupWantedAlbums(context.Background()) }) albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get albums: %v", err) } if len(albums) == 0 { t.Fatal("expected at least one album") } testAlbumID := albums[0]["id"].(string) t.Run("Step1_ToggleAlbumMonitoredOff", func(t *testing.T) { resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{ "monitored": false, }) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) album, err := env.GetAlbumByID(ctx, testAlbumID) if err != nil { t.Fatalf("failed to get album: %v", err) } if album["monitored"].(bool) != false { t.Error("expected album to be unmonitored") } }) t.Run("Step2_ToggleAlbumMonitoredOn_AddsToWanted", func(t *testing.T) { wantedBefore, _ := env.CountWantedAlbums(ctx) resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{ "monitored": true, }) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) album, err := env.GetAlbumByID(ctx, testAlbumID) if err != nil { t.Fatalf("failed to get album: %v", err) } if album["monitored"].(bool) != true { t.Error("expected album to be monitored") } isWanted, err := env.IsAlbumWanted(ctx, testAlbumID) if err != nil { t.Fatalf("failed to check wanted status: %v", err) } if !isWanted { t.Error("expected album to be added to wanted_albums when monitored=true") } wantedAfter, _ := env.CountWantedAlbums(ctx) if wantedAfter <= wantedBefore { t.Errorf("expected wanted_albums count to increase, was %d, now %d", wantedBefore, wantedAfter) } }) t.Run("Step3_ToggleMonitoredOff_RemovesFromWanted", func(t *testing.T) { resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{ "monitored": false, }) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) isWanted, err := env.IsAlbumWanted(ctx, testAlbumID) if err != nil { t.Fatalf("failed to check wanted status: %v", err) } if isWanted { t.Error("expected album to be removed from wanted_albums when monitored=false") } }) t.Run("Step4_BulkMonitorArtistAlbums", func(t *testing.T) { env.CleanupWantedAlbums(ctx) resp, err := env.PUT("/api/artists/"+syncResult.ArtistID+"/albums/monitor", map[string]any{ "monitored": true, }) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var result struct { UpdatedCount int `json:"updated_count"` } if err := resp.DecodeJSON(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if result.UpdatedCount == 0 { t.Error("expected updated_count > 0") } wantedAlbums, err := env.GetWantedAlbumsByArtist(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get wanted albums: %v", err) } if len(wantedAlbums) == 0 { t.Error("expected albums to be added to wanted_albums after bulk monitor") } }) t.Run("Step5_BulkUnmonitorArtistAlbums", func(t *testing.T) { resp, err := env.PUT("/api/artists/"+syncResult.ArtistID+"/albums/monitor", map[string]any{ "monitored": false, }) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) wantedAlbums, err := env.GetWantedAlbumsByArtist(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get wanted albums: %v", err) } if len(wantedAlbums) != 0 { t.Errorf("expected no wanted albums after bulk unmonitor, got %d", len(wantedAlbums)) } }) } // TestAlbumSearch_Flow covers section 2.2 of FLOWS.md: // 1. User triggers search for specific album // 2. Query all configured indexers (Torznab) // 3. Filter results: check blocklist, check quality against profile // 4. Rank results: FLAC preference, seeder count // 5. Return ranked list for manual selection or auto-grab best func TestAlbumSearch_Flow(t *testing.T) { env := testutil.NewTestEnv(t) defer env.Close() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() artistName := "Radiohead" 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": "OK Computer", "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"` Results []struct { AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` } `json:"results"` } if err := syncResp.DecodeJSON(&syncResult); err != nil { t.Fatalf("failed to decode sync response: %v", err) } if len(syncResult.Results) == 0 { t.Skip("no albums synced for test") } t.Cleanup(func() { env.CleanupArtistByName(context.Background(), artistName) env.CleanupBlocklist(context.Background()) }) albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get albums: %v", err) } testAlbumID := albums[0]["id"].(string) t.Run("Step1_SearchAlbum", func(t *testing.T) { resp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var result struct { AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` ArtistName string `json:"artist_name"` Results []struct { GUID string `json:"guid"` Title string `json:"title"` DownloadURL string `json:"download_url"` Size uint64 `json:"size"` Seeders *int `json:"seeders"` Quality string `json:"quality"` Indexer string `json:"indexer"` Score float64 `json:"score"` } `json:"results"` TotalResults int `json:"total_results"` } if err := resp.DecodeJSON(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if result.AlbumID != testAlbumID { t.Errorf("expected album_id=%s, got %s", testAlbumID, result.AlbumID) } t.Logf("found %d results for album search", result.TotalResults) }) t.Run("Step2_SearchResults_RankedByQualityAndSeeders", func(t *testing.T) { resp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var result struct { Results []struct { Quality string `json:"quality"` Seeders *int `json:"seeders"` Score float64 `json:"score"` } `json:"results"` } if err := resp.DecodeJSON(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(result.Results) < 2 { t.Skip("need at least 2 results to verify ranking") } for i := 1; i < len(result.Results); i++ { if result.Results[i].Score > result.Results[i-1].Score { t.Errorf("results not sorted by score: result[%d].Score=%f > result[%d].Score=%f", i, result.Results[i].Score, i-1, result.Results[i-1].Score) } } }) t.Run("Step3_BlocklistedResults_Filtered", func(t *testing.T) { searchResp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil) if err != nil { t.Fatalf("search request failed: %v", err) } searchResp.AssertStatus(t, 200) var searchResult struct { Results []struct { GUID string `json:"guid"` Title string `json:"title"` Indexer string `json:"indexer"` } `json:"results"` } if err := searchResp.DecodeJSON(&searchResult); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(searchResult.Results) == 0 { t.Skip("no results to test blocklist filtering") } blockedGUID := searchResult.Results[0].GUID blockedTitle := searchResult.Results[0].Title blockResp, err := env.POST("/api/blocklist", map[string]any{ "album_id": testAlbumID, "source_title": blockedTitle, "guid": blockedGUID, }) if err != nil { t.Fatalf("blocklist request failed: %v", err) } blockResp.AssertStatus(t, 200) searchResp2, err := env.POST("/api/albums/"+testAlbumID+"/search", nil) if err != nil { t.Fatalf("second search request failed: %v", err) } searchResp2.AssertStatus(t, 200) var searchResult2 struct { Results []struct { GUID string `json:"guid"` } `json:"results"` } if err := searchResp2.DecodeJSON(&searchResult2); err != nil { t.Fatalf("failed to decode response: %v", err) } for _, r := range searchResult2.Results { if r.GUID == blockedGUID { t.Errorf("blocklisted result (GUID=%s) should not appear in search results", blockedGUID) } } }) } // TestArtistSearch_Flow covers section 2.3 of FLOWS.md: // 1. Search all monitored albums for an artist in one batch // 2. For each monitored album: run album search flow func TestArtistSearch_Flow(t *testing.T) { env := testutil.NewTestEnv(t) defer env.Close() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() artistName := "Massive Attack" if err := env.CleanupArtistByName(ctx, artistName); err != nil { t.Fatalf("cleanup failed: %v", err) } if err := env.CleanupWantedAlbums(ctx); err != nil { t.Fatalf("cleanup wanted_albums failed: %v", err) } 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"` } if err := syncResp.DecodeJSON(&syncResult); err != nil { t.Fatalf("failed to decode sync response: %v", err) } t.Cleanup(func() { env.CleanupArtistByName(context.Background(), artistName) env.CleanupWantedAlbums(context.Background()) }) albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get albums: %v", err) } monitoredCount := 0 for _, album := range albums { if album["monitored"].(bool) { monitoredCount++ } } t.Logf("artist has %d/%d monitored albums", monitoredCount, len(albums)) t.Run("Step1_SearchAllMonitoredAlbums", func(t *testing.T) { resp, err := env.POST("/api/artists/"+syncResult.ArtistID+"/search", nil) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var result struct { ArtistID string `json:"artist_id"` ArtistName string `json:"artist_name"` AlbumsSearched int `json:"albums_searched"` Results []struct { AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` ResultsCount int `json:"results_count"` } `json:"results"` } if err := resp.DecodeJSON(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if result.ArtistID != syncResult.ArtistID { t.Errorf("expected artist_id=%s, got %s", syncResult.ArtistID, result.ArtistID) } if result.AlbumsSearched != monitoredCount { t.Errorf("expected albums_searched=%d (monitored count), got %d", monitoredCount, result.AlbumsSearched) } if len(result.Results) != monitoredCount { t.Errorf("expected %d album results, got %d", monitoredCount, len(result.Results)) } t.Logf("searched %d albums, got results for each", result.AlbumsSearched) }) t.Run("Step2_OnlyMonitoredAlbumsSearched", func(t *testing.T) { if len(albums) < 2 { t.Skip("need at least 2 albums to test selective monitoring") } unmonitorID := albums[0]["id"].(string) _, err := env.PUT("/api/albums/"+unmonitorID, map[string]any{ "monitored": false, }) if err != nil { t.Fatalf("failed to unmonitor album: %v", err) } resp, err := env.POST("/api/artists/"+syncResult.ArtistID+"/search", nil) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var result struct { AlbumsSearched int `json:"albums_searched"` Results []struct { AlbumID string `json:"album_id"` } `json:"results"` } if err := resp.DecodeJSON(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } for _, r := range result.Results { if r.AlbumID == unmonitorID { t.Errorf("unmonitored album %s should not be searched", unmonitorID) } } expectedSearched := monitoredCount - 1 if result.AlbumsSearched != expectedSearched { t.Errorf("expected albums_searched=%d after unmonitoring one, got %d", expectedSearched, result.AlbumsSearched) } }) } func TestAlbum_GetById(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) } syncResp, err := env.POST("/api/sync", map[string]any{ "artist": artistName, "album": "Selected Ambient Works", "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"` } if err := syncResp.DecodeJSON(&syncResult); err != nil { t.Fatalf("failed to decode sync response: %v", err) } t.Cleanup(func() { env.CleanupArtistByName(context.Background(), artistName) }) albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID) if err != nil { t.Fatalf("failed to get albums: %v", err) } if len(albums) == 0 { t.Skip("no albums synced") } testAlbumID := albums[0]["id"].(string) resp, err := env.GET("/api/albums/" + testAlbumID) if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 200) var album struct { ID string `json:"id"` Title string `json:"title"` Monitored bool `json:"monitored"` } if err := resp.DecodeJSON(&album); err != nil { t.Fatalf("failed to decode response: %v", err) } if album.ID != testAlbumID { t.Errorf("expected id=%s, got %s", testAlbumID, album.ID) } if album.Title == "" { t.Error("expected non-empty title") } } func TestAlbum_NotFound(t *testing.T) { env := testutil.NewTestEnv(t) defer env.Close() resp, err := env.GET("/api/albums/00000000-0000-0000-0000-000000000000") if err != nil { t.Fatalf("request failed: %v", err) } resp.AssertStatus(t, 404) }