test: add e2e tests for Add Artist flow (section 1.1)
- Add testutil package with DB/HTTP helpers and cleanup utilities - Add e2e tests covering artist search, album fetch, sync persistence - Test idempotent sync, album filtering, library list endpoints - Requires running PostgreSQL, MetadataService, and API server
This commit is contained in:
@@ -0,0 +1,600 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user