···
4
+
"Coves/internal/api/handlers/communityFeed"
5
+
"Coves/internal/core/communities"
6
+
"Coves/internal/core/communityFeeds"
7
+
"Coves/internal/db/postgres"
17
+
"github.com/stretchr/testify/assert"
18
+
"github.com/stretchr/testify/require"
21
+
// TestGetCommunityFeed_Hot tests hot feed sorting algorithm
22
+
func TestGetCommunityFeed_Hot(t *testing.T) {
23
+
if testing.Short() {
24
+
t.Skip("Skipping integration test in short mode")
27
+
db := setupTestDB(t)
28
+
t.Cleanup(func() { _ = db.Close() })
31
+
feedRepo := postgres.NewCommunityFeedRepository(db)
32
+
communityRepo := postgres.NewCommunityRepository(db)
33
+
communityService := communities.NewCommunityService(
35
+
"http://localhost:3001",
36
+
"did:web:test.coves.social",
37
+
"test.coves.social",
40
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
41
+
handler := communityFeed.NewGetCommunityHandler(feedService)
43
+
// Setup test data: community, users, and posts
44
+
ctx := context.Background()
45
+
testID := time.Now().UnixNano()
46
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID))
47
+
require.NoError(t, err)
49
+
// Create posts with different scores and ages
50
+
// Post 1: Recent with medium score (should rank high in "hot")
51
+
post1URI := createTestPost(t, db, communityDID, "did:plc:alice", "Recent trending post", 50, time.Now().Add(-1*time.Hour))
53
+
// Post 2: Old with high score (hot algorithm should penalize age)
54
+
post2URI := createTestPost(t, db, communityDID, "did:plc:bob", "Old popular post", 100, time.Now().Add(-24*time.Hour))
56
+
// Post 3: Very recent with low score
57
+
post3URI := createTestPost(t, db, communityDID, "did:plc:charlie", "Brand new post", 5, time.Now().Add(-10*time.Minute))
60
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=10", communityDID), nil)
61
+
rec := httptest.NewRecorder()
62
+
handler.HandleGetCommunity(rec, req)
65
+
assert.Equal(t, http.StatusOK, rec.Code)
67
+
var response communityFeeds.FeedResponse
68
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
69
+
require.NoError(t, err)
71
+
assert.Len(t, response.Feed, 3)
73
+
// Verify hot ranking: recent + medium score should beat old + high score
74
+
// (exact order depends on hot algorithm, but we can verify posts exist)
75
+
uris := []string{response.Feed[0].Post.URI, response.Feed[1].Post.URI, response.Feed[2].Post.URI}
76
+
assert.Contains(t, uris, post1URI)
77
+
assert.Contains(t, uris, post2URI)
78
+
assert.Contains(t, uris, post3URI)
80
+
// Verify Record field is populated (schema compliance)
81
+
for i, feedPost := range response.Feed {
82
+
assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
83
+
record, ok := feedPost.Post.Record.(map[string]interface{})
84
+
require.True(t, ok, "Record should be a map")
85
+
assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type")
86
+
assert.NotEmpty(t, record["community"], "Record should have community")
87
+
assert.NotEmpty(t, record["author"], "Record should have author")
88
+
assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
92
+
// TestGetCommunityFeed_Top_WithTimeframe tests top sorting with time filters
93
+
func TestGetCommunityFeed_Top_WithTimeframe(t *testing.T) {
94
+
if testing.Short() {
95
+
t.Skip("Skipping integration test in short mode")
98
+
db := setupTestDB(t)
99
+
t.Cleanup(func() { _ = db.Close() })
102
+
feedRepo := postgres.NewCommunityFeedRepository(db)
103
+
communityRepo := postgres.NewCommunityRepository(db)
104
+
communityService := communities.NewCommunityService(
106
+
"http://localhost:3001",
107
+
"did:web:test.coves.social",
108
+
"test.coves.social",
111
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
112
+
handler := communityFeed.NewGetCommunityHandler(feedService)
115
+
ctx := context.Background()
116
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", time.Now().UnixNano()), fmt.Sprintf("bob.test-%d", time.Now().UnixNano()))
117
+
require.NoError(t, err)
119
+
// Create posts at different times
120
+
// Post 1: 2 hours ago, score 100
121
+
createTestPost(t, db, communityDID, "did:plc:alice", "2 hours old", 100, time.Now().Add(-2*time.Hour))
123
+
// Post 2: 2 days ago, score 200 (should be filtered out by "day" timeframe)
124
+
createTestPost(t, db, communityDID, "did:plc:bob", "2 days old", 200, time.Now().Add(-48*time.Hour))
126
+
// Post 3: 30 minutes ago, score 50
127
+
createTestPost(t, db, communityDID, "did:plc:charlie", "30 minutes old", 50, time.Now().Add(-30*time.Minute))
129
+
t.Run("Top posts from last day", func(t *testing.T) {
130
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=top&timeframe=day&limit=10", communityDID), nil)
131
+
rec := httptest.NewRecorder()
132
+
handler.HandleGetCommunity(rec, req)
134
+
assert.Equal(t, http.StatusOK, rec.Code)
136
+
var response communityFeeds.FeedResponse
137
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
138
+
require.NoError(t, err)
140
+
// Should only return 2 posts (within last day)
141
+
assert.Len(t, response.Feed, 2)
143
+
// Verify top-ranked post (highest score)
144
+
assert.Equal(t, "2 hours old", *response.Feed[0].Post.Title)
145
+
assert.Equal(t, 100, response.Feed[0].Post.Stats.Score)
148
+
t.Run("Top posts from all time", func(t *testing.T) {
149
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=top&timeframe=all&limit=10", communityDID), nil)
150
+
rec := httptest.NewRecorder()
151
+
handler.HandleGetCommunity(rec, req)
153
+
assert.Equal(t, http.StatusOK, rec.Code)
155
+
var response communityFeeds.FeedResponse
156
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
157
+
require.NoError(t, err)
159
+
// Should return all 3 posts
160
+
assert.Len(t, response.Feed, 3)
162
+
// Highest score should be first
163
+
assert.Equal(t, "2 days old", *response.Feed[0].Post.Title)
164
+
assert.Equal(t, 200, response.Feed[0].Post.Stats.Score)
168
+
// TestGetCommunityFeed_New tests chronological sorting
169
+
func TestGetCommunityFeed_New(t *testing.T) {
170
+
if testing.Short() {
171
+
t.Skip("Skipping integration test in short mode")
174
+
db := setupTestDB(t)
175
+
t.Cleanup(func() { _ = db.Close() })
178
+
feedRepo := postgres.NewCommunityFeedRepository(db)
179
+
communityRepo := postgres.NewCommunityRepository(db)
180
+
communityService := communities.NewCommunityService(
182
+
"http://localhost:3001",
183
+
"did:web:test.coves.social",
184
+
"test.coves.social",
187
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
188
+
handler := communityFeed.NewGetCommunityHandler(feedService)
191
+
ctx := context.Background()
192
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("news-%d", time.Now().UnixNano()), fmt.Sprintf("charlie.test-%d", time.Now().UnixNano()))
193
+
require.NoError(t, err)
195
+
// Create posts in specific order (older first)
196
+
time1 := time.Now().Add(-3 * time.Hour)
197
+
time2 := time.Now().Add(-2 * time.Hour)
198
+
time3 := time.Now().Add(-1 * time.Hour)
200
+
createTestPost(t, db, communityDID, "did:plc:alice", "Oldest post", 10, time1)
201
+
createTestPost(t, db, communityDID, "did:plc:bob", "Middle post", 100, time2) // High score, but not newest
202
+
createTestPost(t, db, communityDID, "did:plc:charlie", "Newest post", 1, time3)
204
+
// Request new feed
205
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10", communityDID), nil)
206
+
rec := httptest.NewRecorder()
207
+
handler.HandleGetCommunity(rec, req)
210
+
assert.Equal(t, http.StatusOK, rec.Code)
212
+
var response communityFeeds.FeedResponse
213
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
214
+
require.NoError(t, err)
216
+
assert.Len(t, response.Feed, 3)
218
+
// Verify chronological order (newest first)
219
+
assert.Equal(t, "Newest post", *response.Feed[0].Post.Title)
220
+
assert.Equal(t, "Middle post", *response.Feed[1].Post.Title)
221
+
assert.Equal(t, "Oldest post", *response.Feed[2].Post.Title)
224
+
// TestGetCommunityFeed_Pagination tests cursor-based pagination
225
+
func TestGetCommunityFeed_Pagination(t *testing.T) {
226
+
if testing.Short() {
227
+
t.Skip("Skipping integration test in short mode")
230
+
db := setupTestDB(t)
231
+
t.Cleanup(func() { _ = db.Close() })
234
+
feedRepo := postgres.NewCommunityFeedRepository(db)
235
+
communityRepo := postgres.NewCommunityRepository(db)
236
+
communityService := communities.NewCommunityService(
238
+
"http://localhost:3001",
239
+
"did:web:test.coves.social",
240
+
"test.coves.social",
243
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
244
+
handler := communityFeed.NewGetCommunityHandler(feedService)
246
+
// Setup test data with many posts
247
+
ctx := context.Background()
248
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("pagination-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
249
+
require.NoError(t, err)
252
+
for i := 0; i < 25; i++ {
253
+
createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), i, time.Now().Add(-time.Duration(i)*time.Minute))
256
+
// Page 1: Get first 10 posts
257
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10", communityDID), nil)
258
+
rec := httptest.NewRecorder()
259
+
handler.HandleGetCommunity(rec, req)
261
+
assert.Equal(t, http.StatusOK, rec.Code)
263
+
var page1 communityFeeds.FeedResponse
264
+
err = json.Unmarshal(rec.Body.Bytes(), &page1)
265
+
require.NoError(t, err)
267
+
assert.Len(t, page1.Feed, 10)
268
+
assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
270
+
t.Logf("Page 1 cursor: %s", *page1.Cursor)
272
+
// Page 2: Use cursor
273
+
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, *page1.Cursor), nil)
274
+
rec = httptest.NewRecorder()
275
+
handler.HandleGetCommunity(rec, req)
277
+
if rec.Code != http.StatusOK {
278
+
t.Logf("Page 2 error: %s", rec.Body.String())
280
+
assert.Equal(t, http.StatusOK, rec.Code)
282
+
var page2 communityFeeds.FeedResponse
283
+
err = json.Unmarshal(rec.Body.Bytes(), &page2)
284
+
require.NoError(t, err)
286
+
assert.Len(t, page2.Feed, 10)
288
+
// Verify no duplicate posts between pages
289
+
page1URIs := make(map[string]bool)
290
+
for _, p := range page1.Feed {
291
+
page1URIs[p.Post.URI] = true
293
+
for _, p := range page2.Feed {
294
+
assert.False(t, page1URIs[p.Post.URI], "Found duplicate post between pages")
297
+
// Page 3: Should have remaining 5 posts
298
+
if page2.Cursor == nil {
299
+
t.Fatal("Expected cursor for page 3, got nil")
301
+
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, *page2.Cursor), nil)
302
+
rec = httptest.NewRecorder()
303
+
handler.HandleGetCommunity(rec, req)
305
+
assert.Equal(t, http.StatusOK, rec.Code)
307
+
var page3 communityFeeds.FeedResponse
308
+
err = json.Unmarshal(rec.Body.Bytes(), &page3)
309
+
require.NoError(t, err)
311
+
assert.Len(t, page3.Feed, 5)
312
+
assert.Nil(t, page3.Cursor, "Should not have cursor on last page")
315
+
// TestGetCommunityFeed_InvalidCommunity tests error handling for invalid community
316
+
func TestGetCommunityFeed_InvalidCommunity(t *testing.T) {
317
+
if testing.Short() {
318
+
t.Skip("Skipping integration test in short mode")
321
+
db := setupTestDB(t)
322
+
t.Cleanup(func() { _ = db.Close() })
325
+
feedRepo := postgres.NewCommunityFeedRepository(db)
326
+
communityRepo := postgres.NewCommunityRepository(db)
327
+
communityService := communities.NewCommunityService(
329
+
"http://localhost:3001",
330
+
"did:web:test.coves.social",
331
+
"test.coves.social",
334
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
335
+
handler := communityFeed.NewGetCommunityHandler(feedService)
337
+
// Request feed for non-existent community
338
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)
339
+
rec := httptest.NewRecorder()
340
+
handler.HandleGetCommunity(rec, req)
342
+
assert.Equal(t, http.StatusNotFound, rec.Code)
344
+
var errResp map[string]interface{}
345
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
346
+
require.NoError(t, err)
348
+
assert.Equal(t, "CommunityNotFound", errResp["error"])
351
+
// TestGetCommunityFeed_InvalidCursor tests cursor validation
352
+
func TestGetCommunityFeed_InvalidCursor(t *testing.T) {
353
+
if testing.Short() {
354
+
t.Skip("Skipping integration test in short mode")
357
+
db := setupTestDB(t)
358
+
t.Cleanup(func() { _ = db.Close() })
361
+
feedRepo := postgres.NewCommunityFeedRepository(db)
362
+
communityRepo := postgres.NewCommunityRepository(db)
363
+
communityService := communities.NewCommunityService(
365
+
"http://localhost:3001",
366
+
"did:web:test.coves.social",
367
+
"test.coves.social",
370
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
371
+
handler := communityFeed.NewGetCommunityHandler(feedService)
373
+
// Setup test community
374
+
ctx := context.Background()
375
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cursortest-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
376
+
require.NoError(t, err)
378
+
tests := []struct {
382
+
{"Invalid base64", "not-base64!!!"},
383
+
{"Malicious SQL", "JyBPUiAnMSc9JzE="}, // ' OR '1'='1
384
+
{"Invalid timestamp", "bWFsaWNpb3VzOnN0cmluZw=="}, // malicious:string
385
+
{"Invalid URI format", "MjAyNS0wMS0wMVQwMDowMDowMFo6bm90LWF0LXVyaQ=="}, // 2025-01-01T00:00:00Z:not-at-uri
388
+
for _, tt := range tests {
389
+
t.Run(tt.name, func(t *testing.T) {
390
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, tt.cursor), nil)
391
+
rec := httptest.NewRecorder()
392
+
handler.HandleGetCommunity(rec, req)
394
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
396
+
var errResp map[string]interface{}
397
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
398
+
require.NoError(t, err)
400
+
// Accept either InvalidRequest or InvalidCursor (both are correct)
401
+
errorCode := errResp["error"].(string)
402
+
assert.True(t, errorCode == "InvalidRequest" || errorCode == "InvalidCursor", "Expected InvalidRequest or InvalidCursor, got %s", errorCode)
407
+
// TestGetCommunityFeed_EmptyFeed tests handling of empty communities
408
+
func TestGetCommunityFeed_EmptyFeed(t *testing.T) {
409
+
if testing.Short() {
410
+
t.Skip("Skipping integration test in short mode")
413
+
db := setupTestDB(t)
414
+
t.Cleanup(func() { _ = db.Close() })
417
+
feedRepo := postgres.NewCommunityFeedRepository(db)
418
+
communityRepo := postgres.NewCommunityRepository(db)
419
+
communityService := communities.NewCommunityService(
421
+
"http://localhost:3001",
422
+
"did:web:test.coves.social",
423
+
"test.coves.social",
426
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
427
+
handler := communityFeed.NewGetCommunityHandler(feedService)
429
+
// Create community with no posts
430
+
ctx := context.Background()
431
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("empty-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
432
+
require.NoError(t, err)
434
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=10", communityDID), nil)
435
+
rec := httptest.NewRecorder()
436
+
handler.HandleGetCommunity(rec, req)
438
+
if rec.Code != http.StatusOK {
439
+
t.Logf("Response body: %s", rec.Body.String())
441
+
assert.Equal(t, http.StatusOK, rec.Code)
443
+
var response communityFeeds.FeedResponse
444
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
445
+
require.NoError(t, err)
447
+
assert.Len(t, response.Feed, 0)
448
+
assert.Nil(t, response.Cursor)
451
+
// TestGetCommunityFeed_LimitValidation tests limit parameter validation
452
+
func TestGetCommunityFeed_LimitValidation(t *testing.T) {
453
+
if testing.Short() {
454
+
t.Skip("Skipping integration test in short mode")
457
+
db := setupTestDB(t)
458
+
t.Cleanup(func() { _ = db.Close() })
461
+
feedRepo := postgres.NewCommunityFeedRepository(db)
462
+
communityRepo := postgres.NewCommunityRepository(db)
463
+
communityService := communities.NewCommunityService(
465
+
"http://localhost:3001",
466
+
"did:web:test.coves.social",
467
+
"test.coves.social",
470
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
471
+
handler := communityFeed.NewGetCommunityHandler(feedService)
473
+
// Setup test community
474
+
ctx := context.Background()
475
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("limittest-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
476
+
require.NoError(t, err)
478
+
t.Run("Reject limit over 50", func(t *testing.T) {
479
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=100", communityDID), nil)
480
+
rec := httptest.NewRecorder()
481
+
handler.HandleGetCommunity(rec, req)
483
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
485
+
var errResp map[string]interface{}
486
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
487
+
require.NoError(t, err)
489
+
assert.Equal(t, "InvalidRequest", errResp["error"])
490
+
assert.Contains(t, errResp["message"], "limit must not exceed 50")
493
+
t.Run("Handle zero limit with default", func(t *testing.T) {
494
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=0", communityDID), nil)
495
+
rec := httptest.NewRecorder()
496
+
handler.HandleGetCommunity(rec, req)
498
+
// Should succeed with default limit (15)
499
+
assert.Equal(t, http.StatusOK, rec.Code)
503
+
// TestGetCommunityFeed_HotPaginationBug tests the critical hot pagination bug fix
504
+
// Verifies that posts with higher raw scores but lower hot ranks don't get dropped during pagination
505
+
func TestGetCommunityFeed_HotPaginationBug(t *testing.T) {
506
+
if testing.Short() {
507
+
t.Skip("Skipping integration test in short mode")
510
+
db := setupTestDB(t)
511
+
t.Cleanup(func() { _ = db.Close() })
514
+
feedRepo := postgres.NewCommunityFeedRepository(db)
515
+
communityRepo := postgres.NewCommunityRepository(db)
516
+
communityService := communities.NewCommunityService(
518
+
"http://localhost:3001",
519
+
"did:web:test.coves.social",
520
+
"test.coves.social",
523
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
524
+
handler := communityFeed.NewGetCommunityHandler(feedService)
527
+
ctx := context.Background()
528
+
testID := time.Now().UnixNano()
529
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("hotbug-%d", testID), fmt.Sprintf("hotbug-%d.test", testID))
530
+
require.NoError(t, err)
532
+
// Create posts that reproduce the bug:
533
+
// Post A: Recent, low score (hot_rank ~17.6) - should be on page 1
534
+
// Post B: Old, high score (hot_rank ~10.4) - should be on page 2
535
+
// Post C: Older, medium score (hot_rank ~8.2) - should be on page 2
537
+
// Bug: If cursor stores raw score (17) from Post A, Post B (score=100) gets filtered out
538
+
// because WHERE p.score < 17 excludes it, even though hot_rank(B) < hot_rank(A)
540
+
_ = createTestPost(t, db, communityDID, "did:plc:alice", "Recent trending", 17, time.Now().Add(-1*time.Hour))
541
+
postB := createTestPost(t, db, communityDID, "did:plc:bob", "Old popular", 100, time.Now().Add(-24*time.Hour))
542
+
_ = createTestPost(t, db, communityDID, "did:plc:charlie", "Older medium", 50, time.Now().Add(-36*time.Hour))
544
+
// Page 1: Get first post (limit=1)
545
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=1", communityDID), nil)
546
+
rec := httptest.NewRecorder()
547
+
handler.HandleGetCommunity(rec, req)
549
+
assert.Equal(t, http.StatusOK, rec.Code)
551
+
var page1 communityFeeds.FeedResponse
552
+
err = json.Unmarshal(rec.Body.Bytes(), &page1)
553
+
require.NoError(t, err)
555
+
assert.Len(t, page1.Feed, 1)
556
+
assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
558
+
// The highest hot_rank post should be first (recent with low-medium score)
559
+
firstPostURI := page1.Feed[0].Post.URI
560
+
t.Logf("Page 1 - First post: %s (URI: %s)", *page1.Feed[0].Post.Title, firstPostURI)
561
+
t.Logf("Page 1 - Cursor: %s", *page1.Cursor)
563
+
// Page 2: Use cursor - this is where the bug would occur
564
+
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=2&cursor=%s", communityDID, *page1.Cursor), nil)
565
+
rec = httptest.NewRecorder()
566
+
handler.HandleGetCommunity(rec, req)
568
+
if rec.Code != http.StatusOK {
569
+
t.Fatalf("Page 2 failed: %s", rec.Body.String())
572
+
var page2 communityFeeds.FeedResponse
573
+
err = json.Unmarshal(rec.Body.Bytes(), &page2)
574
+
require.NoError(t, err)
576
+
// CRITICAL: Page 2 should contain at least 1 post (at most 2 due to time drift)
577
+
// Bug would cause high-score posts to be filtered out entirely
578
+
assert.GreaterOrEqual(t, len(page2.Feed), 1, "Page 2 should contain at least 1 remaining post")
579
+
assert.LessOrEqual(t, len(page2.Feed), 3, "Page 2 should contain at most 3 posts")
581
+
// Collect all URIs across pages
582
+
allURIs := []string{firstPostURI}
583
+
seenURIs := map[string]bool{firstPostURI: true}
584
+
for _, p := range page2.Feed {
585
+
allURIs = append(allURIs, p.Post.URI)
586
+
t.Logf("Page 2 - Post: %s (URI: %s)", *p.Post.Title, p.Post.URI)
587
+
// Check for duplicates
588
+
if seenURIs[p.Post.URI] {
589
+
t.Errorf("Duplicate post found: %s", p.Post.URI)
591
+
seenURIs[p.Post.URI] = true
594
+
// The critical test: Post B (high raw score, low hot rank) must appear somewhere
595
+
// Without the fix, it would be filtered out by p.score < 17
596
+
if !seenURIs[postB] {
597
+
t.Fatalf("CRITICAL BUG: Post B (old, high score=100) missing - filtered by raw score cursor!")
600
+
t.Logf("SUCCESS: All posts with high raw scores appear (bug fixed)")
601
+
t.Logf("Found %d total posts across pages (expected 3, time drift may cause slight variation)", len(allURIs))
604
+
// TestGetCommunityFeed_HotCursorPrecision tests that hot rank cursor preserves full float precision
605
+
// Regression test for precision bug where posts with hot ranks differing by <1e-6 were dropped
606
+
func TestGetCommunityFeed_HotCursorPrecision(t *testing.T) {
607
+
if testing.Short() {
608
+
t.Skip("Skipping integration test in short mode")
611
+
db := setupTestDB(t)
612
+
t.Cleanup(func() { _ = db.Close() })
615
+
feedRepo := postgres.NewCommunityFeedRepository(db)
616
+
communityRepo := postgres.NewCommunityRepository(db)
617
+
communityService := communities.NewCommunityService(
619
+
"http://localhost:3001",
620
+
"did:web:test.coves.social",
621
+
"test.coves.social",
624
+
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
625
+
handler := communityFeed.NewGetCommunityHandler(feedService)
628
+
ctx := context.Background()
629
+
testID := time.Now().UnixNano()
630
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("precision-%d", testID), fmt.Sprintf("precision-%d.test", testID))
631
+
require.NoError(t, err)
633
+
// Create posts with very similar ages (fractions of seconds apart)
634
+
// This creates hot ranks that differ by tiny amounts (<1e-6)
635
+
// Without full precision, pagination would drop the second post
636
+
baseTime := time.Now().Add(-2 * time.Hour)
638
+
// Post A: 2 hours old, score 50 (hot_rank ~8.24)
639
+
postA := createTestPost(t, db, communityDID, "did:plc:alice", "Post A", 50, baseTime)
641
+
// Post B: 2 hours + 100ms old, score 50 (hot_rank ~8.239999... - differs by <1e-6)
642
+
// This is the critical post that would get dropped with low precision
643
+
postB := createTestPost(t, db, communityDID, "did:plc:bob", "Post B", 50, baseTime.Add(100*time.Millisecond))
645
+
// Post C: 2 hours + 200ms old, score 50
646
+
postC := createTestPost(t, db, communityDID, "did:plc:charlie", "Post C", 50, baseTime.Add(200*time.Millisecond))
648
+
// Page 1: Get first post (limit=1)
649
+
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=1", communityDID), nil)
650
+
rec := httptest.NewRecorder()
651
+
handler.HandleGetCommunity(rec, req)
653
+
assert.Equal(t, http.StatusOK, rec.Code)
655
+
var page1 communityFeeds.FeedResponse
656
+
err = json.Unmarshal(rec.Body.Bytes(), &page1)
657
+
require.NoError(t, err)
659
+
assert.Len(t, page1.Feed, 1)
660
+
assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
662
+
firstPostURI := page1.Feed[0].Post.URI
663
+
t.Logf("Page 1 - First post: %s", firstPostURI)
664
+
t.Logf("Page 1 - Cursor: %s", *page1.Cursor)
666
+
// Page 2: Use cursor - this is where precision loss would drop Post B
667
+
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=2&cursor=%s", communityDID, *page1.Cursor), nil)
668
+
rec = httptest.NewRecorder()
669
+
handler.HandleGetCommunity(rec, req)
671
+
if rec.Code != http.StatusOK {
672
+
t.Fatalf("Page 2 failed: %s", rec.Body.String())
675
+
var page2 communityFeeds.FeedResponse
676
+
err = json.Unmarshal(rec.Body.Bytes(), &page2)
677
+
require.NoError(t, err)
679
+
// CRITICAL: Page 2 must contain the remaining posts
680
+
// Without full precision, Post B (with hot_rank differing by <1e-6) would be filtered out
681
+
assert.GreaterOrEqual(t, len(page2.Feed), 2, "Page 2 should contain at least 2 remaining posts")
683
+
// Verify all posts appear across pages
684
+
allURIs := map[string]bool{firstPostURI: true}
685
+
for _, p := range page2.Feed {
686
+
allURIs[p.Post.URI] = true
687
+
t.Logf("Page 2 - Post: %s", p.Post.URI)
690
+
// All 3 posts must be present
691
+
assert.True(t, allURIs[postA], "Post A missing")
692
+
assert.True(t, allURIs[postB], "CRITICAL: Post B missing - cursor precision loss bug!")
693
+
assert.True(t, allURIs[postC], "Post C missing")
695
+
t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)")
698
+
// Helper: createFeedTestCommunity creates a test community and returns its DID
699
+
func createFeedTestCommunity(db *sql.DB, ctx context.Context, name, ownerHandle string) (string, error) {
700
+
// Create owner user first (directly insert to avoid service dependencies)
701
+
ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle)
702
+
_, err := db.ExecContext(ctx, `
703
+
INSERT INTO users (did, handle, pds_url, created_at)
704
+
VALUES ($1, $2, $3, NOW())
705
+
ON CONFLICT (did) DO NOTHING
706
+
`, ownerDID, ownerHandle, "https://bsky.social")
711
+
// Create community
712
+
communityDID := fmt.Sprintf("did:plc:community-%s", name)
713
+
_, err = db.ExecContext(ctx, `
714
+
INSERT INTO communities (did, name, owner_did, created_by_did, hosted_by_did, handle, created_at)
715
+
VALUES ($1, $2, $3, $4, $5, $6, NOW())
716
+
ON CONFLICT (did) DO NOTHING
717
+
`, communityDID, name, ownerDID, ownerDID, "did:web:test.coves.social", fmt.Sprintf("%s.coves.social", name))
719
+
return communityDID, err
722
+
// Helper: createTestPost creates a test post and returns its URI
723
+
func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title string, score int, createdAt time.Time) string {
726
+
ctx := context.Background()
728
+
// Create author user if not exists (directly insert to avoid service dependencies)
729
+
_, _ = db.ExecContext(ctx, `
730
+
INSERT INTO users (did, handle, pds_url, created_at)
731
+
VALUES ($1, $2, $3, NOW())
732
+
ON CONFLICT (did) DO NOTHING
733
+
`, authorDID, fmt.Sprintf("%s.bsky.social", authorDID), "https://bsky.social")
736
+
rkey := fmt.Sprintf("post-%d", time.Now().UnixNano())
737
+
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey)
740
+
_, err := db.ExecContext(ctx, `
741
+
INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, score, upvote_count)
742
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
743
+
`, uri, "bafytest", rkey, authorDID, communityDID, title, createdAt, score, score)
744
+
require.NoError(t, err)