diff --git a/api/v1_users_feed.go b/api/v1_users_feed.go index 024f85e6..788bd184 100644 --- a/api/v1_users_feed.go +++ b/api/v1_users_feed.go @@ -50,30 +50,49 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { ), history as ( + -- Track-type reposts. Splitting from playlist-type reposts so each + -- branch can use a per-row JOIN against the entity instead of forcing + -- the planner to hash every public playlist (~94k rows) just to filter + -- a handful of repost rows. ( SELECT - repost_type as entity_type, + 'track' as entity_type, repost_item_id as entity_id, min(reposts.created_at) as created_at FROM reposts JOIN follow_set using (user_id) - LEFT JOIN tracks - ON repost_type = 'track' - AND repost_item_id = track_id + JOIN tracks ON repost_item_id = tracks.track_id AND tracks.is_delete = false AND tracks.is_unlisted = false AND tracks.is_available = true - LEFT JOIN playlists - ON repost_type != 'track' - AND repost_item_id = playlist_id + WHERE + @filter in ('all', 'repost') + AND reposts.repost_type = 'track' + AND reposts.created_at < @before + AND reposts.created_at >= @before - INTERVAL '1 YEAR' + AND reposts.is_delete = false + GROUP BY entity_id + ) + + UNION ALL + + -- Playlist/album-type reposts. + ( + SELECT + reposts.repost_type::text as entity_type, + repost_item_id as entity_id, + min(reposts.created_at) as created_at + FROM reposts + JOIN follow_set using (user_id) + JOIN playlists ON repost_item_id = playlists.playlist_id AND playlists.is_delete = false AND playlists.is_private = false WHERE @filter in ('all', 'repost') + AND reposts.repost_type <> 'track' AND reposts.created_at < @before AND reposts.created_at >= @before - INTERVAL '1 YEAR' AND reposts.is_delete = false - AND (tracks.track_id IS NOT NULL OR playlists.playlist_id IS NOT NULL) GROUP BY entity_type, entity_id ) diff --git a/api/v1_users_feed_test.go b/api/v1_users_feed_test.go new file mode 100644 index 00000000..b81b3c27 --- /dev/null +++ b/api/v1_users_feed_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "testing" + + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +// Regression coverage for the feed query. The query was rewritten to split +// repost handling into separate track-type and playlist-type branches so the +// planner stops building a hash over every public playlist on every call — +// this test guards both branches plus the owned-track and owned-playlist +// branches. +func TestUsersFeed(t *testing.T) { + app := testAppWithFixtures(t) + app.skipAuthCheck = true + + // Seed a playlist repost so Branch 1b (playlist/album reposts) executes + // alongside the existing track repost in RepostFixtures (user 1 → track + // 200). Reposts pkey is (user_id, repost_item_id, repost_type, txhash). + _, err := app.pool.Exec(context.Background(), ` + INSERT INTO reposts (user_id, repost_type, repost_item_id, txhash, blockhash, blocknumber, created_at, is_delete, is_current) + VALUES (3, 'playlist', 1, 'feed-test-tx-1', 'block1', 101, now() - interval '1 hour', false, true) + `) + assert.NoError(t, err) + + // User 2 follows user 1 (track repost path) and user 3 (playlist repost + // path) per fixtures. Feed should surface both. + var resp struct { + Data []struct { + Type string `json:"type"` + } + } + status, body := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(2)+"/feed?limit=50", &resp) + assert.Equal(t, 200, status) + assert.NotEmpty(t, resp.Data, "feed for user 2 (2 followees) should not be empty") + + // Spot-check the items the data contains. + titles := []string{} + for _, m := range gjson.GetBytes(body, "data.#.item.title").Array() { + titles = append(titles, m.String()) + } + playlistNames := []string{} + for _, m := range gjson.GetBytes(body, "data.#.item.playlist_name").Array() { + playlistNames = append(playlistNames, m.String()) + } + + // Track 200 (Culca Canyon) is owned by user 2 themselves but reposted by + // user 1; it should appear via the track-repost branch. + assert.Contains(t, titles, "Culca Canyon", "feed should include track reposted by a followee") + + // Playlist 1 (First) is owned by user 1 and now reposted by user 3; it + // should appear via the playlist-repost branch. + assert.Contains(t, playlistNames, "First", "feed should include playlist reposted by a followee") + + // Sanity: a user with zero followees gets an empty feed. + var empty struct { + Data []any + } + status, _ = testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(99999)+"/feed?limit=10", &empty) + assert.Equal(t, 200, status) + assert.Empty(t, empty.Data) +}