More removing old code
This commit is contained in:
@@ -36,46 +36,10 @@
|
|||||||
gofmt.enable = true;
|
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
|
in
|
||||||
{
|
{
|
||||||
formatter = pkgs.nixfmt-tree;
|
formatter = pkgs.nixfmt-tree;
|
||||||
|
|
||||||
packages = {
|
|
||||||
default = music-agregator;
|
|
||||||
inherit music-agregator;
|
|
||||||
};
|
|
||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
inherit pre-commit-check;
|
inherit pre-commit-check;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user