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