582 lines
16 KiB
Go
582 lines
16 KiB
Go
// 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)
|
|
}
|