@@ -227,28 +227,31 @@ func TestRemixContestsDiscoveryPage(t *testing.T) {
227227 })
228228}
229229
230- // TestRemixContestsSortPriority covers the multi-tier sort:
231- // 1. Featured-audience-account contests come first.
232- // 2. Then contests with at least one entry.
233- // 3. Ended contests with zero entries land at the bottom.
230+ // TestRemixContestsSortPriority covers the four-group sort:
231+ // 1. Open + Audius-followed host.
232+ // 2. Open + unfollowed host.
233+ // 3. Ended + Audius-followed host.
234+ // 4. Ended + unfollowed host.
234235//
235- // Within each group the existing active-first / soonest-ending sort still
236- // applies — we don't reassert that here because TestRemixContestsDiscoveryPage
237- // already covers it.
236+ // Within each group, contests sort by entry_count DESC.
238237func TestRemixContestsSortPriority (t * testing.T ) {
239238 app := emptyTestApp (t )
240239
241- featuredHostID := 9101 // contests by this user must sort first
242- regularHostID := 9102
243- ownerID := 9103
240+ audiusUserID := 9100 // the "Audius" account whose follows reorder each open/ended group
241+ followedHostID := 9101
242+ unfollowedHostID := 9102
244243 remixerID := 9104
245244
246- // Track ids for each contest's parent track.
247- featuredEndedZeroTrackID := 8201 // featured + ended + zero entries → still group 1
248- hasEntriesActiveTrackID := 8202 // group 2 (has entries)
249- hasEntriesEndedTrackID := 8203 // group 2 (has entries, ended)
250- activeZeroTrackID := 8204 // group 3 (active, no entries — neither featured nor has-entries nor ended-empty)
251- endedZeroTrackID := 8205 // group 4 (ended + zero entries) — must be LAST
245+ // Two contests per group so we can also exercise the entry_count DESC
246+ // tiebreak inside each group.
247+ openFollowedHighEntriesTrackID := 8201 // group 1, 2 entries
248+ openFollowedLowEntriesTrackID := 8202 // group 1, 0 entries
249+ openUnfollowedHighTrackID := 8203 // group 2, 2 entries
250+ openUnfollowedLowTrackID := 8204 // group 2, 0 entries
251+ endedFollowedHighTrackID := 8205 // group 3, 2 entries
252+ endedFollowedLowTrackID := 8206 // group 3, 0 entries
253+ endedUnfollowedHighTrackID := 8207 // group 4, 2 entries
254+ endedUnfollowedLowTrackID := 8208 // group 4, 0 entries
252255
253256 farFuture := parseTime (t , "2099-01-01" )
254257 farPast := parseTime (t , "2024-02-10" )
@@ -259,104 +262,234 @@ func TestRemixContestsSortPriority(t *testing.T) {
259262 "events" : []map [string ]any {
260263 {
261264 "event_id" : 601 , "event_type" : "remix_contest" , "entity_type" : "track" ,
262- "entity_id" : featuredEndedZeroTrackID , "user_id" : featuredHostID ,
263- "created_at" : contestStart , "end_date" : farPast ,
265+ "entity_id" : openFollowedHighEntriesTrackID , "user_id" : followedHostID ,
266+ "created_at" : contestStart , "end_date" : farFuture ,
264267 },
265268 {
266269 "event_id" : 602 , "event_type" : "remix_contest" , "entity_type" : "track" ,
267- "entity_id" : hasEntriesActiveTrackID , "user_id" : regularHostID ,
270+ "entity_id" : openFollowedLowEntriesTrackID , "user_id" : followedHostID ,
268271 "created_at" : contestStart , "end_date" : farFuture ,
269272 },
270273 {
271274 "event_id" : 603 , "event_type" : "remix_contest" , "entity_type" : "track" ,
272- "entity_id" : hasEntriesEndedTrackID , "user_id" : regularHostID ,
273- "created_at" : contestStart , "end_date" : farPast ,
275+ "entity_id" : openUnfollowedHighTrackID , "user_id" : unfollowedHostID ,
276+ "created_at" : contestStart , "end_date" : farFuture ,
274277 },
275278 {
276279 "event_id" : 604 , "event_type" : "remix_contest" , "entity_type" : "track" ,
277- "entity_id" : activeZeroTrackID , "user_id" : regularHostID ,
280+ "entity_id" : openUnfollowedLowTrackID , "user_id" : unfollowedHostID ,
278281 "created_at" : contestStart , "end_date" : farFuture ,
279282 },
280283 {
281284 "event_id" : 605 , "event_type" : "remix_contest" , "entity_type" : "track" ,
282- "entity_id" : endedZeroTrackID , "user_id" : regularHostID ,
285+ "entity_id" : endedFollowedHighTrackID , "user_id" : followedHostID ,
286+ "created_at" : contestStart , "end_date" : farPast ,
287+ },
288+ {
289+ "event_id" : 606 , "event_type" : "remix_contest" , "entity_type" : "track" ,
290+ "entity_id" : endedFollowedLowTrackID , "user_id" : followedHostID ,
291+ "created_at" : contestStart , "end_date" : farPast ,
292+ },
293+ {
294+ "event_id" : 607 , "event_type" : "remix_contest" , "entity_type" : "track" ,
295+ "entity_id" : endedUnfollowedHighTrackID , "user_id" : unfollowedHostID ,
296+ "created_at" : contestStart , "end_date" : farPast ,
297+ },
298+ {
299+ "event_id" : 608 , "event_type" : "remix_contest" , "entity_type" : "track" ,
300+ "entity_id" : endedUnfollowedLowTrackID , "user_id" : unfollowedHostID ,
283301 "created_at" : contestStart , "end_date" : farPast ,
284302 },
285303 },
286304 "users" : []map [string ]any {
287- {"user_id" : featuredHostID , "handle" : "featured " },
288- {"user_id" : regularHostID , "handle" : "regular " },
289- {"user_id" : ownerID , "handle" : "owner " },
305+ {"user_id" : audiusUserID , "handle" : "audius " },
306+ {"user_id" : followedHostID , "handle" : "followed " },
307+ {"user_id" : unfollowedHostID , "handle" : "unfollowed " },
290308 {"user_id" : remixerID , "handle" : "remixer" },
291309 },
310+ "follows" : []map [string ]any {
311+ {"follower_user_id" : audiusUserID , "followee_user_id" : followedHostID },
312+ },
292313 "tracks" : []map [string ]any {
293- {"track_id" : featuredEndedZeroTrackID , "owner_id" : featuredHostID , "created_at" : contestStart },
294- {"track_id" : hasEntriesActiveTrackID , "owner_id" : regularHostID , "created_at" : contestStart },
295- {"track_id" : hasEntriesEndedTrackID , "owner_id" : regularHostID , "created_at" : contestStart },
296- {"track_id" : activeZeroTrackID , "owner_id" : regularHostID , "created_at" : contestStart },
297- {"track_id" : endedZeroTrackID , "owner_id" : regularHostID , "created_at" : contestStart },
298- // Entries — only for the two has-entries contests.
299- {"track_id" : 8302 , "owner_id" : remixerID , "created_at" : inWindow },
300- {"track_id" : 8303 , "owner_id" : remixerID , "created_at" : inWindow },
314+ {"track_id" : openFollowedHighEntriesTrackID , "owner_id" : followedHostID , "created_at" : contestStart },
315+ {"track_id" : openFollowedLowEntriesTrackID , "owner_id" : followedHostID , "created_at" : contestStart },
316+ {"track_id" : openUnfollowedHighTrackID , "owner_id" : unfollowedHostID , "created_at" : contestStart },
317+ {"track_id" : openUnfollowedLowTrackID , "owner_id" : unfollowedHostID , "created_at" : contestStart },
318+ {"track_id" : endedFollowedHighTrackID , "owner_id" : followedHostID , "created_at" : contestStart },
319+ {"track_id" : endedFollowedLowTrackID , "owner_id" : followedHostID , "created_at" : contestStart },
320+ {"track_id" : endedUnfollowedHighTrackID , "owner_id" : unfollowedHostID , "created_at" : contestStart },
321+ {"track_id" : endedUnfollowedLowTrackID , "owner_id" : unfollowedHostID , "created_at" : contestStart },
322+ // Two entries each for the "high" contest in every group.
323+ {"track_id" : 8311 , "owner_id" : remixerID , "created_at" : inWindow },
324+ {"track_id" : 8312 , "owner_id" : remixerID , "created_at" : inWindow },
325+ {"track_id" : 8313 , "owner_id" : remixerID , "created_at" : inWindow },
326+ {"track_id" : 8314 , "owner_id" : remixerID , "created_at" : inWindow },
327+ {"track_id" : 8315 , "owner_id" : remixerID , "created_at" : inWindow },
328+ {"track_id" : 8316 , "owner_id" : remixerID , "created_at" : inWindow },
329+ {"track_id" : 8317 , "owner_id" : remixerID , "created_at" : inWindow },
330+ {"track_id" : 8318 , "owner_id" : remixerID , "created_at" : inWindow },
301331 },
302332 "remixes" : []map [string ]any {
303- {"parent_track_id" : hasEntriesActiveTrackID , "child_track_id" : 8302 },
304- {"parent_track_id" : hasEntriesEndedTrackID , "child_track_id" : 8303 },
333+ {"parent_track_id" : openFollowedHighEntriesTrackID , "child_track_id" : 8311 },
334+ {"parent_track_id" : openFollowedHighEntriesTrackID , "child_track_id" : 8312 },
335+ {"parent_track_id" : openUnfollowedHighTrackID , "child_track_id" : 8313 },
336+ {"parent_track_id" : openUnfollowedHighTrackID , "child_track_id" : 8314 },
337+ {"parent_track_id" : endedFollowedHighTrackID , "child_track_id" : 8315 },
338+ {"parent_track_id" : endedFollowedHighTrackID , "child_track_id" : 8316 },
339+ {"parent_track_id" : endedUnfollowedHighTrackID , "child_track_id" : 8317 },
340+ {"parent_track_id" : endedUnfollowedHighTrackID , "child_track_id" : 8318 },
305341 },
306342 }
307343 database .Seed (app .pool .Replicas [0 ], fixtures )
308344
309- featuredEvent := trashid .MustEncodeHashID (601 )
310- hasActiveEvent := trashid .MustEncodeHashID (602 )
311- hasEndedEvent := trashid .MustEncodeHashID (603 )
312- activeZeroEvent := trashid .MustEncodeHashID (604 )
313- endedZeroEvent := trashid .MustEncodeHashID (605 )
345+ openFollowedHigh := trashid .MustEncodeHashID (601 )
346+ openFollowedLow := trashid .MustEncodeHashID (602 )
347+ openUnfollowedHigh := trashid .MustEncodeHashID (603 )
348+ openUnfollowedLow := trashid .MustEncodeHashID (604 )
349+ endedFollowedHigh := trashid .MustEncodeHashID (605 )
350+ endedFollowedLow := trashid .MustEncodeHashID (606 )
351+ endedUnfollowedHigh := trashid .MustEncodeHashID (607 )
352+ endedUnfollowedLow := trashid .MustEncodeHashID (608 )
314353
315- t .Run ("featured account contests sort first, ended-zero- entries last " , func (t * testing.T ) {
354+ t .Run ("open-vs-ended × followed-vs-unfollowed, entries desc within group " , func (t * testing.T ) {
316355 prev := config .Cfg .FeaturedAudienceUserID
317- config .Cfg .FeaturedAudienceUserID = int32 (featuredHostID )
356+ config .Cfg .FeaturedAudienceUserID = int32 (audiusUserID )
318357 t .Cleanup (func () { config .Cfg .FeaturedAudienceUserID = prev })
319358
320359 status , body := testGet (t , app , "/v1/events/remix-contests" )
321360 assert .Equal (t , 200 , status )
322361
323362 jsonAssert (t , body , map [string ]any {
324- "data.#" : 5 ,
325- "data.0.event_id" : featuredEvent , // featured (group 1)
326- "data.1.event_id" : hasActiveEvent , // has entries, active (group 2)
327- "data.2.event_id" : hasEndedEvent , // has entries, ended (group 2)
328- "data.3.event_id" : activeZeroEvent , // active, zero entries (group 3 — not ended-empty)
329- "data.4.event_id" : endedZeroEvent , // ended + zero entries (group 4, LAST)
363+ "data.#" : 8 ,
364+ "data.0.event_id" : openFollowedHigh , // group 1, 2 entries
365+ "data.1.event_id" : openFollowedLow , // group 1, 0 entries
366+ "data.2.event_id" : openUnfollowedHigh , // group 2, 2 entries
367+ "data.3.event_id" : openUnfollowedLow , // group 2, 0 entries
368+ "data.4.event_id" : endedFollowedHigh , // group 3, 2 entries
369+ "data.5.event_id" : endedFollowedLow , // group 3, 0 entries
370+ "data.6.event_id" : endedUnfollowedHigh , // group 4, 2 entries
371+ "data.7.event_id" : endedUnfollowedLow , // group 4, 0 entries
330372 })
331373 })
332374
333- t .Run ("with featured user unset, featured contest falls back to entry-based sort " , func (t * testing.T ) {
375+ t .Run ("with audius user unset, only the open-vs-ended split applies " , func (t * testing.T ) {
334376 prev := config .Cfg .FeaturedAudienceUserID
335377 config .Cfg .FeaturedAudienceUserID = 0
336378 t .Cleanup (func () { config .Cfg .FeaturedAudienceUserID = prev })
337379
338380 status , body := testGet (t , app , "/v1/events/remix-contests" )
339381 assert .Equal (t , 200 , status )
340382
341- // featuredEvent is now ended-with-zero-entries, so it should sort
342- // alongside endedZeroEvent at the bottom (group 4). The two has-entries
343- // contests are group 2, activeZeroEvent is group 3 .
383+ // Without the Audius user, the follow-based tiebreak collapses. The
384+ // remaining order is: open (4 rows) → ended (4 rows), each block sorted
385+ // by entry_count DESC then event_id ASC .
344386 jsonAssert (t , body , map [string ]any {
345- "data.#" : 5 ,
346- "data.0.event_id" : hasActiveEvent ,
347- "data.1 .event_id" : hasEndedEvent ,
348- "data.2 .event_id" : activeZeroEvent ,
349- })
350- // Last two entries are both ended-zero- entries — order within is
351- // determined by end_date DESC then event_id; both events share end_date
352- // (farPast), so the smaller event_id (601) comes first.
353- jsonAssert ( t , body , map [ string ] any {
354- "data.3 .event_id" : featuredEvent ,
355- "data.4 .event_id" : endedZeroEvent ,
387+ "data.#" : 8 ,
388+ // Open contests with 2 entries (601, 603), then with 0 (602, 604).
389+ "data.0 .event_id" : openFollowedHigh , // 601, 2 entries
390+ "data.1 .event_id" : openUnfollowedHigh , // 603, 2 entries
391+ "data.2.event_id" : openFollowedLow , // 602, 0 entries
392+ "data.3.event_id" : openUnfollowedLow , // 604, 0 entries
393+ // Ended contests with 2 entries (605, 607), then with 0 (606, 608).
394+ "data.4.event_id" : endedFollowedHigh , // 605
395+ "data.5.event_id" : endedUnfollowedHigh , // 607
396+ "data.6 .event_id" : endedFollowedLow , // 606
397+ "data.7 .event_id" : endedUnfollowedLow , // 608
356398 })
357399 })
358400}
359401
402+ // TestRemixContestsFollowFilterIgnoresStaleRows verifies that the
403+ // follow-based tiebreak only counts live `follows` rows. A soft-deleted
404+ // (is_delete=true) or non-current (is_current=false) follow must not promote
405+ // a host into the "followed" sub-group.
406+ func TestRemixContestsFollowFilterIgnoresStaleRows (t * testing.T ) {
407+ app := emptyTestApp (t )
408+
409+ audiusUserID := 9200
410+ liveFollowHostID := 9201
411+ deletedFollowHostID := 9202
412+ staleFollowHostID := 9203
413+ remixerID := 9210
414+
415+ liveTrackID := 8401
416+ deletedTrackID := 8402
417+ staleTrackID := 8403
418+
419+ farFuture := parseTime (t , "2099-01-01" )
420+ contestStart := parseTime (t , "2024-01-02" )
421+ inWindow := parseTime (t , "2024-01-03" )
422+
423+ fixtures := database.FixtureMap {
424+ "events" : []map [string ]any {
425+ // All three contests are open with the same entry_count (1), so the
426+ // only differentiator left is the follow tiebreak.
427+ {
428+ "event_id" : 701 , "event_type" : "remix_contest" , "entity_type" : "track" ,
429+ "entity_id" : liveTrackID , "user_id" : liveFollowHostID ,
430+ "created_at" : contestStart , "end_date" : farFuture ,
431+ },
432+ {
433+ "event_id" : 702 , "event_type" : "remix_contest" , "entity_type" : "track" ,
434+ "entity_id" : deletedTrackID , "user_id" : deletedFollowHostID ,
435+ "created_at" : contestStart , "end_date" : farFuture ,
436+ },
437+ {
438+ "event_id" : 703 , "event_type" : "remix_contest" , "entity_type" : "track" ,
439+ "entity_id" : staleTrackID , "user_id" : staleFollowHostID ,
440+ "created_at" : contestStart , "end_date" : farFuture ,
441+ },
442+ },
443+ "users" : []map [string ]any {
444+ {"user_id" : audiusUserID , "handle" : "audius" },
445+ {"user_id" : liveFollowHostID , "handle" : "live_follow" },
446+ {"user_id" : deletedFollowHostID , "handle" : "deleted_follow" },
447+ {"user_id" : staleFollowHostID , "handle" : "stale_follow" },
448+ {"user_id" : remixerID , "handle" : "remixer" },
449+ },
450+ "follows" : []map [string ]any {
451+ {"follower_user_id" : audiusUserID , "followee_user_id" : liveFollowHostID },
452+ {"follower_user_id" : audiusUserID , "followee_user_id" : deletedFollowHostID , "is_delete" : true },
453+ {"follower_user_id" : audiusUserID , "followee_user_id" : staleFollowHostID , "is_current" : false },
454+ },
455+ "tracks" : []map [string ]any {
456+ {"track_id" : liveTrackID , "owner_id" : liveFollowHostID , "created_at" : contestStart },
457+ {"track_id" : deletedTrackID , "owner_id" : deletedFollowHostID , "created_at" : contestStart },
458+ {"track_id" : staleTrackID , "owner_id" : staleFollowHostID , "created_at" : contestStart },
459+ // One entry per contest so all three reach the same entry_count.
460+ {"track_id" : 8411 , "owner_id" : remixerID , "created_at" : inWindow },
461+ {"track_id" : 8412 , "owner_id" : remixerID , "created_at" : inWindow },
462+ {"track_id" : 8413 , "owner_id" : remixerID , "created_at" : inWindow },
463+ },
464+ "remixes" : []map [string ]any {
465+ {"parent_track_id" : liveTrackID , "child_track_id" : 8411 },
466+ {"parent_track_id" : deletedTrackID , "child_track_id" : 8412 },
467+ {"parent_track_id" : staleTrackID , "child_track_id" : 8413 },
468+ },
469+ }
470+ database .Seed (app .pool .Replicas [0 ], fixtures )
471+
472+ liveEvent := trashid .MustEncodeHashID (701 )
473+ deletedEvent := trashid .MustEncodeHashID (702 )
474+ staleEvent := trashid .MustEncodeHashID (703 )
475+
476+ prev := config .Cfg .FeaturedAudienceUserID
477+ config .Cfg .FeaturedAudienceUserID = int32 (audiusUserID )
478+ t .Cleanup (func () { config .Cfg .FeaturedAudienceUserID = prev })
479+
480+ status , body := testGet (t , app , "/v1/events/remix-contests" )
481+ assert .Equal (t , 200 , status )
482+
483+ // liveFollowHost should be the only one promoted to the followed sub-group.
484+ // The other two are unfollowed and break by event_id ASC (702 < 703).
485+ jsonAssert (t , body , map [string ]any {
486+ "data.#" : 3 ,
487+ "data.0.event_id" : liveEvent , // 701 — only live follow row
488+ "data.1.event_id" : deletedEvent , // 702 — follow soft-deleted, treated as unfollowed
489+ "data.2.event_id" : staleEvent , // 703 — follow not current, treated as unfollowed
490+ })
491+ }
492+
360493// TestRemixContestsExcludesUnavailableContent covers server-side filtering
361494// of contests whose track or host is not in a publishable state. The
362495// frontend used to drop these on the client (the "deleted accounts surface
0 commit comments