diff --git a/api/server.go b/api/server.go index 609de0a4..1cc79687 100644 --- a/api/server.go +++ b/api/server.go @@ -300,32 +300,35 @@ func NewApiServer(config config.Config) *ApiServer { if app.rateLimitMiddleware != nil { app.Use(app.rateLimitMiddleware.Middleware(app)) } - app.Use(fiberzap.New(fiberzap.Config{ - Logger: logger, - FieldsFunc: func(c *fiber.Ctx) []zap.Field { - fields := []zap.Field{} - - if startTime, ok := c.Locals("start").(time.Time); ok { - latencyMs := float64(time.Since(startTime).Nanoseconds()) / float64(time.Millisecond) - fields = append(fields, zap.Float64("latency_ms", latencyMs)) - } + // Avoid request log spam in tests; test failures still include assertion output. + if config.Env != "test" { + app.Use(fiberzap.New(fiberzap.Config{ + Logger: logger, + FieldsFunc: func(c *fiber.Ctx) []zap.Field { + fields := []zap.Field{} + + if startTime, ok := c.Locals("start").(time.Time); ok { + latencyMs := float64(time.Since(startTime).Nanoseconds()) / float64(time.Millisecond) + fields = append(fields, zap.Float64("latency_ms", latencyMs)) + } - // Add upstream server to logs, if found - if upstream, ok := c.Locals("upstream").(string); ok && upstream != "" { - fields = append(fields, zap.String("upstream", upstream)) - } + // Add upstream server to logs, if found + if upstream, ok := c.Locals("upstream").(string); ok && upstream != "" { + fields = append(fields, zap.String("upstream", upstream)) + } - if requestId, ok := c.Locals("requestId").(string); ok && requestId != "" { - fields = append(fields, zap.String("request_id", requestId)) - } + if requestId, ok := c.Locals("requestId").(string); ok && requestId != "" { + fields = append(fields, zap.String("request_id", requestId)) + } - ipAddress := apiutils.GetIP(c) - fields = append(fields, zap.String("ip", ipAddress)) + ipAddress := apiutils.GetIP(c) + fields = append(fields, zap.String("ip", ipAddress)) - return fields - }, - Fields: []string{"status", "method", "url", "route"}, - })) + return fields + }, + Fields: []string{"status", "method", "url", "route"}, + })) + } app.Get("/", app.home) diff --git a/api/server_test.go b/api/server_test.go index 7b713573..c763362c 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -32,6 +32,7 @@ func emptyTestApp(t *testing.T) *ApiServer { app := NewApiServer(config.Config{ Env: "test", + LogLevel: "fatal", ReadDbUrl: pool.Config().ConnString(), WriteDbUrl: pool.Config().ConnString(), RunMigrations: false, diff --git a/api/v1_challenges_info_test.go b/api/v1_challenges_info_test.go new file mode 100644 index 00000000..656b33e5 --- /dev/null +++ b/api/v1_challenges_info_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "testing" + "time" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +func TestV1ChallengesInfo(t *testing.T) { + app := emptyTestApp(t) + + now := time.Now().UTC() + + fixtures := database.FixtureMap{ + "challenges": { + { + "id": "challenge-aggregate", + "type": "aggregate", + "amount": "100000000", + "active": true, + "step_count": 10, + "starting_block": 100, + "weekly_pool": 100, + "cooldown_days": 7, + }, + }, + "challenge_disbursements": { + { + "challenge_id": "challenge-aggregate", + "user_id": 1, + "specifier": "spec-a", + "signature": "sig-a", + "slot": 1, + "amount": "200000000", + "created_at": now, + }, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + status, body := testGet(t, app, "/v1/challenges/challenge-aggregate/info") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.challenge_id": "challenge-aggregate", + "data.type": "aggregate", + "data.amount": "100000000", + "data.weekly_pool": 100, + "data.weekly_pool_remaining": 98, + }) + + status, body = testGet(t, app, "/v1/challenges/challenge-aggregate/info?weekly_pool_min_amount=99") + assert.Equal(t, 500, status) + jsonAssert(t, body, map[string]any{ + "data.challenge_id": "challenge-aggregate", + "data.weekly_pool_remaining": 98, + }) +} + +func TestV1ChallengesInfoInvalidWeeklyPoolMinAmount(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/challenges/any/info?weekly_pool_min_amount=-1") + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "weekly_pool_min_amount is invalid", + }) +} diff --git a/api/v1_claim_rewards_test.go b/api/v1_claim_rewards_test.go index f40a82f9..9cfb32ee 100644 --- a/api/v1_claim_rewards_test.go +++ b/api/v1_claim_rewards_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync" "testing" "api.audius.co/config" @@ -14,11 +15,17 @@ import ( func TestFetchAttestations(t *testing.T) { // Track which URLs are called + var urlCallCountMu sync.Mutex urlCallCount := make(map[string]int) + incrementURLCallCount := func(host string) { + urlCallCountMu.Lock() + defer urlCallCountMu.Unlock() + urlCallCount[host]++ + } // Create mock HTTP servers for AAO and validators aaoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"result": "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"}`)) @@ -27,7 +34,7 @@ func TestFetchAttestations(t *testing.T) { // Create separate validator servers validator1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"attestation": "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd", "owner": "0x1111111111111111111111111111111111111111"}`)) @@ -35,7 +42,7 @@ func TestFetchAttestations(t *testing.T) { defer validator1.Close() validator2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) // Duplicate owner should not be selected @@ -44,7 +51,7 @@ func TestFetchAttestations(t *testing.T) { defer validator2.Close() validator3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"attestation": "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd", "owner": "0x3333333333333333333333333333333333333333"}`)) @@ -52,7 +59,7 @@ func TestFetchAttestations(t *testing.T) { defer validator3.Close() validator4 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) w.Write([]byte(`{"error": "unhappy validator"}`)) @@ -60,7 +67,7 @@ func TestFetchAttestations(t *testing.T) { defer validator4.Close() validator5 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlCallCount[r.Host]++ + incrementURLCallCount(r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"attestation": "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd", "owner": "0x5555555555555555555555555555555555555555"}`)) @@ -127,6 +134,8 @@ func TestFetchAttestations(t *testing.T) { assert.False(t, addresses["0x4444444444444444444444444444444444444444"], "validator4 should not be present in attestations") // Verify no URL was called more than once + urlCallCountMu.Lock() + defer urlCallCountMu.Unlock() for url, count := range urlCallCount { assert.LessOrEqual(t, count, 1, "URL %s should never be called more than once, but was called %d times", url, count) } diff --git a/api/v1_coin_members_count_test.go b/api/v1_coin_members_count_test.go new file mode 100644 index 00000000..5b66e1f4 --- /dev/null +++ b/api/v1_coin_members_count_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +func TestV1CoinMembersCount(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "artist_coins": { + { + "mint": "coin-mint-1", + "ticker": "COINONE", + "user_id": 1, + "decimals": 2, + }, + }, + "sol_user_balances": { + { + "user_id": 1, + "mint": "coin-mint-1", + "balance": 99, + "created_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + "updated_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + }, + { + "user_id": 2, + "mint": "coin-mint-1", + "balance": 100, + "created_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + "updated_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + }, + { + "user_id": 3, + "mint": "coin-mint-1", + "balance": 250, + "created_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + "updated_at": parseTimeWithLayout( + t, + "2024-01-01 00:00:00", + "2006-01-02 15:04:05", + ), + }, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + status, body := testGet(t, app, "/v1/coins/coin-mint-1/members/count") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data": 2, + }) + + status, body = testGet(t, app, "/v1/coins/coin-mint-1/members/count?min_balance=2.5") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data": 1, + }) +} + +func TestV1CoinMembersCountInvalidMinBalance(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/coins/coin-mint-1/members/count?min_balance=-1") + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "min_balance is invalid", + }) +} diff --git a/api/v1_coins_volume_leaders_test.go b/api/v1_coins_volume_leaders_test.go new file mode 100644 index 00000000..9e931162 --- /dev/null +++ b/api/v1_coins_volume_leaders_test.go @@ -0,0 +1,60 @@ +package api + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +func TestV1CoinsVolumeLeaders(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/coins/volume-leaders?from=not-a-date") + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "from is invalid", + }) + + now := time.Now().UTC().Truncate(time.Second) + from := now.Add(-2 * time.Hour).Format(time.RFC3339) + to := now.Add(-1 * time.Hour).Format(time.RFC3339) + + status, body = testGet( + t, app, + fmt.Sprintf("/v1/coins/volume-leaders?from=%s&to=%s&limit=5&offset=0", from, to), + ) + assert.Equal(t, 200, status) + assert.True(t, gjson.GetBytes(body, "data").IsArray()) + + status, body = testGet( + t, app, + fmt.Sprintf("/v1/coins/volume-leaders?from=%s&to=%s", now.Format(time.RFC3339), now.Add(-1*time.Hour).Format(time.RFC3339)), + ) + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "To date must be after from date", + }) + + status, body = testGet( + t, app, + fmt.Sprintf("/v1/coins/volume-leaders?from=%s&to=%s", now.Add(-9*24*time.Hour).Format(time.RFC3339), now.Format(time.RFC3339)), + ) + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "Time range must be <= 7 days", + }) + + tooOldFrom := now.Add(-12 * 24 * time.Hour) + tooOldTo := tooOldFrom.Add(6 * time.Hour) + status, body = testGet( + t, app, + fmt.Sprintf("/v1/coins/volume-leaders?from=%s&to=%s", tooOldFrom.Format(time.RFC3339), tooOldTo.Format(time.RFC3339)), + ) + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "Time range too old", + }) +} diff --git a/api/v1_engagement_mutations_test.go b/api/v1_engagement_mutations_test.go new file mode 100644 index 00000000..9e6e9e43 --- /dev/null +++ b/api/v1_engagement_mutations_test.go @@ -0,0 +1,252 @@ +package api + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http/httptest" + "testing" + + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func requestWithBasicAuth(t *testing.T, app *ApiServer, method string, path string, privateKey string, body []byte) (int, []byte) { + t.Helper() + + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("user:%s", privateKey))) + req.Header.Set("Authorization", "Basic "+auth) + + res, err := app.Test(req, -1) + assert.NoError(t, err) + + resBody, _ := io.ReadAll(res.Body) + return res.StatusCode, resBody +} + +func TestEngagementMutationRoutes(t *testing.T) { + app := emptyTestApp(t) + + // Shared dev key used in other mutator tests. + privateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + + userID := trashid.MustEncodeHashID(101) + trackID := trashid.MustEncodeHashID(102) + playlistID := trashid.MustEncodeHashID(103) + + cases := []struct { + name string + method string + path string + body []byte + expectedErr string + }{ + { + name: "post user follow", + method: "POST", + path: "/v1/full/users/" + userID + "/follow", + expectedErr: "Failed to follow user", + }, + { + name: "delete user follow", + method: "DELETE", + path: "/v1/full/users/" + userID + "/follow", + expectedErr: "Failed to unfollow user", + }, + { + name: "post user mute", + method: "POST", + path: "/v1/full/users/" + userID + "/mute", + expectedErr: "Failed to mute user", + }, + { + name: "delete user mute", + method: "DELETE", + path: "/v1/full/users/" + userID + "/mute", + expectedErr: "Failed to unmute user", + }, + { + name: "post user subscribe", + method: "POST", + path: "/v1/full/users/" + userID + "/subscribe", + expectedErr: "Failed to subscribe to user", + }, + { + name: "delete user subscribe", + method: "DELETE", + path: "/v1/full/users/" + userID + "/subscribe", + expectedErr: "Failed to unsubscribe from user", + }, + { + name: "post track favorite", + method: "POST", + path: "/v1/full/tracks/" + trackID + "/favorites", + body: []byte(`{"is_save_of_repost":true}`), + expectedErr: "Failed to favorite track", + }, + { + name: "delete track favorite", + method: "DELETE", + path: "/v1/full/tracks/" + trackID + "/favorites", + expectedErr: "Failed to unfavorite track", + }, + { + name: "post track share", + method: "POST", + path: "/v1/full/tracks/" + trackID + "/shares", + expectedErr: "Failed to share track", + }, + { + name: "post track download", + method: "POST", + path: "/v1/full/tracks/" + trackID + "/downloads", + body: []byte(`{"city":"Los Angeles","region":"CA","country":"US"}`), + expectedErr: "Failed to record track download", + }, + { + name: "delete track repost", + method: "DELETE", + path: "/v1/full/tracks/" + trackID + "/reposts", + expectedErr: "Failed to unrepost track", + }, + { + name: "post playlist favorite", + method: "POST", + path: "/v1/full/playlists/" + playlistID + "/favorites", + body: []byte(`{"is_save_of_repost":true}`), + expectedErr: "Failed to favorite playlist", + }, + { + name: "delete playlist favorite", + method: "DELETE", + path: "/v1/full/playlists/" + playlistID + "/favorites", + expectedErr: "Failed to unfavorite playlist", + }, + { + name: "post playlist repost", + method: "POST", + path: "/v1/full/playlists/" + playlistID + "/reposts", + body: []byte(`{"is_repost_of_repost":true}`), + expectedErr: "Failed to repost playlist", + }, + { + name: "delete playlist repost", + method: "DELETE", + path: "/v1/full/playlists/" + playlistID + "/reposts", + expectedErr: "Failed to unrepost playlist", + }, + { + name: "post playlist share", + method: "POST", + path: "/v1/full/playlists/" + playlistID + "/shares", + expectedErr: "Failed to share playlist", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, body := requestWithBasicAuth(t, app, tc.method, tc.path, privateKey, tc.body) + assert.Equal(t, 500, status, "response body: %s", string(body)) + jsonAssert(t, body, map[string]any{ + "error": tc.expectedErr, + }) + }) + } +} + +func TestEntityMutationRoutes(t *testing.T) { + app := emptyTestApp(t) + + privateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + userID := trashid.MustEncodeHashID(201) + trackID := trashid.MustEncodeHashID(202) + playlistID := trashid.MustEncodeHashID(203) + + cases := []struct { + name string + method string + path string + body []byte + expectStatus int + expectedErr string + }{ + { + name: "post track", + method: "POST", + path: "/v1/full/tracks", + body: []byte(`{"title":"Track","genre":"Electronic","track_cid":"track-cid"}`), + expectStatus: 500, + expectedErr: "Failed to create track", + }, + { + name: "put track with empty update", + method: "PUT", + path: "/v1/full/tracks/" + trackID, + body: []byte(`{}`), + expectStatus: 400, + expectedErr: "At least one field must be provided for update", + }, + { + name: "delete track", + method: "DELETE", + path: "/v1/full/tracks/" + trackID, + expectStatus: 500, + expectedErr: "Failed to delete track", + }, + { + name: "post playlist", + method: "POST", + path: "/v1/full/playlists", + body: []byte(`{"playlist_name":"Playlist"}`), + expectStatus: 500, + expectedErr: "Failed to create playlist", + }, + { + name: "put playlist with empty update", + method: "PUT", + path: "/v1/full/playlists/" + playlistID, + body: []byte(`{}`), + expectStatus: 400, + expectedErr: "At least one field must be provided for update", + }, + { + name: "delete playlist", + method: "DELETE", + path: "/v1/full/playlists/" + playlistID, + expectStatus: 500, + expectedErr: "Failed to delete playlist", + }, + { + name: "post user", + method: "POST", + path: "/v1/full/users", + body: []byte(`{"handle":"newuser","wallet":"0x1111111111111111111111111111111111111111"}`), + expectStatus: 500, + expectedErr: "Failed to create user", + }, + { + name: "put user with empty update", + method: "PUT", + path: "/v1/full/users/" + userID, + body: []byte(`{}`), + expectStatus: 400, + expectedErr: "At least one field must be provided for update", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, body := requestWithBasicAuth(t, app, tc.method, tc.path, privateKey, tc.body) + assert.Equal(t, tc.expectStatus, status, "response body: %s", string(body)) + jsonAssert(t, body, map[string]any{ + "error": tc.expectedErr, + }) + }) + } +} diff --git a/api/v1_metrics_plays_test.go b/api/v1_metrics_plays_test.go new file mode 100644 index 00000000..9bb3636e --- /dev/null +++ b/api/v1_metrics_plays_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestV1MetricsPlays(t *testing.T) { + app := emptyTestApp(t) + + firstTimestamp := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + secondTimestamp := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + + _, err := app.pool.Exec(t.Context(), ` + INSERT INTO hourly_play_counts (hourly_timestamp, play_count) + VALUES + ($1, 7), + ($2, 5) + `, firstTimestamp, secondTimestamp) + assert.NoError(t, err) + + status, body := testGet(t, app, "/v1/metrics/plays?start_time=0&bucket_size=hour&limit=10") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.timestamp": fmt.Sprintf("%d", secondTimestamp.Unix()), + "data.0.count": 5, + "data.1.timestamp": fmt.Sprintf("%d", firstTimestamp.Unix()), + "data.1.count": 7, + }) +} + +func TestV1MetricsPlaysInvalidBucketSize(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/metrics/plays?bucket_size=quarter") + assert.Equal(t, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "bucket_size is invalid", + }) +} diff --git a/api/v1_rendezvous_test.go b/api/v1_rendezvous_test.go new file mode 100644 index 00000000..24434236 --- /dev/null +++ b/api/v1_rendezvous_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "testing" + + "api.audius.co/config" + "api.audius.co/rendezvous" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +func TestV1Rendezvous(t *testing.T) { + app := emptyTestApp(t) + + nodes := []config.Node{ + {Endpoint: "https://validator-1.test"}, + {Endpoint: "https://validator-2.test"}, + {Endpoint: "https://validator-3.test"}, + } + app.validators.SetNodes(nodes) + rendezvous.Refresh(nodes) + + status, body := testGet(t, app, "/v1/rendezvous/bafybeigdyr") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "validators.#": 3, + }) + assert.True(t, gjson.GetBytes(body, "first").Exists()) + assert.True(t, gjson.GetBytes(body, "rest").IsArray()) +} diff --git a/api/v1_search_test.go b/api/v1_search_test.go index 3f29b437..82edcca3 100644 --- a/api/v1_search_test.go +++ b/api/v1_search_test.go @@ -5,6 +5,7 @@ import ( "api.audius.co/database" "api.audius.co/esindexer" + "api.audius.co/trashid" "github.com/test-go/testify/require" ) @@ -445,6 +446,52 @@ func TestSearch(t *testing.T) { }) } + // tracks route wrapper + { + status, body := testGet(t, app, "/v1/tracks/search?query=mouse") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.title": "mouse trap", + }) + } + + // playlists route wrapper + { + status, body := testGet(t, app, "/v1/playlists/search?query=brooding") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.playlist_name": "Brooding Bangers", + }) + } + + // playlists top endpoint + { + status, body := testGet(t, app, "/v1/playlists/top?type=album") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.is_album": true, + "data.0.id": trashid.MustEncodeHashID(9002), + }) + } + + { + status, body := testGet(t, app, "/v1/playlists/top?type=playlist&mood=brooding") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.playlist_name": "Brooding Bangers", + "data.0.is_album": false, + }) + } + + { + status, _ := testGet(t, app, "/v1/playlists/top?type=invalid") + require.Equal(t, 400, status) + } + // // tag search // diff --git a/api/v1_track_inspect_test.go b/api/v1_track_inspect_test.go new file mode 100644 index 00000000..015532cb --- /dev/null +++ b/api/v1_track_inspect_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "testing" + + "api.audius.co/api/dbv1" + "api.audius.co/rendezvous" + "api.audius.co/trashid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" +) + +func TestInspectTrackWithoutHosts(t *testing.T) { + rendezvous.GlobalHasher = rendezvous.NewRendezvousHasher([]string{}) + + track := dbv1.Track{ + GetTracksRow: dbv1.GetTracksRow{ + TrackCid: pgtype.Text{String: "track-cid", Valid: true}, + }, + } + + info, err := inspectTrack(track, false) + assert.Nil(t, info) + assert.ErrorContains(t, err, "failed to fetch blob info from any host") +} + +func TestV1TrackInspectTrackNotFound(t *testing.T) { + app := emptyTestApp(t) + + trackID := trashid.MustEncodeHashID(999999) + status, body := testGet(t, app, "/v1/tracks/"+trackID+"/inspect") + assert.Equal(t, 404, status) + jsonAssert(t, body, map[string]any{ + "error": "track not found", + }) +} + +func TestV1TracksInspectNoTracksFound(t *testing.T) { + app := emptyTestApp(t) + + trackID := trashid.MustEncodeHashID(999998) + status, body := testGet(t, app, "/v1/tracks/inspect?id="+trackID) + assert.Equal(t, 404, status) + jsonAssert(t, body, map[string]any{ + "error": "no tracks found", + }) +} diff --git a/api/v1_unclaimed_id_events_test.go b/api/v1_unclaimed_id_events_test.go new file mode 100644 index 00000000..bf7add2a --- /dev/null +++ b/api/v1_unclaimed_id_events_test.go @@ -0,0 +1,37 @@ +package api + +import ( + "testing" + + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +func TestV1EventsUnclaimedID(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/events/unclaimed_id") + assert.Equal(t, 200, status) + + id := gjson.GetBytes(body, "data").String() + assert.NotEmpty(t, id) + + decoded, err := trashid.DecodeHashId(id) + assert.NoError(t, err) + assert.Greater(t, decoded, 0) +} + +func TestV1EventsUnclaimedIDAlias(t *testing.T) { + app := emptyTestApp(t) + + status, body := testGet(t, app, "/v1/events/unclaimed-id") + assert.Equal(t, 200, status) + + id := gjson.GetBytes(body, "data").String() + assert.NotEmpty(t, id) + + decoded, err := trashid.DecodeHashId(id) + assert.NoError(t, err) + assert.Greater(t, decoded, 0) +} diff --git a/api/v1_users_genre_top_test.go b/api/v1_users_genre_top_test.go new file mode 100644 index 00000000..76b2ef7d --- /dev/null +++ b/api/v1_users_genre_top_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestUsersGenreTop(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "electro1", "name": "Electro 1"}, + {"user_id": 2, "handle": "hiphop1", "name": "HipHop 1"}, + {"user_id": 3, "handle": "electro2", "name": "Electro 2"}, + {"user_id": 4, "handle": "electro-no-tracks", "name": "Electro No Tracks"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "follower_count": 200, "track_count": 5, "dominant_genre": "Electronic"}, + {"user_id": 2, "follower_count": 300, "track_count": 3, "dominant_genre": "Hip-Hop"}, + {"user_id": 3, "follower_count": 100, "track_count": 2, "dominant_genre": "Electronic"}, + {"user_id": 4, "follower_count": 500, "track_count": 0, "dominant_genre": "Electronic"}, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + status, body := testGet(t, app, "/v1/users/genre/top?genre=Electronic") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.id": trashid.MustEncodeHashID(1), + "data.1.id": trashid.MustEncodeHashID(3), + }) +} diff --git a/api/v1_users_muted_test.go b/api/v1_users_muted_test.go new file mode 100644 index 00000000..74f44477 --- /dev/null +++ b/api/v1_users_muted_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestUsersMuted(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "owner", "name": "Owner"}, + {"user_id": 2, "handle": "muted-user", "name": "Muted User"}, + {"user_id": 3, "handle": "unmuted-user", "name": "Unmuted User"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "track_count": 1}, + {"user_id": 2, "track_count": 1}, + {"user_id": 3, "track_count": 1}, + }, + "muted_users": []map[string]any{ + { + "user_id": 1, + "muted_user_id": 2, + "is_delete": false, + "txhash": "tx-mute-active", + "blockhash": "block-mute-active", + }, + { + "user_id": 1, + "muted_user_id": 3, + "is_delete": true, + "txhash": "tx-mute-deleted", + "blockhash": "block-mute-deleted", + }, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + userID := trashid.MustEncodeHashID(1) + status, body := testGet(t, app, "/v1/users/"+userID+"/muted") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.id": trashid.MustEncodeHashID(2), + }) +} diff --git a/api/v1_users_subscribers_test.go b/api/v1_users_subscribers_test.go new file mode 100644 index 00000000..3c8bf897 --- /dev/null +++ b/api/v1_users_subscribers_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestUsersSubscribers(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "artist", "name": "Artist"}, + {"user_id": 2, "handle": "subscriber2", "name": "Subscriber 2"}, + {"user_id": 3, "handle": "subscriber3", "name": "Subscriber 3"}, + {"user_id": 4, "handle": "deletedsub", "name": "Deleted Sub"}, + {"user_id": 5, "handle": "oldsub", "name": "Old Sub"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "track_count": 1}, + {"user_id": 2, "track_count": 1}, + {"user_id": 3, "track_count": 1}, + {"user_id": 4, "track_count": 1}, + {"user_id": 5, "track_count": 1}, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + _, err := app.pool.Exec(t.Context(), ` + INSERT INTO subscriptions (user_id, subscriber_id, is_current, is_delete, txhash) + VALUES + (1, 3, TRUE, FALSE, 'tx-sub-3'), + (1, 2, TRUE, FALSE, 'tx-sub-2'), + (1, 4, TRUE, TRUE, 'tx-sub-deleted'), + (1, 5, FALSE, FALSE, 'tx-sub-not-current') + `) + assert.NoError(t, err) + + artistID := trashid.MustEncodeHashID(1) + status, body := testGet(t, app, "/v1/users/"+artistID+"/subscribers") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.id": trashid.MustEncodeHashID(2), + "data.1.id": trashid.MustEncodeHashID(3), + }) +} diff --git a/api/v1_users_top_test.go b/api/v1_users_top_test.go new file mode 100644 index 00000000..56661b7f --- /dev/null +++ b/api/v1_users_top_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestUsersTop(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "user1", "name": "User 1"}, + {"user_id": 2, "handle": "user2", "name": "User 2"}, + {"user_id": 3, "handle": "user3", "name": "User 3"}, + {"user_id": 4, "handle": "user4", "name": "User 4"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "track_count": 1, "follower_count": 10}, + {"user_id": 2, "track_count": 1, "follower_count": 100}, + {"user_id": 3, "track_count": 2, "follower_count": 100}, + {"user_id": 4, "track_count": 0, "follower_count": 1000}, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + { + status, body := testGet(t, app, "/v1/users/top?limit=2") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.id": trashid.MustEncodeHashID(2), + "data.1.id": trashid.MustEncodeHashID(3), + }) + } + + { + status, body := testGet(t, app, "/v1/users/top?limit=2&offset=1") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.id": trashid.MustEncodeHashID(3), + "data.1.id": trashid.MustEncodeHashID(1), + }) + } +} diff --git a/api/v1_validators_test.go b/api/v1_validators_test.go new file mode 100644 index 00000000..b47da1df --- /dev/null +++ b/api/v1_validators_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "testing" + + "api.audius.co/config" + "github.com/stretchr/testify/assert" +) + +func TestNodesSetGet(t *testing.T) { + nodesStore := NewNodes() + + nodes := []config.Node{ + {Id: "1", Endpoint: "https://validator-1.test"}, + {Id: "2", Endpoint: "https://validator-2.test"}, + } + + nodesStore.SetNodes(nodes) + + got := nodesStore.GetNodes() + assert.Equal(t, nodes, got) + + // Ensure GetNodes returns a copy and not the underlying slice. + got[0].Endpoint = "https://changed.test" + gotAgain := nodesStore.GetNodes() + assert.Equal(t, "https://validator-1.test", gotAgain[0].Endpoint) +} + +func TestV1Validators(t *testing.T) { + app := emptyTestApp(t) + + app.validators.SetNodes([]config.Node{ + {Id: "1", Endpoint: "https://validator-1.test"}, + {Id: "2", Endpoint: "https://validator-2.test"}, + }) + + status, body := testGet(t, app, "/v1/validators") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "nodes.#": 2, + "nodes.0.Endpoint": "https://validator-1.test", + "nodes.1.Endpoint": "https://validator-2.test", + }) +}