Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions api/v1_events_remix_contests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
},
})
}
226 changes: 226 additions & 0 deletions api/v1_events_remix_contests_test.go
Original file line number Diff line number Diff line change
@@ -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,
})
})
}
Loading