ff49403fd5
- Add GET/PUT /api/artists/{id} for artist settings
- Update sync to create artists table entry (library settings)
- Support partial updates for monitored, path, quality/metadata profiles
- Add e2e tests for get, edit, partial update flows
317 lines
8.3 KiB
Go
317 lines
8.3 KiB
Go
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|