diff --git a/flake.nix b/flake.nix index 624b9f3..c755200 100644 --- a/flake.nix +++ b/flake.nix @@ -36,46 +36,10 @@ gofmt.enable = true; }; }; - - music-agregator = pkgs.buildGoModule { - pname = "music-agregator"; - version = "0.1.0"; - src = ./.; - vendorHash = "sha256-gad5/pLGWyU45QiEvZJ8xEKNy4K2p5OykKE0nykzh8w="; - - nativeBuildInputs = [ - pkgs.protobuf - pkgs.protoc-gen-go - pkgs.protoc-gen-go-grpc - ]; - - preBuild = '' - export HOME=$(mktemp -d) - mkdir -p pkg/metadatapb/metadata/v1 - ${pkgs.protobuf}/bin/protoc \ - --plugin=protoc-gen-go=${pkgs.protoc-gen-go}/bin/protoc-gen-go \ - --plugin=protoc-gen-go-grpc=${pkgs.protoc-gen-go-grpc}/bin/protoc-gen-go-grpc \ - --proto_path=proto \ - --go_out=pkg/metadatapb --go_opt=paths=source_relative \ - --go-grpc_out=pkg/metadatapb --go-grpc_opt=paths=source_relative \ - proto/metadata/v1/metadata.proto - ''; - - subPackages = [ "cmd/server" ]; - - postInstall = '' - mv $out/bin/server $out/bin/music-agregator - ''; - }; in { formatter = pkgs.nixfmt-tree; - packages = { - default = music-agregator; - inherit music-agregator; - }; - checks = { inherit pre-commit-check; }; diff --git a/testing/e2e/add_artist_test.go b/testing/e2e/add_artist_test.go deleted file mode 100644 index 058a482..0000000 --- a/testing/e2e/add_artist_test.go +++ /dev/null @@ -1,600 +0,0 @@ -// 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) - }) -} diff --git a/testing/e2e/album_test.go b/testing/e2e/album_test.go deleted file mode 100644 index 8a46a12..0000000 --- a/testing/e2e/album_test.go +++ /dev/null @@ -1,581 +0,0 @@ -// 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) -} diff --git a/testing/e2e/delete_artist_test.go b/testing/e2e/delete_artist_test.go deleted file mode 100644 index 465ae57..0000000 --- a/testing/e2e/delete_artist_test.go +++ /dev/null @@ -1,240 +0,0 @@ -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) -} diff --git a/testing/e2e/download_test.go b/testing/e2e/download_test.go deleted file mode 100644 index c5227b7..0000000 --- a/testing/e2e/download_test.go +++ /dev/null @@ -1,458 +0,0 @@ -// Package e2e contains end-to-end tests for the music aggregator. -// -// This file covers Section 4 of FLOWS.md: Download Tracking -// - 4.1 Track Active Downloads -// - 4.2 Completed Download Handling -// - 4.3 Failed Download Handling -// - 4.4 Download Queue Management -package e2e - -import ( - "context" - "testing" - "time" - - "github.com/fujin/music-agregator/testing/e2e/testutil" -) - -// TestTrackActiveDownloads_Flow covers section 4.1 of FLOWS.md: -// 1. Poll torrent client for status of all active downloads -// 2. Match against download_queue entries by torrent_hash -// 3. Update: progress, size_left, status -// 4. Detect state transitions: queued → downloading → seeding → completed -func TestTrackActiveDownloads_Flow(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := env.CleanupDownloadQueue(ctx); err != nil { - t.Fatalf("cleanup failed: %v", err) - } - - t.Cleanup(func() { - env.CleanupDownloadQueue(context.Background()) - }) - - t.Run("Step1_ListDownloadQueue", func(t *testing.T) { - resp, err := env.GET("/api/queue") - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Items []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Progress float64 `json:"progress"` - TorrentHash *string `json:"torrent_hash"` - } `json:"items"` - Total int `json:"total"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("queue has %d items", result.Total) - }) - - t.Run("Step2_AddToQueue", func(t *testing.T) { - resp, err := env.POST("/api/queue", map[string]any{ - "title": "Test Album - FLAC", - "torrent_hash": "abc123def456", - "size": 500000000, - "indexer": "test-indexer", - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - TorrentHash string `json:"torrent_hash"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result.ID == "" { - t.Error("expected non-empty ID") - } - if result.Status != "queued" { - t.Errorf("expected status=queued, got %s", result.Status) - } - }) - - t.Run("Step3_SyncQueueWithTorrentClient", func(t *testing.T) { - resp, err := env.POST("/api/queue/sync", nil) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Synced int `json:"synced"` - Updated int `json:"updated"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("synced %d items, updated %d", result.Synced, result.Updated) - }) - - t.Run("Step4_GetQueueItem", func(t *testing.T) { - listResp, err := env.GET("/api/queue") - if err != nil { - t.Fatalf("request failed: %v", err) - } - - var listResult struct { - Items []struct { - ID string `json:"id"` - } `json:"items"` - } - if err := listResp.DecodeJSON(&listResult); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(listResult.Items) == 0 { - t.Skip("no items in queue") - } - - itemID := listResult.Items[0].ID - resp, err := env.GET("/api/queue/" + itemID) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var item struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Progress float64 `json:"progress"` - Size int64 `json:"size"` - SizeLeft int64 `json:"size_left"` - } - if err := resp.DecodeJSON(&item); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if item.ID != itemID { - t.Errorf("expected id=%s, got %s", itemID, item.ID) - } - }) -} - -// TestCompletedDownloadHandling_Flow covers section 4.2 of FLOWS.md: -// 1. Detect download_queue entry where torrent reports completed/seeding -// 2. Mark download_queue.status = completed, set completed_at -// 3. Remove from wanted_albums -func TestCompletedDownloadHandling_Flow(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := env.CleanupDownloadQueue(ctx); err != nil { - t.Fatalf("cleanup failed: %v", err) - } - if err := env.CleanupWantedAlbums(ctx); err != nil { - t.Fatalf("cleanup wanted_albums failed: %v", err) - } - - t.Cleanup(func() { - env.CleanupDownloadQueue(context.Background()) - env.CleanupWantedAlbums(context.Background()) - }) - - t.Run("Step1_MarkDownloadCompleted", func(t *testing.T) { - addResp, err := env.POST("/api/queue", map[string]any{ - "title": "Completed Album - FLAC", - "torrent_hash": "completed123", - "size": 100000000, - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - addResp.AssertStatus(t, 200) - - var addResult struct { - ID string `json:"id"` - } - if err := addResp.DecodeJSON(&addResult); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - resp, err := env.PUT("/api/queue/"+addResult.ID, map[string]any{ - "status": "completed", - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Status string `json:"status"` - CompletedAt *string `json:"completed_at"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result.Status != "completed" { - t.Errorf("expected status=completed, got %s", result.Status) - } - if result.CompletedAt == nil { - t.Error("expected completed_at to be set") - } - }) -} - -// TestFailedDownloadHandling_Flow covers section 4.3 of FLOWS.md: -// 1. Detect download failure (torrent client reports error) -// 2. Mark download_queue.status = failed, set error_message -// 3. Add release to blocklist (source_title, torrent_hash, indexer, quality) -// 4. Re-add album to wanted_albums for retry search -func TestFailedDownloadHandling_Flow(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := env.CleanupDownloadQueue(ctx); err != nil { - t.Fatalf("cleanup failed: %v", err) - } - if err := env.CleanupBlocklist(ctx); err != nil { - t.Fatalf("cleanup blocklist failed: %v", err) - } - - t.Cleanup(func() { - env.CleanupDownloadQueue(context.Background()) - env.CleanupBlocklist(context.Background()) - }) - - t.Run("Step1_MarkDownloadFailed", func(t *testing.T) { - addResp, err := env.POST("/api/queue", map[string]any{ - "title": "Failed Album - FLAC", - "torrent_hash": "failed456", - "size": 100000000, - "indexer": "test-indexer", - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - addResp.AssertStatus(t, 200) - - var addResult struct { - ID string `json:"id"` - } - if err := addResp.DecodeJSON(&addResult); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - resp, err := env.PUT("/api/queue/"+addResult.ID, map[string]any{ - "status": "failed", - "error_message": "Tracker returned error: torrent not found", - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result.Status != "failed" { - t.Errorf("expected status=failed, got %s", result.Status) - } - if result.ErrorMessage == "" { - t.Error("expected error_message to be set") - } - }) - - t.Run("Step2_BlocklistAndRemove", func(t *testing.T) { - addResp, err := env.POST("/api/queue", map[string]any{ - "title": "To Blocklist Album", - "torrent_hash": "blocklist789", - "size": 100000000, - "indexer": "test-indexer", - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - addResp.AssertStatus(t, 200) - - var addResult struct { - ID string `json:"id"` - } - if err := addResp.DecodeJSON(&addResult); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - resp, err := env.POST("/api/queue/"+addResult.ID+"/blocklist", nil) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Blocklisted bool `json:"blocklisted"` - Removed bool `json:"removed"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !result.Removed { - t.Error("expected item to be removed from queue") - } - - getResp, err := env.GET("/api/queue/" + addResult.ID) - if err != nil { - t.Fatalf("request failed: %v", err) - } - if getResp.StatusCode != 404 { - t.Error("expected item to return 404 after removal") - } - }) -} - -// TestDownloadQueueManagement_Flow covers section 4.4 of FLOWS.md: -// 1. List all download_queue entries with status and progress -// 2. Remove entry (cancel download in torrent client) -// 3. Blocklist and remove (add to blocklist, cancel, re-search) -func TestDownloadQueueManagement_Flow(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := env.CleanupDownloadQueue(ctx); err != nil { - t.Fatalf("cleanup failed: %v", err) - } - - t.Cleanup(func() { - env.CleanupDownloadQueue(context.Background()) - }) - - t.Run("Step1_AddMultipleItems", func(t *testing.T) { - for i := 0; i < 3; i++ { - resp, err := env.POST("/api/queue", map[string]any{ - "title": "Queue Item " + string(rune('A'+i)), - "torrent_hash": "hash" + string(rune('a'+i)), - "size": 100000000 * (i + 1), - }) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - } - }) - - t.Run("Step2_ListWithFilters", func(t *testing.T) { - resp, err := env.GET("/api/queue?status=queued") - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Items []struct { - Status string `json:"status"` - } `json:"items"` - Total int `json:"total"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - for _, item := range result.Items { - if item.Status != "queued" { - t.Errorf("expected all items to have status=queued, got %s", item.Status) - } - } - }) - - t.Run("Step3_RemoveFromQueue", func(t *testing.T) { - listResp, err := env.GET("/api/queue") - if err != nil { - t.Fatalf("request failed: %v", err) - } - - var listResult struct { - Items []struct { - ID string `json:"id"` - } `json:"items"` - } - if err := listResp.DecodeJSON(&listResult); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(listResult.Items) == 0 { - t.Skip("no items in queue") - } - - itemID := listResult.Items[0].ID - countBefore, _ := env.CountDownloadQueue(ctx) - - resp, err := env.DELETE("/api/queue/" + itemID) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - countAfter, _ := env.CountDownloadQueue(ctx) - if countAfter >= countBefore { - t.Error("expected queue count to decrease") - } - }) - - t.Run("Step4_QueueStats", func(t *testing.T) { - resp, err := env.GET("/api/queue/stats") - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 200) - - var result struct { - Total int `json:"total"` - Downloading int `json:"downloading"` - Queued int `json:"queued"` - Completed int `json:"completed"` - Failed int `json:"failed"` - } - if err := resp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("queue stats: total=%d, downloading=%d, queued=%d, completed=%d, failed=%d", - result.Total, result.Downloading, result.Queued, result.Completed, result.Failed) - }) -} - -func TestDownloadQueue_NotFound(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - resp, err := env.GET("/api/queue/00000000-0000-0000-0000-000000000000") - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.AssertStatus(t, 404) -} diff --git a/testing/e2e/edit_artist_test.go b/testing/e2e/edit_artist_test.go deleted file mode 100644 index 5972332..0000000 --- a/testing/e2e/edit_artist_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package e2e - -import ( - "context" - "testing" - "time" - - "github.com/fujin/music-agregator/testing/e2e/testutil" -) - -// TestEditArtist_Flow covers section 1.4 of FLOWS.md: -// 1. User changes quality profile, root folder, monitoring status -// 2. Persist to artists table -func TestEditArtist_Flow(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - artistName := "Morcheeba" - - 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"` - } - if err := syncResp.DecodeJSON(&syncResult); err != nil { - t.Fatalf("failed to decode sync response: %v", err) - } - - artistID := syncResult.ArtistID - - t.Run("GetArtistSettings", func(t *testing.T) { - getResp, err := env.GET("/api/artists/" + artistID) - if err != nil { - t.Fatalf("get artist failed: %v", err) - } - getResp.AssertStatus(t, 200) - - var artist struct { - ID string `json:"id"` - Name string `json:"name"` - Monitored bool `json:"monitored"` - } - if err := getResp.DecodeJSON(&artist); err != nil { - t.Fatalf("failed to decode artist: %v", err) - } - - if artist.Name != syncResult.ArtistName { - t.Errorf("expected name=%q, got %q", syncResult.ArtistName, artist.Name) - } - }) - - t.Run("UpdateMonitoredStatus", func(t *testing.T) { - editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ - "monitored": false, - }) - if err != nil { - t.Fatalf("edit request failed: %v", err) - } - editResp.AssertStatus(t, 200) - - var result struct { - ID string `json:"id"` - Monitored bool `json:"monitored"` - } - if err := editResp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode edit response: %v", err) - } - - if result.Monitored != false { - t.Error("expected monitored=false after edit") - } - - getResp, _ := env.GET("/api/artists/" + artistID) - var artist struct { - Monitored bool `json:"monitored"` - } - getResp.DecodeJSON(&artist) - if artist.Monitored != false { - t.Error("expected monitored=false to persist") - } - }) - - t.Run("UpdateQualityProfile", func(t *testing.T) { - editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ - "quality_profile_id": "test-profile-id", - }) - if err != nil { - t.Fatalf("edit request failed: %v", err) - } - editResp.AssertStatus(t, 200) - - var result struct { - QualityProfileID *string `json:"quality_profile_id"` - } - if err := editResp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode edit response: %v", err) - } - }) - - t.Run("UpdateRootFolder", func(t *testing.T) { - editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ - "root_folder_id": "test-folder-id", - "path": "/music/morcheeba", - }) - if err != nil { - t.Fatalf("edit request failed: %v", err) - } - editResp.AssertStatus(t, 200) - - var result struct { - RootFolderID *string `json:"root_folder_id"` - Path *string `json:"path"` - } - if err := editResp.DecodeJSON(&result); err != nil { - t.Fatalf("failed to decode edit response: %v", err) - } - - if result.Path == nil || *result.Path != "/music/morcheeba" { - t.Errorf("expected path=/music/morcheeba, got %v", result.Path) - } - }) - - t.Cleanup(func() { - env.CleanupArtistByName(context.Background(), syncResult.ArtistName) - }) -} - -func TestEditArtist_NotFound(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - editResp, err := env.PUT("/api/artists/nonexistent-artist-id-99999", map[string]any{ - "monitored": false, - }) - if err != nil { - t.Fatalf("edit request failed: %v", err) - } - - editResp.AssertStatus(t, 404) -} - -func TestEditArtist_PartialUpdate(t *testing.T) { - env := testutil.NewTestEnv(t) - defer env.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - artistName := "Zero 7" - - 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"` - } - syncResp.DecodeJSON(&syncResult) - - firstEdit, err := env.PUT("/api/artists/"+syncResult.ArtistID, map[string]any{ - "monitored": false, - "path": "/music/zero7", - }) - if err != nil { - t.Fatalf("first edit failed: %v", err) - } - firstEdit.AssertStatus(t, 200) - - secondEdit, err := env.PUT("/api/artists/"+syncResult.ArtistID, map[string]any{ - "monitored": true, - }) - if err != nil { - t.Fatalf("second edit failed: %v", err) - } - secondEdit.AssertStatus(t, 200) - - getResp, _ := env.GET("/api/artists/" + syncResult.ArtistID) - var artist struct { - Monitored bool `json:"monitored"` - Path *string `json:"path"` - } - getResp.DecodeJSON(&artist) - - if artist.Monitored != true { - t.Error("expected monitored=true after second edit") - } - - if artist.Path == nil || *artist.Path != "/music/zero7" { - t.Errorf("expected path to be preserved, got %v", artist.Path) - } - - t.Cleanup(func() { - env.CleanupArtistByName(context.Background(), syncResult.ArtistName) - }) -} diff --git a/testing/e2e/refresh_artist_test.go b/testing/e2e/refresh_artist_test.go deleted file mode 100644 index d971e29..0000000 --- a/testing/e2e/refresh_artist_test.go +++ /dev/null @@ -1,265 +0,0 @@ -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) - }) -} diff --git a/testing/e2e/testutil/testutil.go b/testing/e2e/testutil/testutil.go deleted file mode 100644 index ef002d7..0000000 --- a/testing/e2e/testutil/testutil.go +++ /dev/null @@ -1,416 +0,0 @@ -// Package testutil provides utilities for e2e tests. -// Tests require running services: PostgreSQL, MetadataService (gRPC), and the API server. -package testutil - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// Config holds test environment configuration. -type Config struct { - APIBaseURL string - DatabaseURL string -} - -// DefaultConfig returns configuration from environment variables or defaults. -func DefaultConfig() Config { - return Config{ - APIBaseURL: getEnv("TEST_API_URL", "http://localhost:3000"), - DatabaseURL: getEnv("TEST_DATABASE_URL", "postgresql://music:music@localhost:5433/music_aggregator"), - } -} - -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -// TestEnv provides test environment with database and HTTP client. -type TestEnv struct { - Config Config - DB *pgxpool.Pool - Client *http.Client - t *testing.T -} - -// NewTestEnv creates a new test environment. -// It connects to the database and verifies the API is reachable. -func NewTestEnv(t *testing.T) *TestEnv { - t.Helper() - - cfg := DefaultConfig() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - pool, err := pgxpool.New(ctx, cfg.DatabaseURL) - if err != nil { - t.Fatalf("failed to connect to database: %v", err) - } - - if err := pool.Ping(ctx); err != nil { - t.Fatalf("failed to ping database: %v", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - - env := &TestEnv{ - Config: cfg, - DB: pool, - Client: client, - t: t, - } - - if err := env.waitForAPI(ctx); err != nil { - t.Fatalf("API not reachable: %v", err) - } - - return env -} - -// Close cleans up test environment resources. -func (e *TestEnv) Close() { - if e.DB != nil { - e.DB.Close() - } -} - -// waitForAPI waits for the API to become available. -func (e *TestEnv) waitForAPI(ctx context.Context) error { - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for API") - default: - resp, err := e.Client.Get(e.Config.APIBaseURL + "/health") - if err == nil && resp.StatusCode == http.StatusOK { - resp.Body.Close() - return nil - } - if resp != nil { - resp.Body.Close() - } - time.Sleep(500 * time.Millisecond) - } - } -} - -// CleanupArtist removes an artist and all related data from the database. -func (e *TestEnv) CleanupArtist(ctx context.Context, foreignArtistID string) error { - _, err := e.DB.Exec(ctx, ` - DELETE FROM artist_metadata WHERE foreign_artist_id = $1 - `, foreignArtistID) - return err -} - -// CleanupArtistByName removes artists matching a name pattern. -func (e *TestEnv) CleanupArtistByName(ctx context.Context, namePattern string) error { - _, err := e.DB.Exec(ctx, ` - DELETE FROM artist_metadata WHERE name ILIKE $1 - `, namePattern) - return err -} - -// APIRequest makes an HTTP request to the API. -type APIRequest struct { - Method string - Path string - Body any -} - -// APIResponse holds the response from an API call. -type APIResponse struct { - StatusCode int - Body []byte -} - -// Do executes an API request and returns the response. -func (e *TestEnv) Do(req APIRequest) (*APIResponse, error) { - var bodyReader io.Reader - if req.Body != nil { - bodyBytes, err := json.Marshal(req.Body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(bodyBytes) - } - - httpReq, err := http.NewRequest(req.Method, e.Config.APIBaseURL+req.Path, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - if req.Body != nil { - httpReq.Header.Set("Content-Type", "application/json") - } - - resp, err := e.Client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - return &APIResponse{ - StatusCode: resp.StatusCode, - Body: body, - }, nil -} - -// POST is a convenience method for POST requests. -func (e *TestEnv) POST(path string, body any) (*APIResponse, error) { - return e.Do(APIRequest{Method: http.MethodPost, Path: path, Body: body}) -} - -// GET is a convenience method for GET requests. -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}) -} - -// PUT is a convenience method for PUT requests. -func (e *TestEnv) PUT(path string, body any) (*APIResponse, error) { - return e.Do(APIRequest{Method: http.MethodPut, Path: path, Body: body}) -} - -// DecodeJSON decodes the response body into the given value. -func (r *APIResponse) DecodeJSON(v any) error { - return json.Unmarshal(r.Body, v) -} - -// AssertStatus checks that the response has the expected status code. -func (r *APIResponse) AssertStatus(t *testing.T, expected int) { - t.Helper() - if r.StatusCode != expected { - t.Errorf("expected status %d, got %d. Body: %s", expected, r.StatusCode, string(r.Body)) - } -} - -// CountArtists returns the number of artists in the database. -func (e *TestEnv) CountArtists(ctx context.Context) (int64, error) { - var count int64 - err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count) - return count, err -} - -// CountAlbums returns the number of albums in the database. -func (e *TestEnv) CountAlbums(ctx context.Context) (int64, error) { - var count int64 - err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) - return count, err -} - -// GetArtistByForeignID retrieves an artist by foreign artist ID. -func (e *TestEnv) GetArtistByForeignID(ctx context.Context, foreignID string) (map[string]any, error) { - var result map[string]any - row := e.DB.QueryRow(ctx, ` - SELECT id, foreign_artist_id, name, sort_name, artist_type, genres - FROM artist_metadata - WHERE foreign_artist_id = $1 - `, foreignID) - - var id, foreignArtistID, name string - var sortName, artistType *string - var genres []byte - - err := row.Scan(&id, &foreignArtistID, &name, &sortName, &artistType, &genres) - if err != nil { - return nil, err - } - - result = map[string]any{ - "id": id, - "foreign_artist_id": foreignArtistID, - "name": name, - "sort_name": sortName, - "artist_type": artistType, - } - - var genreList []map[string]any - if err := json.Unmarshal(genres, &genreList); err == nil { - result["genres"] = genreList - } - - 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 -} - -// GetAlbumByID retrieves an album by its UUID. -func (e *TestEnv) GetAlbumByID(ctx context.Context, albumID string) (map[string]any, error) { - var id, title string - var foreignAlbumID, albumType *string - var releaseDate *time.Time - var monitored bool - - err := e.DB.QueryRow(ctx, ` - SELECT id, foreign_album_id, title, album_type, release_date, monitored - FROM albums WHERE id = $1 - `, albumID).Scan(&id, &foreignAlbumID, &title, &albumType, &releaseDate, &monitored) - if err != nil { - return nil, err - } - - result := map[string]any{ - "id": id, - "foreign_album_id": foreignAlbumID, - "title": title, - "album_type": albumType, - "monitored": monitored, - } - if releaseDate != nil { - result["release_date"] = releaseDate.Format("2006-01-02") - } - return result, nil -} - -// CountWantedAlbums returns the number of entries in wanted_albums. -func (e *TestEnv) CountWantedAlbums(ctx context.Context) (int64, error) { - var count int64 - err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM wanted_albums").Scan(&count) - return count, err -} - -// IsAlbumWanted checks if an album is in the wanted_albums table. -func (e *TestEnv) IsAlbumWanted(ctx context.Context, albumID string) (bool, error) { - var count int64 - err := e.DB.QueryRow(ctx, ` - SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1 - `, albumID).Scan(&count) - return count > 0, err -} - -// GetWantedAlbumsByArtist returns wanted album IDs for an artist. -func (e *TestEnv) GetWantedAlbumsByArtist(ctx context.Context, foreignArtistID string) ([]string, error) { - rows, err := e.DB.Query(ctx, ` - SELECT wa.album_id::text FROM wanted_albums wa - JOIN albums a ON wa.album_id = a.id - JOIN artist_metadata am ON a.artist_metadata_id = am.id - WHERE am.foreign_artist_id = $1 - `, foreignArtistID) - if err != nil { - return nil, err - } - defer rows.Close() - - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - return ids, nil -} - -// CountBlocklistEntries returns the number of entries in blocklist. -func (e *TestEnv) CountBlocklistEntries(ctx context.Context) (int64, error) { - var count int64 - err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM blocklist").Scan(&count) - return count, err -} - -// CleanupWantedAlbums removes all wanted_albums entries (for test cleanup). -func (e *TestEnv) CleanupWantedAlbums(ctx context.Context) error { - _, err := e.DB.Exec(ctx, "DELETE FROM wanted_albums") - return err -} - -// CleanupBlocklist removes all blocklist entries (for test cleanup). -func (e *TestEnv) CleanupBlocklist(ctx context.Context) error { - _, err := e.DB.Exec(ctx, "DELETE FROM blocklist") - return err -} - -// CleanupDownloadQueue removes all download_queue entries (for test cleanup). -func (e *TestEnv) CleanupDownloadQueue(ctx context.Context) error { - _, err := e.DB.Exec(ctx, "DELETE FROM download_queue") - return err -} - -// CountDownloadQueue returns the number of entries in download_queue. -func (e *TestEnv) CountDownloadQueue(ctx context.Context) (int64, error) { - var count int64 - err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM download_queue").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, ` - SELECT a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored - FROM albums a - JOIN artist_metadata am ON a.artist_metadata_id = am.id - WHERE am.foreign_artist_id = $1 - ORDER BY a.release_date DESC NULLS LAST - `, foreignArtistID) - if err != nil { - return nil, err - } - defer rows.Close() - - var albums []map[string]any - for rows.Next() { - var id, title string - var foreignAlbumID, albumType *string - var releaseDate *time.Time - var monitored bool - - if err := rows.Scan(&id, &foreignAlbumID, &title, &albumType, &releaseDate, &monitored); err != nil { - return nil, err - } - - album := map[string]any{ - "id": id, - "foreign_album_id": foreignAlbumID, - "title": title, - "album_type": albumType, - "monitored": monitored, - } - if releaseDate != nil { - album["release_date"] = releaseDate.Format("2006-01-02") - } - albums = append(albums, album) - } - - return albums, nil -}