A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/api/handlers/communityFeed"
5 "Coves/internal/core/communities"
6 "Coves/internal/core/communityFeeds"
7 "Coves/internal/db/postgres"
8 "context"
9 "encoding/json"
10 "fmt"
11 "net/http"
12 "net/http/httptest"
13 "testing"
14 "time"
15
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18)
19
20// TestGetCommunityFeed_Hot tests hot feed sorting algorithm
21func TestGetCommunityFeed_Hot(t *testing.T) {
22 if testing.Short() {
23 t.Skip("Skipping integration test in short mode")
24 }
25
26 db := setupTestDB(t)
27 t.Cleanup(func() { _ = db.Close() })
28
29 // Setup services
30 feedRepo := postgres.NewCommunityFeedRepository(db)
31 communityRepo := postgres.NewCommunityRepository(db)
32 communityService := communities.NewCommunityService(
33 communityRepo,
34 "http://localhost:3001",
35 "did:web:test.coves.social",
36 "test.coves.social",
37 nil,
38 )
39 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
40 handler := communityFeed.NewGetCommunityHandler(feedService)
41
42 // Setup test data: community, users, and posts
43 ctx := context.Background()
44 testID := time.Now().UnixNano()
45 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID))
46 require.NoError(t, err)
47
48 // Create posts with different scores and ages
49 // Post 1: Recent with medium score (should rank high in "hot")
50 post1URI := createTestPost(t, db, communityDID, "did:plc:alice", "Recent trending post", 50, time.Now().Add(-1*time.Hour))
51
52 // Post 2: Old with high score (hot algorithm should penalize age)
53 post2URI := createTestPost(t, db, communityDID, "did:plc:bob", "Old popular post", 100, time.Now().Add(-24*time.Hour))
54
55 // Post 3: Very recent with low score
56 post3URI := createTestPost(t, db, communityDID, "did:plc:charlie", "Brand new post", 5, time.Now().Add(-10*time.Minute))
57
58 // Request hot feed
59 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=10", communityDID), nil)
60 rec := httptest.NewRecorder()
61 handler.HandleGetCommunity(rec, req)
62
63 // Assertions
64 assert.Equal(t, http.StatusOK, rec.Code)
65
66 var response communityFeeds.FeedResponse
67 err = json.Unmarshal(rec.Body.Bytes(), &response)
68 require.NoError(t, err)
69
70 assert.Len(t, response.Feed, 3)
71
72 // Verify hot ranking: recent + medium score should beat old + high score
73 // (exact order depends on hot algorithm, but we can verify posts exist)
74 uris := []string{response.Feed[0].Post.URI, response.Feed[1].Post.URI, response.Feed[2].Post.URI}
75 assert.Contains(t, uris, post1URI)
76 assert.Contains(t, uris, post2URI)
77 assert.Contains(t, uris, post3URI)
78
79 // Verify Record field is populated (schema compliance)
80 for i, feedPost := range response.Feed {
81 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
82 record, ok := feedPost.Post.Record.(map[string]interface{})
83 require.True(t, ok, "Record should be a map")
84 assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
85 assert.NotEmpty(t, record["community"], "Record should have community")
86 assert.NotEmpty(t, record["author"], "Record should have author")
87 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
88 }
89}
90
91// TestGetCommunityFeed_Top_WithTimeframe tests top sorting with time filters
92func TestGetCommunityFeed_Top_WithTimeframe(t *testing.T) {
93 if testing.Short() {
94 t.Skip("Skipping integration test in short mode")
95 }
96
97 db := setupTestDB(t)
98 t.Cleanup(func() { _ = db.Close() })
99
100 // Setup services
101 feedRepo := postgres.NewCommunityFeedRepository(db)
102 communityRepo := postgres.NewCommunityRepository(db)
103 communityService := communities.NewCommunityService(
104 communityRepo,
105 "http://localhost:3001",
106 "did:web:test.coves.social",
107 "test.coves.social",
108 nil,
109 )
110 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
111 handler := communityFeed.NewGetCommunityHandler(feedService)
112
113 // Setup test data
114 ctx := context.Background()
115 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", time.Now().UnixNano()), fmt.Sprintf("bob.test-%d", time.Now().UnixNano()))
116 require.NoError(t, err)
117
118 // Create posts at different times
119 // Post 1: 2 hours ago, score 100
120 createTestPost(t, db, communityDID, "did:plc:alice", "2 hours old", 100, time.Now().Add(-2*time.Hour))
121
122 // Post 2: 2 days ago, score 200 (should be filtered out by "day" timeframe)
123 createTestPost(t, db, communityDID, "did:plc:bob", "2 days old", 200, time.Now().Add(-48*time.Hour))
124
125 // Post 3: 30 minutes ago, score 50
126 createTestPost(t, db, communityDID, "did:plc:charlie", "30 minutes old", 50, time.Now().Add(-30*time.Minute))
127
128 t.Run("Top posts from last day", func(t *testing.T) {
129 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=top&timeframe=day&limit=10", communityDID), nil)
130 rec := httptest.NewRecorder()
131 handler.HandleGetCommunity(rec, req)
132
133 assert.Equal(t, http.StatusOK, rec.Code)
134
135 var response communityFeeds.FeedResponse
136 err = json.Unmarshal(rec.Body.Bytes(), &response)
137 require.NoError(t, err)
138
139 // Should only return 2 posts (within last day)
140 assert.Len(t, response.Feed, 2)
141
142 // Verify top-ranked post (highest score)
143 assert.Equal(t, "2 hours old", *response.Feed[0].Post.Title)
144 assert.Equal(t, 100, response.Feed[0].Post.Stats.Score)
145 })
146
147 t.Run("Top posts from all time", func(t *testing.T) {
148 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=top&timeframe=all&limit=10", communityDID), nil)
149 rec := httptest.NewRecorder()
150 handler.HandleGetCommunity(rec, req)
151
152 assert.Equal(t, http.StatusOK, rec.Code)
153
154 var response communityFeeds.FeedResponse
155 err = json.Unmarshal(rec.Body.Bytes(), &response)
156 require.NoError(t, err)
157
158 // Should return all 3 posts
159 assert.Len(t, response.Feed, 3)
160
161 // Highest score should be first
162 assert.Equal(t, "2 days old", *response.Feed[0].Post.Title)
163 assert.Equal(t, 200, response.Feed[0].Post.Stats.Score)
164 })
165}
166
167// TestGetCommunityFeed_New tests chronological sorting
168func TestGetCommunityFeed_New(t *testing.T) {
169 if testing.Short() {
170 t.Skip("Skipping integration test in short mode")
171 }
172
173 db := setupTestDB(t)
174 t.Cleanup(func() { _ = db.Close() })
175
176 // Setup services
177 feedRepo := postgres.NewCommunityFeedRepository(db)
178 communityRepo := postgres.NewCommunityRepository(db)
179 communityService := communities.NewCommunityService(
180 communityRepo,
181 "http://localhost:3001",
182 "did:web:test.coves.social",
183 "test.coves.social",
184 nil,
185 )
186 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
187 handler := communityFeed.NewGetCommunityHandler(feedService)
188
189 // Setup test data
190 ctx := context.Background()
191 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("news-%d", time.Now().UnixNano()), fmt.Sprintf("charlie.test-%d", time.Now().UnixNano()))
192 require.NoError(t, err)
193
194 // Create posts in specific order (older first)
195 time1 := time.Now().Add(-3 * time.Hour)
196 time2 := time.Now().Add(-2 * time.Hour)
197 time3 := time.Now().Add(-1 * time.Hour)
198
199 createTestPost(t, db, communityDID, "did:plc:alice", "Oldest post", 10, time1)
200 createTestPost(t, db, communityDID, "did:plc:bob", "Middle post", 100, time2) // High score, but not newest
201 createTestPost(t, db, communityDID, "did:plc:charlie", "Newest post", 1, time3)
202
203 // Request new feed
204 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10", communityDID), nil)
205 rec := httptest.NewRecorder()
206 handler.HandleGetCommunity(rec, req)
207
208 // Assertions
209 assert.Equal(t, http.StatusOK, rec.Code)
210
211 var response communityFeeds.FeedResponse
212 err = json.Unmarshal(rec.Body.Bytes(), &response)
213 require.NoError(t, err)
214
215 assert.Len(t, response.Feed, 3)
216
217 // Verify chronological order (newest first)
218 assert.Equal(t, "Newest post", *response.Feed[0].Post.Title)
219 assert.Equal(t, "Middle post", *response.Feed[1].Post.Title)
220 assert.Equal(t, "Oldest post", *response.Feed[2].Post.Title)
221}
222
223// TestGetCommunityFeed_Pagination tests cursor-based pagination
224func TestGetCommunityFeed_Pagination(t *testing.T) {
225 if testing.Short() {
226 t.Skip("Skipping integration test in short mode")
227 }
228
229 db := setupTestDB(t)
230 t.Cleanup(func() { _ = db.Close() })
231
232 // Setup services
233 feedRepo := postgres.NewCommunityFeedRepository(db)
234 communityRepo := postgres.NewCommunityRepository(db)
235 communityService := communities.NewCommunityService(
236 communityRepo,
237 "http://localhost:3001",
238 "did:web:test.coves.social",
239 "test.coves.social",
240 nil,
241 )
242 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
243 handler := communityFeed.NewGetCommunityHandler(feedService)
244
245 // Setup test data with many posts
246 ctx := context.Background()
247 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("pagination-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
248 require.NoError(t, err)
249
250 // Create 25 posts
251 for i := 0; i < 25; i++ {
252 createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), i, time.Now().Add(-time.Duration(i)*time.Minute))
253 }
254
255 // Page 1: Get first 10 posts
256 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10", communityDID), nil)
257 rec := httptest.NewRecorder()
258 handler.HandleGetCommunity(rec, req)
259
260 assert.Equal(t, http.StatusOK, rec.Code)
261
262 var page1 communityFeeds.FeedResponse
263 err = json.Unmarshal(rec.Body.Bytes(), &page1)
264 require.NoError(t, err)
265
266 assert.Len(t, page1.Feed, 10)
267 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
268
269 t.Logf("Page 1 cursor: %s", *page1.Cursor)
270
271 // Page 2: Use cursor
272 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, *page1.Cursor), nil)
273 rec = httptest.NewRecorder()
274 handler.HandleGetCommunity(rec, req)
275
276 if rec.Code != http.StatusOK {
277 t.Logf("Page 2 error: %s", rec.Body.String())
278 }
279 assert.Equal(t, http.StatusOK, rec.Code)
280
281 var page2 communityFeeds.FeedResponse
282 err = json.Unmarshal(rec.Body.Bytes(), &page2)
283 require.NoError(t, err)
284
285 assert.Len(t, page2.Feed, 10)
286
287 // Verify no duplicate posts between pages
288 page1URIs := make(map[string]bool)
289 for _, p := range page1.Feed {
290 page1URIs[p.Post.URI] = true
291 }
292 for _, p := range page2.Feed {
293 assert.False(t, page1URIs[p.Post.URI], "Found duplicate post between pages")
294 }
295
296 // Page 3: Should have remaining 5 posts
297 if page2.Cursor == nil {
298 t.Fatal("Expected cursor for page 3, got nil")
299 }
300 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, *page2.Cursor), nil)
301 rec = httptest.NewRecorder()
302 handler.HandleGetCommunity(rec, req)
303
304 assert.Equal(t, http.StatusOK, rec.Code)
305
306 var page3 communityFeeds.FeedResponse
307 err = json.Unmarshal(rec.Body.Bytes(), &page3)
308 require.NoError(t, err)
309
310 assert.Len(t, page3.Feed, 5)
311 assert.Nil(t, page3.Cursor, "Should not have cursor on last page")
312}
313
314// TestGetCommunityFeed_InvalidCommunity tests error handling for invalid community
315func TestGetCommunityFeed_InvalidCommunity(t *testing.T) {
316 if testing.Short() {
317 t.Skip("Skipping integration test in short mode")
318 }
319
320 db := setupTestDB(t)
321 t.Cleanup(func() { _ = db.Close() })
322
323 // Setup services
324 feedRepo := postgres.NewCommunityFeedRepository(db)
325 communityRepo := postgres.NewCommunityRepository(db)
326 communityService := communities.NewCommunityService(
327 communityRepo,
328 "http://localhost:3001",
329 "did:web:test.coves.social",
330 "test.coves.social",
331 nil,
332 )
333 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
334 handler := communityFeed.NewGetCommunityHandler(feedService)
335
336 // Request feed for non-existent community
337 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)
338 rec := httptest.NewRecorder()
339 handler.HandleGetCommunity(rec, req)
340
341 assert.Equal(t, http.StatusNotFound, rec.Code)
342
343 var errResp map[string]interface{}
344 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
345 require.NoError(t, err)
346
347 assert.Equal(t, "CommunityNotFound", errResp["error"])
348}
349
350// TestGetCommunityFeed_InvalidCursor tests cursor validation
351func TestGetCommunityFeed_InvalidCursor(t *testing.T) {
352 if testing.Short() {
353 t.Skip("Skipping integration test in short mode")
354 }
355
356 db := setupTestDB(t)
357 t.Cleanup(func() { _ = db.Close() })
358
359 // Setup services
360 feedRepo := postgres.NewCommunityFeedRepository(db)
361 communityRepo := postgres.NewCommunityRepository(db)
362 communityService := communities.NewCommunityService(
363 communityRepo,
364 "http://localhost:3001",
365 "did:web:test.coves.social",
366 "test.coves.social",
367 nil,
368 )
369 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
370 handler := communityFeed.NewGetCommunityHandler(feedService)
371
372 // Setup test community
373 ctx := context.Background()
374 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cursortest-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
375 require.NoError(t, err)
376
377 tests := []struct {
378 name string
379 cursor string
380 }{
381 {"Invalid base64", "not-base64!!!"},
382 {"Malicious SQL", "JyBPUiAnMSc9JzE="}, // ' OR '1'='1
383 {"Invalid timestamp", "bWFsaWNpb3VzOnN0cmluZw=="}, // malicious:string
384 {"Invalid URI format", "MjAyNS0wMS0wMVQwMDowMDowMFo6bm90LWF0LXVyaQ=="}, // 2025-01-01T00:00:00Z:not-at-uri
385 }
386
387 for _, tt := range tests {
388 t.Run(tt.name, func(t *testing.T) {
389 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=new&limit=10&cursor=%s", communityDID, tt.cursor), nil)
390 rec := httptest.NewRecorder()
391 handler.HandleGetCommunity(rec, req)
392
393 assert.Equal(t, http.StatusBadRequest, rec.Code)
394
395 var errResp map[string]interface{}
396 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
397 require.NoError(t, err)
398
399 // Accept either InvalidRequest or InvalidCursor (both are correct)
400 errorCode := errResp["error"].(string)
401 assert.True(t, errorCode == "InvalidRequest" || errorCode == "InvalidCursor", "Expected InvalidRequest or InvalidCursor, got %s", errorCode)
402 })
403 }
404}
405
406// TestGetCommunityFeed_EmptyFeed tests handling of empty communities
407func TestGetCommunityFeed_EmptyFeed(t *testing.T) {
408 if testing.Short() {
409 t.Skip("Skipping integration test in short mode")
410 }
411
412 db := setupTestDB(t)
413 t.Cleanup(func() { _ = db.Close() })
414
415 // Setup services
416 feedRepo := postgres.NewCommunityFeedRepository(db)
417 communityRepo := postgres.NewCommunityRepository(db)
418 communityService := communities.NewCommunityService(
419 communityRepo,
420 "http://localhost:3001",
421 "did:web:test.coves.social",
422 "test.coves.social",
423 nil,
424 )
425 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
426 handler := communityFeed.NewGetCommunityHandler(feedService)
427
428 // Create community with no posts
429 ctx := context.Background()
430 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("empty-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
431 require.NoError(t, err)
432
433 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=10", communityDID), nil)
434 rec := httptest.NewRecorder()
435 handler.HandleGetCommunity(rec, req)
436
437 if rec.Code != http.StatusOK {
438 t.Logf("Response body: %s", rec.Body.String())
439 }
440 assert.Equal(t, http.StatusOK, rec.Code)
441
442 var response communityFeeds.FeedResponse
443 err = json.Unmarshal(rec.Body.Bytes(), &response)
444 require.NoError(t, err)
445
446 assert.Len(t, response.Feed, 0)
447 assert.Nil(t, response.Cursor)
448}
449
450// TestGetCommunityFeed_LimitValidation tests limit parameter validation
451func TestGetCommunityFeed_LimitValidation(t *testing.T) {
452 if testing.Short() {
453 t.Skip("Skipping integration test in short mode")
454 }
455
456 db := setupTestDB(t)
457 t.Cleanup(func() { _ = db.Close() })
458
459 // Setup services
460 feedRepo := postgres.NewCommunityFeedRepository(db)
461 communityRepo := postgres.NewCommunityRepository(db)
462 communityService := communities.NewCommunityService(
463 communityRepo,
464 "http://localhost:3001",
465 "did:web:test.coves.social",
466 "test.coves.social",
467 nil,
468 )
469 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
470 handler := communityFeed.NewGetCommunityHandler(feedService)
471
472 // Setup test community
473 ctx := context.Background()
474 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("limittest-%d", time.Now().UnixNano()), fmt.Sprintf("test.test-%d", time.Now().UnixNano()))
475 require.NoError(t, err)
476
477 t.Run("Reject limit over 50", func(t *testing.T) {
478 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=100", communityDID), nil)
479 rec := httptest.NewRecorder()
480 handler.HandleGetCommunity(rec, req)
481
482 assert.Equal(t, http.StatusBadRequest, rec.Code)
483
484 var errResp map[string]interface{}
485 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
486 require.NoError(t, err)
487
488 assert.Equal(t, "InvalidRequest", errResp["error"])
489 assert.Contains(t, errResp["message"], "limit must not exceed 50")
490 })
491
492 t.Run("Handle zero limit with default", func(t *testing.T) {
493 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=0", communityDID), nil)
494 rec := httptest.NewRecorder()
495 handler.HandleGetCommunity(rec, req)
496
497 // Should succeed with default limit (15)
498 assert.Equal(t, http.StatusOK, rec.Code)
499 })
500}
501
502// TestGetCommunityFeed_HotPaginationBug tests the critical hot pagination bug fix
503// Verifies that posts with higher raw scores but lower hot ranks don't get dropped during pagination
504func TestGetCommunityFeed_HotPaginationBug(t *testing.T) {
505 if testing.Short() {
506 t.Skip("Skipping integration test in short mode")
507 }
508
509 db := setupTestDB(t)
510 t.Cleanup(func() { _ = db.Close() })
511
512 // Setup services
513 feedRepo := postgres.NewCommunityFeedRepository(db)
514 communityRepo := postgres.NewCommunityRepository(db)
515 communityService := communities.NewCommunityService(
516 communityRepo,
517 "http://localhost:3001",
518 "did:web:test.coves.social",
519 "test.coves.social",
520 nil,
521 )
522 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
523 handler := communityFeed.NewGetCommunityHandler(feedService)
524
525 // Setup test data
526 ctx := context.Background()
527 testID := time.Now().UnixNano()
528 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("hotbug-%d", testID), fmt.Sprintf("hotbug-%d.test", testID))
529 require.NoError(t, err)
530
531 // Create posts that reproduce the bug:
532 // Post A: Recent, low score (hot_rank ~17.6) - should be on page 1
533 // Post B: Old, high score (hot_rank ~10.4) - should be on page 2
534 // Post C: Older, medium score (hot_rank ~8.2) - should be on page 2
535 //
536 // Bug: If cursor stores raw score (17) from Post A, Post B (score=100) gets filtered out
537 // because WHERE p.score < 17 excludes it, even though hot_rank(B) < hot_rank(A)
538
539 _ = createTestPost(t, db, communityDID, "did:plc:alice", "Recent trending", 17, time.Now().Add(-1*time.Hour))
540 postB := createTestPost(t, db, communityDID, "did:plc:bob", "Old popular", 100, time.Now().Add(-24*time.Hour))
541 _ = createTestPost(t, db, communityDID, "did:plc:charlie", "Older medium", 50, time.Now().Add(-36*time.Hour))
542
543 // Page 1: Get first post (limit=1)
544 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=1", communityDID), nil)
545 rec := httptest.NewRecorder()
546 handler.HandleGetCommunity(rec, req)
547
548 assert.Equal(t, http.StatusOK, rec.Code)
549
550 var page1 communityFeeds.FeedResponse
551 err = json.Unmarshal(rec.Body.Bytes(), &page1)
552 require.NoError(t, err)
553
554 assert.Len(t, page1.Feed, 1)
555 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
556
557 // The highest hot_rank post should be first (recent with low-medium score)
558 firstPostURI := page1.Feed[0].Post.URI
559 t.Logf("Page 1 - First post: %s (URI: %s)", *page1.Feed[0].Post.Title, firstPostURI)
560 t.Logf("Page 1 - Cursor: %s", *page1.Cursor)
561
562 // Page 2: Use cursor - this is where the bug would occur
563 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=2&cursor=%s", communityDID, *page1.Cursor), nil)
564 rec = httptest.NewRecorder()
565 handler.HandleGetCommunity(rec, req)
566
567 if rec.Code != http.StatusOK {
568 t.Fatalf("Page 2 failed: %s", rec.Body.String())
569 }
570
571 var page2 communityFeeds.FeedResponse
572 err = json.Unmarshal(rec.Body.Bytes(), &page2)
573 require.NoError(t, err)
574
575 // CRITICAL: Page 2 should contain at least 1 post (at most 2 due to time drift)
576 // Bug would cause high-score posts to be filtered out entirely
577 assert.GreaterOrEqual(t, len(page2.Feed), 1, "Page 2 should contain at least 1 remaining post")
578 assert.LessOrEqual(t, len(page2.Feed), 3, "Page 2 should contain at most 3 posts")
579
580 // Collect all URIs across pages
581 allURIs := []string{firstPostURI}
582 seenURIs := map[string]bool{firstPostURI: true}
583 for _, p := range page2.Feed {
584 allURIs = append(allURIs, p.Post.URI)
585 t.Logf("Page 2 - Post: %s (URI: %s)", *p.Post.Title, p.Post.URI)
586 // Check for duplicates
587 if seenURIs[p.Post.URI] {
588 t.Errorf("Duplicate post found: %s", p.Post.URI)
589 }
590 seenURIs[p.Post.URI] = true
591 }
592
593 // The critical test: Post B (high raw score, low hot rank) must appear somewhere
594 // Without the fix, it would be filtered out by p.score < 17
595 if !seenURIs[postB] {
596 t.Fatalf("CRITICAL BUG: Post B (old, high score=100) missing - filtered by raw score cursor!")
597 }
598
599 t.Logf("SUCCESS: All posts with high raw scores appear (bug fixed)")
600 t.Logf("Found %d total posts across pages (expected 3, time drift may cause slight variation)", len(allURIs))
601}
602
603// TestGetCommunityFeed_HotCursorPrecision tests that hot rank cursor preserves full float precision
604// Regression test for precision bug where posts with hot ranks differing by <1e-6 were dropped
605func TestGetCommunityFeed_HotCursorPrecision(t *testing.T) {
606 if testing.Short() {
607 t.Skip("Skipping integration test in short mode")
608 }
609
610 db := setupTestDB(t)
611 t.Cleanup(func() { _ = db.Close() })
612
613 // Setup services
614 feedRepo := postgres.NewCommunityFeedRepository(db)
615 communityRepo := postgres.NewCommunityRepository(db)
616 communityService := communities.NewCommunityService(
617 communityRepo,
618 "http://localhost:3001",
619 "did:web:test.coves.social",
620 "test.coves.social",
621 nil,
622 )
623 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
624 handler := communityFeed.NewGetCommunityHandler(feedService)
625
626 // Setup test data
627 ctx := context.Background()
628 testID := time.Now().UnixNano()
629 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("precision-%d", testID), fmt.Sprintf("precision-%d.test", testID))
630 require.NoError(t, err)
631
632 // Create posts with very similar ages (fractions of seconds apart)
633 // This creates hot ranks that differ by tiny amounts (<1e-6)
634 // Without full precision, pagination would drop the second post
635 baseTime := time.Now().Add(-2 * time.Hour)
636
637 // Post A: 2 hours old, score 50 (hot_rank ~8.24)
638 postA := createTestPost(t, db, communityDID, "did:plc:alice", "Post A", 50, baseTime)
639
640 // Post B: 2 hours + 100ms old, score 50 (hot_rank ~8.239999... - differs by <1e-6)
641 // This is the critical post that would get dropped with low precision
642 postB := createTestPost(t, db, communityDID, "did:plc:bob", "Post B", 50, baseTime.Add(100*time.Millisecond))
643
644 // Post C: 2 hours + 200ms old, score 50
645 postC := createTestPost(t, db, communityDID, "did:plc:charlie", "Post C", 50, baseTime.Add(200*time.Millisecond))
646
647 // Page 1: Get first post (limit=1)
648 req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=1", communityDID), nil)
649 rec := httptest.NewRecorder()
650 handler.HandleGetCommunity(rec, req)
651
652 assert.Equal(t, http.StatusOK, rec.Code)
653
654 var page1 communityFeeds.FeedResponse
655 err = json.Unmarshal(rec.Body.Bytes(), &page1)
656 require.NoError(t, err)
657
658 assert.Len(t, page1.Feed, 1)
659 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
660
661 firstPostURI := page1.Feed[0].Post.URI
662 t.Logf("Page 1 - First post: %s", firstPostURI)
663 t.Logf("Page 1 - Cursor: %s", *page1.Cursor)
664
665 // Page 2: Use cursor - this is where precision loss would drop Post B
666 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.communityFeed.getCommunity?community=%s&sort=hot&limit=2&cursor=%s", communityDID, *page1.Cursor), nil)
667 rec = httptest.NewRecorder()
668 handler.HandleGetCommunity(rec, req)
669
670 if rec.Code != http.StatusOK {
671 t.Fatalf("Page 2 failed: %s", rec.Body.String())
672 }
673
674 var page2 communityFeeds.FeedResponse
675 err = json.Unmarshal(rec.Body.Bytes(), &page2)
676 require.NoError(t, err)
677
678 // CRITICAL: Page 2 must contain the remaining posts
679 // Without full precision, Post B (with hot_rank differing by <1e-6) would be filtered out
680 assert.GreaterOrEqual(t, len(page2.Feed), 2, "Page 2 should contain at least 2 remaining posts")
681
682 // Verify all posts appear across pages
683 allURIs := map[string]bool{firstPostURI: true}
684 for _, p := range page2.Feed {
685 allURIs[p.Post.URI] = true
686 t.Logf("Page 2 - Post: %s", p.Post.URI)
687 }
688
689 // All 3 posts must be present
690 assert.True(t, allURIs[postA], "Post A missing")
691 assert.True(t, allURIs[postB], "CRITICAL: Post B missing - cursor precision loss bug!")
692 assert.True(t, allURIs[postC], "Post C missing")
693
694 t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)")
695}