// 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 }