diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 30d14971..42deca44 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -1238,7 +1238,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/events_response" + $ref: "#/components/schemas/remix_contests_response" "400": description: Bad request content: {} @@ -11795,6 +11795,35 @@ components: type: array items: $ref: "#/components/schemas/event" + remix_contests_related: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/user" + tracks: + type: array + items: + $ref: "#/components/schemas/track" + entry_counts: + type: object + additionalProperties: + type: integer + format: int64 + description: + Per-contest entry counts keyed by the contest's parent track id + (hashid). Lets the discovery UI render entry counts without + issuing an extra `/tracks/{id}/remixes?limit=0` per card. + remix_contests_response: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/event" + related: + $ref: "#/components/schemas/remix_contests_related" event_follow_state: required: - is_followed diff --git a/api/v1_events_remix_contests.go b/api/v1_events_remix_contests.go index b816d575..1da935f9 100644 --- a/api/v1_events_remix_contests.go +++ b/api/v1_events_remix_contests.go @@ -4,6 +4,7 @@ import ( "strings" "api.audius.co/api/dbv1" + "api.audius.co/trashid" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" ) @@ -96,11 +97,113 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error { } data := make([]dbv1.FullEvent, 0, len(items)) + trackIDs := make([]int32, 0, len(items)) + userIDSet := map[int32]struct{}{} for _, event := range items { data = append(data, app.queries.ToFullEvent(event)) + if event.EntityType == dbv1.EventEntityTypeTrack && event.EntityID.Valid { + trackIDs = append(trackIDs, event.EntityID.Int32) + } + userIDSet[event.UserID] = struct{}{} + } + + // Load tracks first so we can also resolve each track's owner id into the + // related users list (contest host ≠ track owner isn't common in practice, + // but we don't want the UI to make a second round-trip when it happens). + myID := app.getMyId(c) + authedWallet := app.tryGetAuthedWallet(c) + + var trackMap map[int32]dbv1.Track + if len(trackIDs) > 0 { + trackMap, err = app.queries.TracksKeyed(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIDs, + MyID: myID, + AuthedWallet: authedWallet, + }, + }) + if err != nil { + return err + } + } + for _, t := range trackMap { + userIDSet[t.GetTracksRow.UserID] = struct{}{} + } + + userIDs := make([]int32, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + var userMap map[int32]dbv1.User + if len(userIDs) > 0 { + userMap, err = app.queries.UsersKeyed(c.Context(), dbv1.GetUsersParams{ + Ids: userIDs, + MyID: myID, + }) + if err != nil { + return err + } + } + + users := make([]dbv1.User, 0, len(userMap)) + for _, u := range userMap { + users = append(users, u) + } + tracks := make([]dbv1.Track, 0, len(trackMap)) + for _, t := range trackMap { + tracks = append(tracks, t) + } + + // Per-contest entry counts. Mirrors the filter used in + // v1TrackRemixes when only_contest_entries=true: a remix is an entry iff + // the child track was created *after* the contest started and *before* + // its end_date, and the child track is listed + published. Keyed by the + // contest's parent track id (event.entity_id) so the UI can prime the + // `useRemixes({ trackId, isContestEntry: true })` cache directly. + entryCounts := map[string]int64{} + if len(trackIDs) > 0 { + countRows, err := app.pool.Query(c.Context(), ` + SELECT + e.entity_id, + COUNT(DISTINCT t.track_id) FILTER ( + WHERE t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.created_at > e.created_at + AND (e.end_date IS NULL OR t.created_at < e.end_date) + ) AS entry_count + FROM events e + LEFT JOIN remixes rm ON rm.parent_track_id = e.entity_id + LEFT JOIN tracks t ON t.track_id = rm.child_track_id + WHERE e.event_type = 'remix_contest' + AND e.is_deleted = false + AND e.entity_type = 'track' + AND e.entity_id = ANY(@track_ids) + GROUP BY e.entity_id + `, pgx.NamedArgs{"track_ids": trackIDs}) + if err != nil { + return err + } + defer countRows.Close() + for countRows.Next() { + var parentTrackID int32 + var count int64 + if err := countRows.Scan(&parentTrackID, &count); err != nil { + return err + } + entryCounts[trashid.MustEncodeHashID(int(parentTrackID))] = count + } + if err := countRows.Err(); err != nil { + return err + } } return c.JSON(fiber.Map{ "data": data, + "related": fiber.Map{ + "users": users, + "tracks": tracks, + "entry_counts": entryCounts, + }, }) } diff --git a/api/v1_events_remix_contests_test.go b/api/v1_events_remix_contests_test.go new file mode 100644 index 00000000..cf9c5b41 --- /dev/null +++ b/api/v1_events_remix_contests_test.go @@ -0,0 +1,226 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +func pluckStrings(body []byte, path string) []string { + values := gjson.GetBytes(body, path).Array() + out := make([]string, 0, len(values)) + for _, v := range values { + out = append(out, v.String()) + } + return out +} + +// TestRemixContestsDiscoveryPage exercises the discovery-page endpoint: +// - ordering (active first by soonest end_date, then ended DESC) +// - `related.users` contains contest hosts *and* track owners (when they +// differ — the common case is that hosts own the track, but we don't +// want the UI to round-trip for owners when they don't) +// - `related.tracks` contains the full track objects +// - `related.entry_counts` counts remixes created inside the contest +// window, matching the filter used by GET /tracks/{id}/remixes with +// only_contest_entries=true +// - pagination via limit/offset +// - status filter (active|ended|all) +func TestRemixContestsDiscoveryPage(t *testing.T) { + app := emptyTestApp(t) + + hostID := 9001 + ownerID := 9002 // track owner != contest host, exercises the owner-fold path + remixer1 := 9003 + remixer2 := 9004 + + activeTrackID := 8001 + endedTrackID := 8002 + + activeStart := parseTime(t, "2024-01-02") + activeEnd := parseTime(t, "2099-01-01") // far future => active + endedStart := parseTime(t, "2024-02-02") + endedEnd := parseTime(t, "2024-02-10") + + inWindowCreated := parseTime(t, "2024-01-03") + outsideWindowCreated := parseTime(t, "2024-01-01") // before contest start + + fixtures := database.FixtureMap{ + "events": []map[string]any{ + { + "event_id": 501, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": activeTrackID, + "user_id": hostID, + "created_at": activeStart, + "end_date": activeEnd, + }, + { + "event_id": 502, + "event_type": "remix_contest", + "entity_type": "track", + "entity_id": endedTrackID, + "user_id": hostID, + "created_at": endedStart, + "end_date": endedEnd, + }, + }, + "users": []map[string]any{ + {"user_id": hostID, "handle": "host"}, + {"user_id": ownerID, "handle": "owner"}, + {"user_id": remixer1, "handle": "remixer1"}, + {"user_id": remixer2, "handle": "remixer2"}, + }, + "tracks": []map[string]any{ + { + "track_id": activeTrackID, + "owner_id": ownerID, + "title": "Active Parent", + "created_at": activeStart, + }, + { + "track_id": endedTrackID, + "owner_id": ownerID, + "title": "Ended Parent", + "created_at": endedStart, + }, + // Two valid remix entries for the active contest + { + "track_id": 8101, + "owner_id": remixer1, + "title": "Active Remix In Window A", + "created_at": inWindowCreated, + }, + { + "track_id": 8102, + "owner_id": remixer2, + "title": "Active Remix In Window B", + "created_at": inWindowCreated, + }, + // One remix submitted *before* the contest started — must not + // be counted. + { + "track_id": 8103, + "owner_id": remixer1, + "title": "Active Remix Too Early", + "created_at": outsideWindowCreated, + }, + // One remix for the ended contest. + { + "track_id": 8104, + "owner_id": remixer1, + "title": "Ended Remix", + "created_at": parseTime(t, "2024-02-05"), + }, + }, + "remixes": []map[string]any{ + {"parent_track_id": activeTrackID, "child_track_id": 8101}, + {"parent_track_id": activeTrackID, "child_track_id": 8102}, + {"parent_track_id": activeTrackID, "child_track_id": 8103}, + {"parent_track_id": endedTrackID, "child_track_id": 8104}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + hostHash := trashid.MustEncodeHashID(hostID) + ownerHash := trashid.MustEncodeHashID(ownerID) + activeTrackHash := trashid.MustEncodeHashID(activeTrackID) + endedTrackHash := trashid.MustEncodeHashID(endedTrackID) + activeEventHash := trashid.MustEncodeHashID(501) + endedEventHash := trashid.MustEncodeHashID(502) + + t.Run("default ordering: active before ended", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.event_id": activeEventHash, + "data.0.entity_id": activeTrackHash, + "data.1.event_id": endedEventHash, + "data.1.entity_id": endedTrackHash, + }) + }) + + t.Run("related.tracks contains full track objects", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests") + assert.Equal(t, 200, status) + + // Both parent tracks must be present. Order isn't guaranteed + // (we iterate a Go map), so assert set membership. + jsonAssert(t, body, map[string]any{ + "related.tracks.#": 2, + }) + trackIds := pluckStrings(body,"related.tracks.#.id") + assert.ElementsMatch(t, + []string{activeTrackHash, endedTrackHash}, + trackIds, + ) + }) + + t.Run("related.users contains host AND owner", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests") + assert.Equal(t, 200, status) + + userIds := pluckStrings(body,"related.users.#.id") + assert.Contains(t, userIds, hostHash) + assert.Contains(t, userIds, ownerHash, + "track owner must be folded into related.users even when the contest host differs") + }) + + t.Run("related.entry_counts only counts in-window remixes", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests") + assert.Equal(t, 200, status) + + // Active: 2 in-window entries + 1 pre-window (excluded) = 2 + // Ended: 1 entry created during the window + jsonAssert(t, body, map[string]any{ + "related.entry_counts." + activeTrackHash: float64(2), + "related.entry_counts." + endedTrackHash: float64(1), + }) + }) + + t.Run("status=active filters out ended contests", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests?status=active") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": activeEventHash, + }) + }) + + t.Run("status=ended filters out active contests", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests?status=ended") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": endedEventHash, + }) + }) + + t.Run("pagination: limit=1 returns first page only", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests?limit=1") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": activeEventHash, + }) + }) + + t.Run("pagination: offset=1 skips first page", func(t *testing.T) { + status, body := testGet(t, app, "/v1/events/remix-contests?limit=1&offset=1") + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.event_id": endedEventHash, + }) + }) +}