From ff37760819edc24c0da78c2c150d05d71597e277 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 13 May 2026 09:42:56 -0700 Subject: [PATCH] chore: remove /v1/users/{id}/feed/for-you endpoint The custom For You feed endpoint is being retired in favor of consolidating on /v1/users/{id}/recommended-tracks, which already powers the Explore page's For You section and is in active use. The client is being switched to that endpoint in a companion PR on the apps repo. Removed: * api/v1_users_feed_for_you.go (handler + 200-row candidate-pool SQL with similar_artists self-join that scaled to ~300M rows for power users) * api/v1_users_feed_for_you_test.go (9 unit tests) * Route registration in api/server.go * The /feed/for-you exemption in authMiddleware (added in #804 as a workaround for the auth gate; no longer needed since the route is gone) * Swagger spec entry for the endpoint Co-Authored-By: Claude Opus 4.7 (1M context) --- api/auth_middleware.go | 12 +- api/server.go | 1 - api/swagger/swagger-v1.yaml | 70 ----- api/v1_users_feed_for_you.go | 448 ------------------------------ api/v1_users_feed_for_you_test.go | 421 ---------------------------- 5 files changed, 2 insertions(+), 950 deletions(-) delete mode 100644 api/v1_users_feed_for_you.go delete mode 100644 api/v1_users_feed_for_you_test.go diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 2060f207..05cc3bbe 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -347,16 +347,8 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired access token") } - // Not authorized to act on behalf of myId. - // - // Exception: /users/:userId/feed/for-you accepts user_id as a viewer hint - // used only for response decoration (has_current_user_reposted etc.); the - // path :userId — not user_id — controls what gets personalized. Treat the - // query user_id as advisory rather than authoritative on this route so - // the endpoint can be called like the other public read endpoints. - allowUnauthenticatedViewerId := strings.HasSuffix(c.Path(), "/feed/for-you") - - if myId != 0 && !pkceAuthed && !allowUnauthenticatedViewerId && !app.isAuthorizedRequest(c.Context(), myId, wallet) { + // Not authorized to act on behalf of myId + if myId != 0 && !pkceAuthed && !app.isAuthorizedRequest(c.Context(), myId, wallet) { return fiber.NewError( fiber.StatusForbidden, fmt.Sprintf( diff --git a/api/server.go b/api/server.go index f2ac1b73..474b11d2 100644 --- a/api/server.go +++ b/api/server.go @@ -453,7 +453,6 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/contests", app.v1UserContests) g.Get("/users/:userId/playlists", app.v1UserPlaylists) g.Get("/users/:userId/feed", app.v1UsersFeed) - g.Get("/users/:userId/feed/for-you", app.v1UsersFeedForYou) g.Get("/users/:userId/connected_wallets", app.v1UsersConnectedWallets) g.Get("/users/:userId/transactions/audio", app.v1UsersTransactionsAudio) g.Get("/users/:userId/transactions/audio/count", app.v1UsersTransactionsAudioCount) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 2794660c..7993a739 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -9433,76 +9433,6 @@ paths: "500": description: Server error content: {} - /users/{id}/feed/for-you: - get: - tags: - - users - summary: Get For You feed for user - description: - Returns a personalized For You feed for the user identified in the - path. Twitter-style multi-source pipeline — candidate retrieval - (in-network, trending, underground, similar-artist) → linear - ranking (recency decay × engagement × social affinity, weighted - by source) → diversity (per-artist cap + consecutive-same-artist - lookahead). - operationId: Get User For You Feed - security: - - {} - - OAuth2: - - read - parameters: - - name: id - in: path - description: A User ID - required: true - schema: - type: string - - name: limit - in: query - description: The number of items to fetch - schema: - type: integer - default: 25 - minimum: 1 - maximum: 100 - - name: offset - in: query - description: - The number of items to skip. Useful for pagination (page number - * limit) - schema: - type: integer - default: 0 - minimum: 0 - maximum: 200 - - name: max_per_artist - in: query - description: - Maximum number of tracks per artist on a single page. Used by the - diversity pass to cap consecutive same-artist results. - schema: - type: integer - default: 3 - minimum: 1 - maximum: 10 - - name: user_id - in: query - description: The user ID of the user making the request - schema: - type: string - responses: - "200": - description: Success - content: - application/json: - schema: - $ref: "#/components/schemas/tracks" - "400": - description: Bad request - content: {} - "500": - description: Server error - content: {} /users/{id}/library/albums: get: tags: diff --git a/api/v1_users_feed_for_you.go b/api/v1_users_feed_for_you.go deleted file mode 100644 index 79460080..00000000 --- a/api/v1_users_feed_for_you.go +++ /dev/null @@ -1,448 +0,0 @@ -package api - -import ( - "api.audius.co/api/dbv1" - "github.com/gofiber/fiber/v2" - "github.com/jackc/pgx/v5" -) - -type GetUsersFeedForYouParams struct { - Limit int `query:"limit" default:"25" validate:"min=1,max=100"` - Offset int `query:"offset" default:"0" validate:"min=0,max=200"` - MaxPerArtist int `query:"max_per_artist" default:"3" validate:"min=1,max=10"` -} - -// v1UsersFeedForYou returns a personalized "For You" track feed for the -// user identified in the path. Modeled on Twitter's open-sourced 2023 -// algorithm (the-algorithm-ml). The shape of the pipeline is -// candidate-retrieval → ranking → filtering+diversity, the same -// three-stage pattern Twitter uses on top of a learned "heavy ranker." -// Audius doesn't yet have a trained ranker, so the heavy ranker is -// approximated by a hand-tuned linear blend below; the candidate -// retrieval / diversity passes carry over directly so a learned model -// can drop in later. -// -// 1. CANDIDATE RETRIEVAL (UNION across four sources, each capped): -// - in_network — tracks uploaded in the last 14 days by users I follow. -// Strongest "this is for me" signal. -// - trending — top week-trending from track_trending_scores. -// Mirrors GET /tracks/trending. Capped at 100. -// - underground — week-trending tracks whose owner has < 1500 -// follower & following count (mirrors GET /tracks/trending/underground). -// Capped at 50. -// - similar — recent uploads by artists who are saved by other -// users that also save artists I save. 1-hop collaborative filter on -// the saves graph; capped at 50 artists × recent uploads. -// -// 2. RANKING — three light signals combined linearly: -// -// recency_score = exp(-ln(2) * age_hours / 48) -// // 48h half-life: 48h-old → 0.5, 96h → 0.25 -// engagement_score = ln(1 + 3*saves + 2*reposts + 1*plays) / 12 -// // saves > reposts > plays, log-compressed -// social_boost = 1.0 + min(ln(1 + my_engagement_with_artist) / 4, 1) -// // up to ~2x for artists I already engage with often -// source_weight = {in_network: 1.20, trending: 1.00, -// underground: 0.95, similar: 0.90} -// -// final_score = (0.55 * recency_score + 0.45 * engagement_score) -// * social_boost * source_weight -// -// 3. FILTERS — applied once after the union to keep the candidate set cheap: -// - is_delete / is_unlisted / is_available / stem_of (track liveness) -// - users.is_deactivated / is_available (owner liveness — same shape -// as v1_events_remix_contests.go) -// - access_authorities: caller's wallet must be on the list, else -// ungated only (matches the v1_users_feed authed-wallet pattern) -// - already-saved by the path-param user (don't resurface) -// - the path-param user's own uploads -// -// 4. DIVERSITY — author-cap of N tracks per owner via row_number() -// (configurable via max_per_artist; default 3), then a Go-side greedy -// pass that, when the next track shares an owner with the previously -// emitted one, prefers the next non-same-owner candidate within a -// 5-position lookahead. This is a "consecutive same-artist penalty" -// without paying for a second ranker. -// -// PAGINATION is offset/limit applied on the diversity-ordered list, so -// pages are stable as long as the underlying scores haven't shifted. -// -// Path: -// - id (required): the user being personalized for. Resolved by -// requireUserIdMiddleware; the handler returns the 400 from that -// middleware on an invalid hash id. -// -// Query params: -// - limit (default 25, max 100) -// - offset (default 0, max 200) -// - max_per_artist (default 3, max 10) — author cap per page -// - user_id (optional): the caller's id. Independent of the -// path id — used to populate has_current_user_reposted and similar -// viewer-relative fields on the returned tracks. Same role it plays -// on every other /v1/users/{id}/... endpoint. -func (app *ApiServer) v1UsersFeedForYou(c *fiber.Ctx) error { - var params = GetUsersFeedForYouParams{} - if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { - return err - } - - // Path id — the user we personalize for. Validated by middleware. - userId := app.getUserId(c) - // Optional caller id from ?user_id=, used only for viewer-relative - // track fields on the response shape. - myId := app.getMyId(c) - - authedWallet := app.tryGetAuthedWallet(c) - - // Pull a candidate pool larger than the page size so the diversity - // cap and the consecutive-same-artist pass have headroom to reorder. - const candidatePoolSize = 200 - - sql := ` - WITH - -- Cap to the 500 most-recently-followed users. A power user with - -- thousands of follows pulls a huge hash table here that then has to - -- join against every recent track upload to find in-network candidates, - -- so the planner can stall. Recent follows are a better signal of - -- current taste anyway. - follow_set AS ( - SELECT followee_user_id AS user_id - FROM follows - WHERE follower_user_id = @userId - AND is_current = true - AND is_delete = false - ORDER BY created_at DESC - LIMIT 500 - ), - my_saved_tracks AS ( - SELECT save_item_id AS track_id - FROM saves - WHERE user_id = @userId - AND save_type = 'track' - AND is_current = true - AND is_delete = false - ), - -- The set of artists I save anchors the collaborative-filter join - -- below. For long-tenure power users with thousands of saved artists - -- the saves self-join explodes and the planner times out, so we cap - -- to the 200 most recently saved artists. Recency is the right axis: - -- old saves are weaker signal of current taste anyway, and a 200-artist - -- anchor still gives the similar-artists CTE enough to work with. - my_saved_artists AS ( - SELECT t.owner_id AS artist_id, MAX(s.created_at) AS last_saved_at - FROM saves s - JOIN tracks t ON t.track_id = s.save_item_id - WHERE s.user_id = @userId - AND s.save_type = 'track' - AND s.is_current = true - AND s.is_delete = false - GROUP BY t.owner_id - ORDER BY last_saved_at DESC - LIMIT 200 - ), - -- 1-hop collaborative filter on the saves graph: artists saved by - -- users who *also* save my saved-artists, but who I haven't saved myself. - -- Bounded so the planner can't get adventurous on power-users. - similar_artists AS ( - SELECT t2.owner_id AS artist_id, COUNT(DISTINCT s2.user_id) AS overlap - FROM saves s1 - JOIN tracks t1 ON s1.save_item_id = t1.track_id - JOIN saves s2 ON s2.user_id = s1.user_id - AND s2.save_type = 'track' - AND s2.is_current = true - AND s2.is_delete = false - JOIN tracks t2 ON s2.save_item_id = t2.track_id - WHERE s1.save_type = 'track' - AND s1.is_current = true - AND s1.is_delete = false - AND s1.user_id <> @userId - AND t1.owner_id IN (SELECT artist_id FROM my_saved_artists) - AND t2.owner_id NOT IN (SELECT artist_id FROM my_saved_artists) - GROUP BY t2.owner_id - ORDER BY overlap DESC - LIMIT 50 - ), - -- Per-artist engagement strength (saves + reposts + plays of any of - -- their tracks by me). Used for the social_boost multiplier. - -- - -- Each sub-select is bounded by recency: a heavy listener can have - -- hundreds of thousands of play rows, and the unbounded union forces - -- a full scan of those rows on every request. Recent engagement is - -- the right signal anyway — old listens say less about current taste. - my_artist_affinity AS ( - SELECT t.owner_id AS artist_id, - LN(1 + COUNT(*)) AS affinity - FROM ( - (SELECT save_item_id AS track_id FROM saves - WHERE user_id = @userId AND save_type = 'track' - AND is_current = true AND is_delete = false - ORDER BY created_at DESC - LIMIT 200) - UNION ALL - (SELECT repost_item_id AS track_id FROM reposts - WHERE user_id = @userId AND repost_type = 'track' - AND is_current = true AND is_delete = false - ORDER BY created_at DESC - LIMIT 200) - UNION ALL - (SELECT play_item_id AS track_id FROM plays - WHERE user_id = @userId - ORDER BY created_at DESC - LIMIT 500) - ) eng - JOIN tracks t ON t.track_id = eng.track_id - GROUP BY t.owner_id - ), - -- Source 1: in-network (followed-creator) recent uploads. - -- Bounded so a power-user with thousands of follows doesn't pull a - -- multi-thousand-row pool we'd just throw away after ranking. - cand_in_network AS ( - SELECT t.track_id, 'in_network'::text AS source - FROM tracks t - JOIN follow_set f ON f.user_id = t.owner_id - WHERE t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.is_available = true - AND t.stem_of IS NULL - AND t.created_at >= NOW() - INTERVAL '14 days' - ORDER BY t.created_at DESC - LIMIT 200 - ), - -- Source 2: weekly trending. - cand_trending AS ( - SELECT tts.track_id, 'trending'::text AS source - FROM track_trending_scores tts - JOIN tracks t ON t.track_id = tts.track_id - AND t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.is_available = true - WHERE tts.type = 'TRACKS' - AND tts.version = 'pnagD' - AND tts.time_range = 'week' - AND (tts.genre IS NULL OR tts.genre = '') - ORDER BY tts.score DESC, tts.track_id DESC - LIMIT 100 - ), - -- Source 3: underground trending (sub-1500 follower & following artists). - cand_underground AS ( - SELECT tts.track_id, 'underground'::text AS source - FROM track_trending_scores tts - JOIN tracks t ON t.track_id = tts.track_id - AND t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.is_available = true - JOIN aggregate_user au ON au.user_id = t.owner_id - WHERE tts.type = 'TRACKS' - AND tts.version = 'pnagD' - AND tts.time_range = 'week' - AND (tts.genre IS NULL OR tts.genre = '') - AND au.follower_count < 1500 - AND au.following_count < 1500 - ORDER BY tts.score DESC, tts.track_id DESC - LIMIT 50 - ), - -- Source 4: similar-artist recent uploads. - cand_similar AS ( - SELECT t.track_id, 'similar'::text AS source - FROM tracks t - JOIN similar_artists sa ON sa.artist_id = t.owner_id - WHERE t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.is_available = true - AND t.stem_of IS NULL - AND t.created_at >= NOW() - INTERVAL '60 days' - ORDER BY sa.overlap DESC, t.created_at DESC - LIMIT 100 - ), - -- One row per track_id. DISTINCT ON keeps the strongest (lowest-prio) - -- source so an in-network track that's also trending keeps the - -- in_network weight rather than the lower trending weight. - candidates AS ( - SELECT DISTINCT ON (track_id) track_id, source - FROM ( - SELECT track_id, source, 1 AS prio FROM cand_in_network - UNION ALL - SELECT track_id, source, 2 AS prio FROM cand_trending - UNION ALL - SELECT track_id, source, 3 AS prio FROM cand_underground - UNION ALL - SELECT track_id, source, 4 AS prio FROM cand_similar - ) u - ORDER BY track_id, prio - ), - filtered AS ( - SELECT - c.track_id, - c.source, - t.owner_id, - t.created_at, - COALESCE(at.save_count, 0) AS save_count, - COALESCE(at.repost_count, 0) AS repost_count, - COALESCE(ap.count, 0) AS play_count, - COALESCE(maa.affinity, 0) AS affinity - FROM candidates c - JOIN tracks t ON t.track_id = c.track_id - JOIN users u ON u.user_id = t.owner_id - LEFT JOIN aggregate_track at ON at.track_id = c.track_id - LEFT JOIN aggregate_plays ap ON ap.play_item_id = c.track_id - LEFT JOIN my_artist_affinity maa ON maa.artist_id = t.owner_id - WHERE t.is_current = true - AND t.is_delete = false - AND t.is_unlisted = false - AND t.is_available = true - AND t.stem_of IS NULL - -- Owner liveness — pattern from v1_events_remix_contests.go - AND u.is_current = true - AND u.is_deactivated = false - AND u.is_available = true - -- Access-gating: ungated, or caller's wallet is on the list - AND (t.access_authorities IS NULL - OR (COALESCE(@authed_wallet, '') <> '' - AND EXISTS ( - SELECT 1 FROM unnest(t.access_authorities) aa - WHERE lower(aa) = lower(@authed_wallet) - ))) - -- Don't resurface tracks the caller already saved - AND NOT EXISTS ( - SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = c.track_id - ) - -- Don't recommend the caller's own uploads - AND t.owner_id <> @userId - ), - scored AS ( - SELECT - f.track_id, - f.owner_id, - -- 48h half-life on age in hours - EXP(-LN(2) * GREATEST(EXTRACT(EPOCH FROM (NOW() - f.created_at)) / 3600.0, 0) / 48.0) - AS recency_score, - -- saves > reposts > plays, log-compressed and normalized - LN(1 + 3 * f.save_count + 2 * f.repost_count + f.play_count) / 12.0 - AS engagement_score, - -- 1.0 baseline, up to ~2x for high-affinity artists - 1.0 + LEAST(f.affinity / 4.0, 1.0) AS social_boost, - CASE f.source - WHEN 'in_network' THEN 1.20 - WHEN 'trending' THEN 1.00 - WHEN 'underground' THEN 0.95 - WHEN 'similar' THEN 0.90 - ELSE 1.00 - END AS source_weight - FROM filtered f - ), - final_scored AS ( - SELECT - track_id, - owner_id, - (0.55 * recency_score + 0.45 * engagement_score) - * social_boost * source_weight AS score - FROM scored - ), - -- Hard cap: max 3 tracks per artist before paginating. - capped AS ( - SELECT track_id, owner_id, score, - ROW_NUMBER() OVER (PARTITION BY owner_id ORDER BY score DESC, track_id DESC) - AS rn_artist - FROM final_scored - ) - SELECT track_id, owner_id - FROM capped - WHERE rn_artist <= @maxPerArtist - ORDER BY score DESC, track_id DESC - LIMIT @poolSize - ` - - rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ - "userId": userId, - "poolSize": candidatePoolSize, - "maxPerArtist": params.MaxPerArtist, - "authed_wallet": authedWallet, - }) - if err != nil { - return err - } - - type ranked struct { - TrackID int32 - OwnerID int32 - } - pool, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ranked]) - if err != nil { - return err - } - - // Greedy diversity pass: keep the global rank order, but if the next - // track shares an owner with the one just emitted, prefer the next - // non-same-owner candidate within a small lookahead. Soft penalty on - // consecutive-same-artist runs without computing a second ranker. - const lookahead = 5 - ordered := make([]ranked, 0, len(pool)) - used := make([]bool, len(pool)) - var lastOwner int32 = -1 - for n := 0; n < len(pool); n++ { - pickIdx := -1 - for i := 0; i < len(pool) && i < n+lookahead+1; i++ { - if used[i] { - continue - } - if pickIdx == -1 { - pickIdx = i - } - if pool[i].OwnerID != lastOwner { - pickIdx = i - break - } - } - if pickIdx == -1 { - break - } - used[pickIdx] = true - ordered = append(ordered, pool[pickIdx]) - lastOwner = pool[pickIdx].OwnerID - } - - // Apply pagination on the diversity-ordered list. - start := params.Offset - if start > len(ordered) { - start = len(ordered) - } - end := start + params.Limit - if end > len(ordered) { - end = len(ordered) - } - page := ordered[start:end] - - trackIds := make([]int32, len(page)) - for i, r := range page { - trackIds[i] = r.TrackID - } - - tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ - GetTracksParams: dbv1.GetTracksParams{ - Ids: trackIds, - MyID: myId, - AuthedWallet: authedWallet, - }, - }) - if err != nil { - return err - } - - // Tracks() returns rows in id order; re-emit in our ranked order. - byId := make(map[int32]dbv1.Track, len(tracks)) - for _, t := range tracks { - byId[t.TrackID] = t - } - sorted := make([]dbv1.Track, 0, len(trackIds)) - for _, id := range trackIds { - if t, ok := byId[id]; ok { - sorted = append(sorted, t) - } - } - - return v1TracksResponse(c, sorted) -} diff --git a/api/v1_users_feed_for_you_test.go b/api/v1_users_feed_for_you_test.go deleted file mode 100644 index b67c1607..00000000 --- a/api/v1_users_feed_for_you_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package api - -import ( - "fmt" - "testing" - "time" - - "api.audius.co/api/dbv1" - "api.audius.co/database" - "api.audius.co/trashid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// feedForYouFixtures builds a small graph that exercises every candidate -// source and every filter the For You feed cares about. -// -// user 1 = me (the viewer) -// user 2 = an artist I follow -> in-network candidates -// user 3 = an artist I do NOT follow -> trending candidate -// user 4 = an underground artist -> underground candidate -// user 5 = a "similar" artist -> CF similar candidate (1-hop) -// user 6 = a peer who shares a save -> CF bridge user -// user 7 = a deactivated artist -> filter test -func feedForYouFixtures() database.FixtureMap { - now := time.Now() - hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } - users := []map[string]any{ - {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, - {"user_id": 2, "handle": "followed", "handle_lc": "followed", "wallet": "0x0000000000000000000000000000000000000002"}, - {"user_id": 3, "handle": "trending_artist", "handle_lc": "trending_artist", "wallet": "0x0000000000000000000000000000000000000003"}, - {"user_id": 4, "handle": "underground", "handle_lc": "underground", "wallet": "0x0000000000000000000000000000000000000004"}, - {"user_id": 5, "handle": "similar", "handle_lc": "similar", "wallet": "0x0000000000000000000000000000000000000005"}, - {"user_id": 6, "handle": "peer", "handle_lc": "peer", "wallet": "0x0000000000000000000000000000000000000006"}, - {"user_id": 7, "handle": "deactivated", "handle_lc": "deactivated", "wallet": "0x0000000000000000000000000000000000000007", "is_deactivated": true}, - {"user_id": 8, "handle": "saved_artist", "handle_lc": "saved_artist", "wallet": "0x0000000000000000000000000000000000000008"}, - } - - aggregateUser := []map[string]any{ - {"user_id": 1, "follower_count": 0, "following_count": 1}, - {"user_id": 2, "follower_count": 100, "following_count": 10}, - {"user_id": 3, "follower_count": 5000, "following_count": 200}, - // Underground artist: must be < 1500 on both counts. - {"user_id": 4, "follower_count": 100, "following_count": 50}, - {"user_id": 5, "follower_count": 200, "following_count": 100}, - {"user_id": 6, "follower_count": 50, "following_count": 50}, - {"user_id": 7, "follower_count": 0, "following_count": 0}, - {"user_id": 8, "follower_count": 200, "following_count": 50}, - } - - tracks := []map[string]any{ - // In-network: two recent tracks by user 2 (the artist I follow). - {"track_id": 101, "owner_id": 2, "title": "in-network 1", "created_at": hoursAgo(2)}, - {"track_id": 102, "owner_id": 2, "title": "in-network 2", "created_at": hoursAgo(10)}, - // Trending track by user 3 (in track_trending_scores). - {"track_id": 201, "owner_id": 3, "title": "trending", "created_at": hoursAgo(72)}, - // Underground track by user 4 (also has trending score). - {"track_id": 301, "owner_id": 4, "title": "underground", "created_at": hoursAgo(50)}, - // Similar-artist candidate: track by user 5. - {"track_id": 501, "owner_id": 5, "title": "similar artist", "created_at": hoursAgo(30)}, - // Saved artist's track that I already saved -> must be filtered out. - {"track_id": 801, "owner_id": 8, "title": "already saved", "created_at": hoursAgo(100)}, - // Track by deactivated user -> filtered. - {"track_id": 701, "owner_id": 7, "title": "deactivated artist track", "created_at": hoursAgo(2)}, - // Track that is unlisted -> filtered. - {"track_id": 901, "owner_id": 2, "title": "unlisted", "created_at": hoursAgo(2), "is_unlisted": true}, - // Track that is deleted -> filtered. - {"track_id": 902, "owner_id": 2, "title": "deleted", "created_at": hoursAgo(2), "is_delete": true}, - // User 6's track (referenced by saves to drive CF; not a candidate - // because user 6 is in my_saved_artists indirectly only via user 8). - } - - follows := []map[string]any{ - // I follow user 2. - {"follower_user_id": 1, "followee_user_id": 2}, - } - - saves := []map[string]any{ - // I have already saved track 801 (by user 8, my favorite artist). - {"user_id": 1, "save_item_id": 801, "save_type": "track"}, - // User 6 saved 801 too (overlap with me on user 8). - {"user_id": 6, "save_item_id": 801, "save_type": "track"}, - // User 6 also saved a track by user 5 -> drives "similar artist". - {"user_id": 6, "save_item_id": 501, "save_type": "track"}, - } - - trackTrendingScores := []map[string]any{ - // Trending leader. - {"track_id": 201, "score": 9_000_000_000, "time_range": "week"}, - // Underground (also trending but artist is sub-1500). - {"track_id": 301, "score": 5_000_000_000, "time_range": "week"}, - } - - aggregateTrack := []map[string]any{ - {"track_id": 101, "save_count": 5, "repost_count": 2}, - {"track_id": 102, "save_count": 1, "repost_count": 0}, - {"track_id": 201, "save_count": 100, "repost_count": 50}, - {"track_id": 301, "save_count": 30, "repost_count": 10}, - {"track_id": 501, "save_count": 10, "repost_count": 5}, - } - - return database.FixtureMap{ - "users": users, - "aggregate_user": aggregateUser, - "tracks": tracks, - "follows": follows, - "saves": saves, - "track_trending_scores": trackTrendingScores, - "aggregate_track": aggregateTrack, - } -} - -func TestV1FeedForYou_Basic(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - database.Seed(app.pool.Replicas[0], feedForYouFixtures()) - - var response struct { - Data []dbv1.Track - } - path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) - - // We should see candidates from at least the in-network and trending - // sources, and we should NOT see any of the filtered tracks. - gotIDs := map[int32]bool{} - for _, tr := range response.Data { - gotIDs[tr.TrackID] = true - } - - // Filtered: already saved, deactivated, unlisted, deleted. - for _, banned := range []int32{801, 701, 901, 902} { - assert.Falsef(t, gotIDs[banned], "track %d should be filtered out", banned) - } - - // At least one in-network track should appear. - assert.True(t, gotIDs[101] || gotIDs[102], "expected an in-network track in results, got %v", gotIDs) - - // Trending track should appear (it's a wide-net candidate). - assert.True(t, gotIDs[201], "expected trending track in results, got %v", gotIDs) -} - -func TestV1FeedForYou_RequiresValidUserId(t *testing.T) { - app := emptyTestApp(t) - // Invalid hash id in the path → 400 from requireUserIdMiddleware. - status, _ := testGet(t, app, "/v1/users/not-a-real-id/feed/for-you") - assert.Equal(t, 400, status) -} - -// /feed/for-you is exempt from authMiddleware's "if user_id is set, wallet -// must match" 403 — the query user_id is a viewer hint, not an authorization -// claim. This test pins that exemption: with skipAuthCheck OFF (so the real -// auth path runs) and no signature headers, the call still returns 200. -func TestV1FeedForYou_UnauthenticatedViewerIdAllowed(t *testing.T) { - app := emptyTestApp(t) - // Deliberately NOT setting app.skipAuthCheck — exercise the real - // authMiddleware exemption added for this route. - database.Seed(app.pool.Replicas[0], feedForYouFixtures()) - - var response struct { - Data []dbv1.Track - } - encodedId := trashid.MustEncodeHashID(1) - path := "/v1/users/" + encodedId + "/feed/for-you?user_id=" + encodedId - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) -} - -func TestV1FeedForYou_ExcludesAlreadySavedTracks(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - database.Seed(app.pool.Replicas[0], feedForYouFixtures()) - - var response struct { - Data []dbv1.Track - } - path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) - - for _, tr := range response.Data { - assert.NotEqual(t, int32(801), tr.TrackID, "already-saved track should be excluded") - } -} - -func TestV1FeedForYou_MaxThreePerArtist(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - - // Build a user (the artist) who has many recent in-network tracks. The - // feed should cap them at 3 per page. - now := time.Now() - hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } - users := []map[string]any{ - {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, - {"user_id": 2, "handle": "prolific", "handle_lc": "prolific", "wallet": "0x0000000000000000000000000000000000000002"}, - } - tracks := make([]map[string]any, 10) - for i := range tracks { - tracks[i] = map[string]any{ - "track_id": 1000 + i, - "owner_id": 2, - "title": fmt.Sprintf("prolific %d", i), - "created_at": hoursAgo(i + 1), - } - } - follows := []map[string]any{{"follower_user_id": 1, "followee_user_id": 2}} - database.Seed(app.pool.Replicas[0], database.FixtureMap{ - "users": users, - "tracks": tracks, - "follows": follows, - }) - - var response struct { - Data []dbv1.Track - } - path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=20" - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) - - count := 0 - for _, tr := range response.Data { - if tr.UserID == trashid.HashId(2) { - count++ - } - } - assert.LessOrEqual(t, count, 3, "expected at most 3 tracks per artist, got %d", count) -} - -func TestV1FeedForYou_DiversityPassNoConsecutiveSameArtist(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - - now := time.Now() - hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } - - // Two artists I follow, each with several recent tracks. The diversity - // pass should interleave them so no two consecutive tracks share an - // artist (when an alternative is available within the lookahead window). - users := []map[string]any{ - {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, - {"user_id": 2, "handle": "a1", "handle_lc": "a1", "wallet": "0x0000000000000000000000000000000000000002"}, - {"user_id": 3, "handle": "a2", "handle_lc": "a2", "wallet": "0x0000000000000000000000000000000000000003"}, - } - tracks := []map[string]any{ - {"track_id": 100, "owner_id": 2, "title": "a1-1", "created_at": hoursAgo(1)}, - {"track_id": 101, "owner_id": 2, "title": "a1-2", "created_at": hoursAgo(2)}, - {"track_id": 102, "owner_id": 2, "title": "a1-3", "created_at": hoursAgo(3)}, - {"track_id": 200, "owner_id": 3, "title": "a2-1", "created_at": hoursAgo(4)}, - {"track_id": 201, "owner_id": 3, "title": "a2-2", "created_at": hoursAgo(5)}, - {"track_id": 202, "owner_id": 3, "title": "a2-3", "created_at": hoursAgo(6)}, - } - follows := []map[string]any{ - {"follower_user_id": 1, "followee_user_id": 2}, - {"follower_user_id": 1, "followee_user_id": 3}, - } - database.Seed(app.pool.Replicas[0], database.FixtureMap{ - "users": users, - "tracks": tracks, - "follows": follows, - }) - - var response struct { - Data []dbv1.Track - } - path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) - require.GreaterOrEqual(t, len(response.Data), 4, "want at least 4 tracks to assert interleave") - - // Walk the response: for each pair, if there's any other artist in the - // rest of the list, we should not have two same-artist tracks adjacent. - for i := 1; i < len(response.Data); i++ { - prev := response.Data[i-1].UserID - cur := response.Data[i].UserID - if prev == cur { - // Only fail if a different-artist track existed elsewhere in the - // page that could have been swapped in. - otherExists := false - for j := i; j < len(response.Data); j++ { - if response.Data[j].UserID != prev { - otherExists = true - break - } - } - if otherExists { - t.Fatalf("adjacent same-artist tracks at pos %d/%d (artist %v); other artist available later in page", i-1, i, prev) - } - } - } -} - -func TestV1FeedForYou_PaginationDoesNotRepeat(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - database.Seed(app.pool.Replicas[0], feedForYouFixtures()) - - page := func(limit, offset int) []int32 { - var resp struct { - Data []dbv1.Track - } - path := fmt.Sprintf("/v1/users/%s/feed/for-you?limit=%d&offset=%d", - trashid.MustEncodeHashID(1), limit, offset) - status, body := testGet(t, app, path, &resp) - require.Equal(t, 200, status, string(body)) - ids := make([]int32, len(resp.Data)) - for i, tr := range resp.Data { - ids[i] = tr.TrackID - } - return ids - } - - first := page(2, 0) - second := page(2, 2) - - seen := map[int32]bool{} - for _, id := range first { - seen[id] = true - } - for _, id := range second { - assert.Falsef(t, seen[id], "track %d appeared on both pages", id) - } -} - -func TestV1FeedForYou_InvalidParams(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - - for _, val := range []string{"-1", "101", "invalid"} { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/feed/for-you?limit="+val) - assert.Equal(t, 400, status, "limit=%s", val) - } - for _, val := range []string{"-1", "invalid"} { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/feed/for-you?offset="+val) - assert.Equal(t, 400, status, "offset=%s", val) - } -} - -// TestV1FeedForYou_RecencyAndEngagementRanking isolates the ranking -// signals: all three artists are in-network (same source weight) and the -// caller has no prior engagement with any of them (uniform social_boost), -// so the only thing differentiating ranks is recency × engagement. -// -// track A — fresh, no engagement → mid-rank -// track B — old, high engagement → low-rank (recency decay) -// track C — fresh + high engagement → top-rank -func TestV1FeedForYou_RecencyAndEngagementRanking(t *testing.T) { - app := emptyTestApp(t) - app.skipAuthCheck = true - - const ( - me = 200 - artistA = 201 - artistB = 202 - artistC = 203 - ) - now := time.Now() - - users := []map[string]any{ - {"user_id": me, "handle": "ranker_me", "handle_lc": "ranker_me", - "wallet": "0x0000000000000000000000000000000000000200"}, - {"user_id": artistA, "handle": "artist_a", "handle_lc": "artist_a", - "wallet": "0x0000000000000000000000000000000000000201"}, - {"user_id": artistB, "handle": "artist_b", "handle_lc": "artist_b", - "wallet": "0x0000000000000000000000000000000000000202"}, - {"user_id": artistC, "handle": "artist_c", "handle_lc": "artist_c", - "wallet": "0x0000000000000000000000000000000000000203"}, - } - aggregateUser := []map[string]any{ - {"user_id": artistA, "follower_count": 100, "following_count": 50}, - {"user_id": artistB, "follower_count": 100, "following_count": 50}, - {"user_id": artistC, "follower_count": 100, "following_count": 50}, - } - tracks := []map[string]any{ - {"track_id": 2001, "owner_id": artistA, "title": "fresh, low engagement", - "created_at": now.Add(-1 * time.Hour)}, - {"track_id": 2002, "owner_id": artistB, "title": "old, high engagement", - "created_at": now.Add(-13 * 24 * time.Hour)}, // ~13d, still inside the 14d in-network window - {"track_id": 2003, "owner_id": artistC, "title": "fresh, high engagement", - "created_at": now.Add(-2 * time.Hour)}, - } - aggregateTrack := []map[string]any{ - {"track_id": 2001, "save_count": 0, "repost_count": 0}, - {"track_id": 2002, "save_count": 5_000, "repost_count": 1_000}, - {"track_id": 2003, "save_count": 5_000, "repost_count": 1_000}, - } - follows := []map[string]any{ - {"follower_user_id": me, "followee_user_id": artistA}, - {"follower_user_id": me, "followee_user_id": artistB}, - {"follower_user_id": me, "followee_user_id": artistC}, - } - database.Seed(app.pool.Replicas[0], database.FixtureMap{ - "users": users, - "aggregate_user": aggregateUser, - "tracks": tracks, - "aggregate_track": aggregateTrack, - "follows": follows, - }) - - var response struct { - Data []dbv1.Track - } - path := "/v1/users/" + trashid.MustEncodeHashID(me) + "/feed/for-you" - status, body := testGet(t, app, path, &response) - require.Equal(t, 200, status, string(body)) - require.GreaterOrEqual(t, len(response.Data), 3, "expected all three candidates in response") - - idx := func(id int32) int { - for i, tr := range response.Data { - if tr.TrackID == id { - return i - } - } - return -1 - } - assert.Less(t, idx(2003), idx(2001), - "fresh+engaged 2003 must outrank low-engagement 2001") - assert.Less(t, idx(2003), idx(2002), - "fresh+engaged 2003 must outrank old 2002 (recency decay)") -}