|
| 1 | +package api |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "testing" |
| 6 | + "time" |
| 7 | + |
| 8 | + "api.audius.co/database" |
| 9 | + "github.com/stretchr/testify/assert" |
| 10 | + "github.com/stretchr/testify/require" |
| 11 | +) |
| 12 | + |
| 13 | +// TestRemixContestUpdate_NotifiesEventSubscribers exercises the |
| 14 | +// handle_comment_remix_contest_update trigger. When the contest host posts |
| 15 | +// a top-level (non-reply) comment on their own remix-contest event, every |
| 16 | +// active event subscriber (excluding the host) should receive one |
| 17 | +// `remix_contest_update` notification row. |
| 18 | +func TestRemixContestUpdate_NotifiesEventSubscribers(t *testing.T) { |
| 19 | + app := emptyTestApp(t) |
| 20 | + ctx := context.Background() |
| 21 | + require.NotNil(t, app.writePool, "test requires write pool") |
| 22 | + |
| 23 | + hostId := 7101 |
| 24 | + subA := 7102 |
| 25 | + subB := 7103 |
| 26 | + parentTrackId := 7201 |
| 27 | + eventId := 7301 |
| 28 | + commentId := 7401 |
| 29 | + |
| 30 | + now := time.Now().UTC() |
| 31 | + fixtures := database.FixtureMap{ |
| 32 | + // comments has an FK on blocknumber → blocks.number; the comment we |
| 33 | + // insert later uses blocknumber=100, so seed it. |
| 34 | + "blocks": []map[string]any{ |
| 35 | + {"blockhash": "rcu-blk-100", "parenthash": nil, "number": 100}, |
| 36 | + }, |
| 37 | + "users": []map[string]any{ |
| 38 | + {"user_id": hostId, "handle": "rc_host"}, |
| 39 | + {"user_id": subA, "handle": "rc_subA"}, |
| 40 | + {"user_id": subB, "handle": "rc_subB"}, |
| 41 | + }, |
| 42 | + "tracks": []map[string]any{ |
| 43 | + { |
| 44 | + "track_id": parentTrackId, |
| 45 | + "owner_id": hostId, |
| 46 | + "title": "Parent Track", |
| 47 | + "created_at": now, |
| 48 | + "updated_at": now, |
| 49 | + }, |
| 50 | + }, |
| 51 | + "events": []map[string]any{ |
| 52 | + { |
| 53 | + "event_id": eventId, |
| 54 | + "event_type": "remix_contest", |
| 55 | + "entity_id": parentTrackId, |
| 56 | + "user_id": hostId, |
| 57 | + "created_at": now, |
| 58 | + "end_date": now.Add(7 * 24 * time.Hour), |
| 59 | + }, |
| 60 | + }, |
| 61 | + "subscriptions": []map[string]any{ |
| 62 | + // Both subA and subB subscribe to the event. Host also "subscribes" |
| 63 | + // to their own event (which should still be excluded by the trigger). |
| 64 | + { |
| 65 | + "subscriber_id": subA, |
| 66 | + "user_id": eventId, |
| 67 | + "entity_type": "Event", |
| 68 | + "entity_id": eventId, |
| 69 | + "is_current": true, |
| 70 | + "is_delete": false, |
| 71 | + "created_at": now, |
| 72 | + "txhash": "seed-subA", |
| 73 | + }, |
| 74 | + { |
| 75 | + "subscriber_id": subB, |
| 76 | + "user_id": eventId, |
| 77 | + "entity_type": "Event", |
| 78 | + "entity_id": eventId, |
| 79 | + "is_current": true, |
| 80 | + "is_delete": false, |
| 81 | + "created_at": now, |
| 82 | + "txhash": "seed-subB", |
| 83 | + }, |
| 84 | + { |
| 85 | + "subscriber_id": hostId, |
| 86 | + "user_id": eventId, |
| 87 | + "entity_type": "Event", |
| 88 | + "entity_id": eventId, |
| 89 | + "is_current": true, |
| 90 | + "is_delete": false, |
| 91 | + "created_at": now, |
| 92 | + "txhash": "seed-subhost", |
| 93 | + }, |
| 94 | + }, |
| 95 | + } |
| 96 | + database.Seed(app.pool.Replicas[0], fixtures) |
| 97 | + |
| 98 | + // Host posts a TOP-LEVEL comment on their own event. Single auto-commit |
| 99 | + // statement → deferred trigger fires at the commit → comment_threads is |
| 100 | + // empty for this comment_id → treated as top-level → fans out. |
| 101 | + _, err := app.writePool.Exec(ctx, ` |
| 102 | + INSERT INTO comments ( |
| 103 | + comment_id, user_id, entity_id, entity_type, |
| 104 | + text, is_delete, is_visible, is_edited, |
| 105 | + created_at, updated_at, |
| 106 | + txhash, blockhash, blocknumber |
| 107 | + ) VALUES ( |
| 108 | + $1, $2, $3, 'Event', |
| 109 | + 'Round 1 is live!', false, true, false, |
| 110 | + $4, $4, |
| 111 | + 'tx-rcu-1', 'blk-rcu-1', 100 |
| 112 | + ) |
| 113 | + `, commentId, hostId, eventId, now) |
| 114 | + require.NoError(t, err) |
| 115 | + |
| 116 | + // Each non-host subscriber should now have exactly one |
| 117 | + // remix_contest_update notification. |
| 118 | + type notifRow struct { |
| 119 | + Specifier string |
| 120 | + GroupId string |
| 121 | + UserIds []int32 |
| 122 | + EventId int |
| 123 | + EntityId int |
| 124 | + EntityUserId int |
| 125 | + CommentId int |
| 126 | + } |
| 127 | + rows, err := app.writePool.Query(ctx, ` |
| 128 | + SELECT specifier, group_id, user_ids, |
| 129 | + (data->>'event_id')::int, |
| 130 | + (data->>'entity_id')::int, |
| 131 | + (data->>'entity_user_id')::int, |
| 132 | + (data->>'comment_id')::int |
| 133 | + FROM notification |
| 134 | + WHERE type = 'remix_contest_update' |
| 135 | + AND group_id = $1 |
| 136 | + ORDER BY specifier ASC |
| 137 | + `, "remix_contest_update:7401:event:7301") |
| 138 | + require.NoError(t, err) |
| 139 | + defer rows.Close() |
| 140 | + |
| 141 | + var got []notifRow |
| 142 | + for rows.Next() { |
| 143 | + var r notifRow |
| 144 | + require.NoError(t, rows.Scan(&r.Specifier, &r.GroupId, &r.UserIds, |
| 145 | + &r.EventId, &r.EntityId, &r.EntityUserId, &r.CommentId)) |
| 146 | + got = append(got, r) |
| 147 | + } |
| 148 | + require.NoError(t, rows.Err()) |
| 149 | + |
| 150 | + require.Len(t, got, 2, "expected exactly one notification per non-host subscriber") |
| 151 | + |
| 152 | + recipients := map[int32]bool{} |
| 153 | + for _, r := range got { |
| 154 | + assert.Equal(t, eventId, r.EventId) |
| 155 | + assert.Equal(t, parentTrackId, r.EntityId, "entity_id = the contest's parent track") |
| 156 | + assert.Equal(t, hostId, r.EntityUserId, "entity_user_id = the event host") |
| 157 | + assert.Equal(t, commentId, r.CommentId) |
| 158 | + require.Len(t, r.UserIds, 1) |
| 159 | + recipients[r.UserIds[0]] = true |
| 160 | + } |
| 161 | + assert.True(t, recipients[int32(subA)], "subA must receive the notification") |
| 162 | + assert.True(t, recipients[int32(subB)], "subB must receive the notification") |
| 163 | + assert.False(t, recipients[int32(hostId)], "host must NOT receive a notification for their own post") |
| 164 | +} |
| 165 | + |
| 166 | +// TestRemixContestUpdate_SkipsReplies verifies that a HOST reply (a |
| 167 | +// comment with a comment_threads row inserted in the same transaction) |
| 168 | +// does NOT trigger remix_contest_update. Only top-level posts do. |
| 169 | +func TestRemixContestUpdate_SkipsReplies(t *testing.T) { |
| 170 | + app := emptyTestApp(t) |
| 171 | + ctx := context.Background() |
| 172 | + require.NotNil(t, app.writePool, "test requires write pool") |
| 173 | + |
| 174 | + hostId := 7501 |
| 175 | + subId := 7502 |
| 176 | + parentTrackId := 7601 |
| 177 | + eventId := 7701 |
| 178 | + parentCommentId := 7801 |
| 179 | + replyCommentId := 7802 |
| 180 | + |
| 181 | + now := time.Now().UTC() |
| 182 | + fixtures := database.FixtureMap{ |
| 183 | + // comments has an FK on blocknumber → blocks.number; both the seeded |
| 184 | + // parent comment (blocknumber=99) and the reply we insert later |
| 185 | + // (blocknumber=100) need their blocks rows. |
| 186 | + "blocks": []map[string]any{ |
| 187 | + {"blockhash": "rcu-blk-99", "parenthash": nil, "number": 99}, |
| 188 | + {"blockhash": "rcu-blk-100", "parenthash": "rcu-blk-99", "number": 100}, |
| 189 | + }, |
| 190 | + "users": []map[string]any{ |
| 191 | + {"user_id": hostId, "handle": "rcr_host"}, |
| 192 | + {"user_id": subId, "handle": "rcr_sub"}, |
| 193 | + }, |
| 194 | + "tracks": []map[string]any{ |
| 195 | + {"track_id": parentTrackId, "owner_id": hostId, "title": "Parent", |
| 196 | + "created_at": now, "updated_at": now}, |
| 197 | + }, |
| 198 | + "events": []map[string]any{ |
| 199 | + {"event_id": eventId, "event_type": "remix_contest", |
| 200 | + "entity_id": parentTrackId, "user_id": hostId, |
| 201 | + "created_at": now, "end_date": now.Add(7 * 24 * time.Hour)}, |
| 202 | + }, |
| 203 | + "subscriptions": []map[string]any{ |
| 204 | + {"subscriber_id": subId, "user_id": eventId, "entity_type": "Event", |
| 205 | + "entity_id": eventId, "is_current": true, "is_delete": false, |
| 206 | + "created_at": now, "txhash": "seed-sub-r"}, |
| 207 | + }, |
| 208 | + // Seed an existing top-level host comment to be the reply's parent. |
| 209 | + "comments": []map[string]any{ |
| 210 | + {"comment_id": parentCommentId, "user_id": hostId, |
| 211 | + "entity_id": eventId, "entity_type": "Event", |
| 212 | + "text": "opener", "is_delete": false, "is_visible": true, |
| 213 | + "created_at": now, "updated_at": now, |
| 214 | + "txhash": "tx-parent", "blockhash": "rcu-blk-99", "blocknumber": 99}, |
| 215 | + }, |
| 216 | + } |
| 217 | + database.Seed(app.pool.Replicas[0], fixtures) |
| 218 | + |
| 219 | + // We need the comment INSERT + comment_threads INSERT in the same tx so |
| 220 | + // the deferred trigger sees the thread row at commit time. |
| 221 | + tx, err := app.writePool.Begin(ctx) |
| 222 | + require.NoError(t, err) |
| 223 | + _, err = tx.Exec(ctx, ` |
| 224 | + INSERT INTO comments ( |
| 225 | + comment_id, user_id, entity_id, entity_type, |
| 226 | + text, is_delete, is_visible, is_edited, |
| 227 | + created_at, updated_at, |
| 228 | + txhash, blockhash, blocknumber |
| 229 | + ) VALUES ( |
| 230 | + $1, $2, $3, 'Event', |
| 231 | + 'my reply', false, true, false, |
| 232 | + $4, $4, |
| 233 | + 'tx-reply', 'blk-reply', 100 |
| 234 | + ) |
| 235 | + `, replyCommentId, hostId, eventId, now) |
| 236 | + require.NoError(t, err) |
| 237 | + _, err = tx.Exec(ctx, ` |
| 238 | + INSERT INTO comment_threads (parent_comment_id, comment_id) |
| 239 | + VALUES ($1, $2) |
| 240 | + `, parentCommentId, replyCommentId) |
| 241 | + require.NoError(t, err) |
| 242 | + require.NoError(t, tx.Commit(ctx)) |
| 243 | + |
| 244 | + // No remix_contest_update notification should exist for the reply. |
| 245 | + var n int |
| 246 | + err = app.writePool.QueryRow(ctx, ` |
| 247 | + SELECT count(*) FROM notification |
| 248 | + WHERE type = 'remix_contest_update' |
| 249 | + AND group_id = $1 |
| 250 | + `, "remix_contest_update:7802:event:7701").Scan(&n) |
| 251 | + require.NoError(t, err) |
| 252 | + assert.Equal(t, 0, n, "host replies must NOT trigger remix_contest_update") |
| 253 | +} |
| 254 | + |
| 255 | +// TestRemixContestUpdate_SkipsNonHostComments verifies that a NON-host |
| 256 | +// commenter on the event does not trigger the notification, even if |
| 257 | +// they're commenting top-level. |
| 258 | +func TestRemixContestUpdate_SkipsNonHostComments(t *testing.T) { |
| 259 | + app := emptyTestApp(t) |
| 260 | + ctx := context.Background() |
| 261 | + require.NotNil(t, app.writePool, "test requires write pool") |
| 262 | + |
| 263 | + hostId := 7901 |
| 264 | + commenterId := 7902 |
| 265 | + subId := 7903 |
| 266 | + parentTrackId := 7801 |
| 267 | + eventId := 7811 |
| 268 | + commentId := 7821 |
| 269 | + |
| 270 | + now := time.Now().UTC() |
| 271 | + fixtures := database.FixtureMap{ |
| 272 | + // comments has an FK on blocknumber → blocks.number. |
| 273 | + "blocks": []map[string]any{ |
| 274 | + {"blockhash": "rcu-blk-100", "parenthash": nil, "number": 100}, |
| 275 | + }, |
| 276 | + "users": []map[string]any{ |
| 277 | + {"user_id": hostId, "handle": "rcn_host"}, |
| 278 | + {"user_id": commenterId, "handle": "rcn_commenter"}, |
| 279 | + {"user_id": subId, "handle": "rcn_sub"}, |
| 280 | + }, |
| 281 | + "tracks": []map[string]any{ |
| 282 | + {"track_id": parentTrackId, "owner_id": hostId, "title": "Parent", |
| 283 | + "created_at": now, "updated_at": now}, |
| 284 | + }, |
| 285 | + "events": []map[string]any{ |
| 286 | + {"event_id": eventId, "event_type": "remix_contest", |
| 287 | + "entity_id": parentTrackId, "user_id": hostId, |
| 288 | + "created_at": now, "end_date": now.Add(7 * 24 * time.Hour)}, |
| 289 | + }, |
| 290 | + "subscriptions": []map[string]any{ |
| 291 | + {"subscriber_id": subId, "user_id": eventId, "entity_type": "Event", |
| 292 | + "entity_id": eventId, "is_current": true, "is_delete": false, |
| 293 | + "created_at": now, "txhash": "seed-sub-nh"}, |
| 294 | + }, |
| 295 | + } |
| 296 | + database.Seed(app.pool.Replicas[0], fixtures) |
| 297 | + |
| 298 | + // Non-host posts a top-level comment on the event. |
| 299 | + _, err := app.writePool.Exec(ctx, ` |
| 300 | + INSERT INTO comments ( |
| 301 | + comment_id, user_id, entity_id, entity_type, |
| 302 | + text, is_delete, is_visible, is_edited, |
| 303 | + created_at, updated_at, |
| 304 | + txhash, blockhash, blocknumber |
| 305 | + ) VALUES ( |
| 306 | + $1, $2, $3, 'Event', |
| 307 | + 'I hope I win!', false, true, false, |
| 308 | + $4, $4, |
| 309 | + 'tx-nh', 'blk-nh', 100 |
| 310 | + ) |
| 311 | + `, commentId, commenterId, eventId, now) |
| 312 | + require.NoError(t, err) |
| 313 | + |
| 314 | + var n int |
| 315 | + err = app.writePool.QueryRow(ctx, ` |
| 316 | + SELECT count(*) FROM notification WHERE type = 'remix_contest_update' |
| 317 | + `).Scan(&n) |
| 318 | + require.NoError(t, err) |
| 319 | + assert.Equal(t, 0, n, "non-host comments must NOT trigger remix_contest_update") |
| 320 | +} |
0 commit comments