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 "database/sql"
10 "encoding/json"
11 "fmt"
12 "net/http"
13 "net/http/httptest"
14 "testing"
15 "time"
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)
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.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")
89 }
90}
91
92// TestGetCommunityFeed_Top_WithTimeframe tests top sorting with time filters
93func TestGetCommunityFeed_Top_WithTimeframe(t *testing.T) {
94 if testing.Short() {
95 t.Skip("Skipping integration test in short mode")
96 }
97
98 db := setupTestDB(t)
99 t.Cleanup(func() { _ = db.Close() })
100
101 // Setup services
102 feedRepo := postgres.NewCommunityFeedRepository(db)
103 communityRepo := postgres.NewCommunityRepository(db)
104 communityService := communities.NewCommunityService(
105 communityRepo,
106 "http://localhost:3001",
107 "did:web:test.coves.social",
108 "test.coves.social",
109 nil,
110 )
111 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
112 handler := communityFeed.NewGetCommunityHandler(feedService)
113
114 // Setup test data
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)
118
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))
122
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))
125
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))
128
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)
133
134 assert.Equal(t, http.StatusOK, rec.Code)
135
136 var response communityFeeds.FeedResponse
137 err = json.Unmarshal(rec.Body.Bytes(), &response)
138 require.NoError(t, err)
139
140 // Should only return 2 posts (within last day)
141 assert.Len(t, response.Feed, 2)
142
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)
146 })
147
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)
152
153 assert.Equal(t, http.StatusOK, rec.Code)
154
155 var response communityFeeds.FeedResponse
156 err = json.Unmarshal(rec.Body.Bytes(), &response)
157 require.NoError(t, err)
158
159 // Should return all 3 posts
160 assert.Len(t, response.Feed, 3)
161
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)
165 })
166}
167
168// TestGetCommunityFeed_New tests chronological sorting
169func TestGetCommunityFeed_New(t *testing.T) {
170 if testing.Short() {
171 t.Skip("Skipping integration test in short mode")
172 }
173
174 db := setupTestDB(t)
175 t.Cleanup(func() { _ = db.Close() })
176
177 // Setup services
178 feedRepo := postgres.NewCommunityFeedRepository(db)
179 communityRepo := postgres.NewCommunityRepository(db)
180 communityService := communities.NewCommunityService(
181 communityRepo,
182 "http://localhost:3001",
183 "did:web:test.coves.social",
184 "test.coves.social",
185 nil,
186 )
187 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
188 handler := communityFeed.NewGetCommunityHandler(feedService)
189
190 // Setup test data
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)
194
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)
199
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)
203
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)
208
209 // Assertions
210 assert.Equal(t, http.StatusOK, rec.Code)
211
212 var response communityFeeds.FeedResponse
213 err = json.Unmarshal(rec.Body.Bytes(), &response)
214 require.NoError(t, err)
215
216 assert.Len(t, response.Feed, 3)
217
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)
222}
223
224// TestGetCommunityFeed_Pagination tests cursor-based pagination
225func TestGetCommunityFeed_Pagination(t *testing.T) {
226 if testing.Short() {
227 t.Skip("Skipping integration test in short mode")
228 }
229
230 db := setupTestDB(t)
231 t.Cleanup(func() { _ = db.Close() })
232
233 // Setup services
234 feedRepo := postgres.NewCommunityFeedRepository(db)
235 communityRepo := postgres.NewCommunityRepository(db)
236 communityService := communities.NewCommunityService(
237 communityRepo,
238 "http://localhost:3001",
239 "did:web:test.coves.social",
240 "test.coves.social",
241 nil,
242 )
243 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
244 handler := communityFeed.NewGetCommunityHandler(feedService)
245
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)
250
251 // Create 25 posts
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))
254 }
255
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)
260
261 assert.Equal(t, http.StatusOK, rec.Code)
262
263 var page1 communityFeeds.FeedResponse
264 err = json.Unmarshal(rec.Body.Bytes(), &page1)
265 require.NoError(t, err)
266
267 assert.Len(t, page1.Feed, 10)
268 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
269
270 t.Logf("Page 1 cursor: %s", *page1.Cursor)
271
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)
276
277 if rec.Code != http.StatusOK {
278 t.Logf("Page 2 error: %s", rec.Body.String())
279 }
280 assert.Equal(t, http.StatusOK, rec.Code)
281
282 var page2 communityFeeds.FeedResponse
283 err = json.Unmarshal(rec.Body.Bytes(), &page2)
284 require.NoError(t, err)
285
286 assert.Len(t, page2.Feed, 10)
287
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
292 }
293 for _, p := range page2.Feed {
294 assert.False(t, page1URIs[p.Post.URI], "Found duplicate post between pages")
295 }
296
297 // Page 3: Should have remaining 5 posts
298 if page2.Cursor == nil {
299 t.Fatal("Expected cursor for page 3, got nil")
300 }
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)
304
305 assert.Equal(t, http.StatusOK, rec.Code)
306
307 var page3 communityFeeds.FeedResponse
308 err = json.Unmarshal(rec.Body.Bytes(), &page3)
309 require.NoError(t, err)
310
311 assert.Len(t, page3.Feed, 5)
312 assert.Nil(t, page3.Cursor, "Should not have cursor on last page")
313}
314
315// TestGetCommunityFeed_InvalidCommunity tests error handling for invalid community
316func TestGetCommunityFeed_InvalidCommunity(t *testing.T) {
317 if testing.Short() {
318 t.Skip("Skipping integration test in short mode")
319 }
320
321 db := setupTestDB(t)
322 t.Cleanup(func() { _ = db.Close() })
323
324 // Setup services
325 feedRepo := postgres.NewCommunityFeedRepository(db)
326 communityRepo := postgres.NewCommunityRepository(db)
327 communityService := communities.NewCommunityService(
328 communityRepo,
329 "http://localhost:3001",
330 "did:web:test.coves.social",
331 "test.coves.social",
332 nil,
333 )
334 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
335 handler := communityFeed.NewGetCommunityHandler(feedService)
336
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)
341
342 assert.Equal(t, http.StatusNotFound, rec.Code)
343
344 var errResp map[string]interface{}
345 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
346 require.NoError(t, err)
347
348 assert.Equal(t, "CommunityNotFound", errResp["error"])
349}
350
351// TestGetCommunityFeed_InvalidCursor tests cursor validation
352func TestGetCommunityFeed_InvalidCursor(t *testing.T) {
353 if testing.Short() {
354 t.Skip("Skipping integration test in short mode")
355 }
356
357 db := setupTestDB(t)
358 t.Cleanup(func() { _ = db.Close() })
359
360 // Setup services
361 feedRepo := postgres.NewCommunityFeedRepository(db)
362 communityRepo := postgres.NewCommunityRepository(db)
363 communityService := communities.NewCommunityService(
364 communityRepo,
365 "http://localhost:3001",
366 "did:web:test.coves.social",
367 "test.coves.social",
368 nil,
369 )
370 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
371 handler := communityFeed.NewGetCommunityHandler(feedService)
372
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)
377
378 tests := []struct {
379 name string
380 cursor string
381 }{
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
386 }
387
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)
393
394 assert.Equal(t, http.StatusBadRequest, rec.Code)
395
396 var errResp map[string]interface{}
397 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
398 require.NoError(t, err)
399
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)
403 })
404 }
405}
406
407// TestGetCommunityFeed_EmptyFeed tests handling of empty communities
408func TestGetCommunityFeed_EmptyFeed(t *testing.T) {
409 if testing.Short() {
410 t.Skip("Skipping integration test in short mode")
411 }
412
413 db := setupTestDB(t)
414 t.Cleanup(func() { _ = db.Close() })
415
416 // Setup services
417 feedRepo := postgres.NewCommunityFeedRepository(db)
418 communityRepo := postgres.NewCommunityRepository(db)
419 communityService := communities.NewCommunityService(
420 communityRepo,
421 "http://localhost:3001",
422 "did:web:test.coves.social",
423 "test.coves.social",
424 nil,
425 )
426 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
427 handler := communityFeed.NewGetCommunityHandler(feedService)
428
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)
433
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)
437
438 if rec.Code != http.StatusOK {
439 t.Logf("Response body: %s", rec.Body.String())
440 }
441 assert.Equal(t, http.StatusOK, rec.Code)
442
443 var response communityFeeds.FeedResponse
444 err = json.Unmarshal(rec.Body.Bytes(), &response)
445 require.NoError(t, err)
446
447 assert.Len(t, response.Feed, 0)
448 assert.Nil(t, response.Cursor)
449}
450
451// TestGetCommunityFeed_LimitValidation tests limit parameter validation
452func TestGetCommunityFeed_LimitValidation(t *testing.T) {
453 if testing.Short() {
454 t.Skip("Skipping integration test in short mode")
455 }
456
457 db := setupTestDB(t)
458 t.Cleanup(func() { _ = db.Close() })
459
460 // Setup services
461 feedRepo := postgres.NewCommunityFeedRepository(db)
462 communityRepo := postgres.NewCommunityRepository(db)
463 communityService := communities.NewCommunityService(
464 communityRepo,
465 "http://localhost:3001",
466 "did:web:test.coves.social",
467 "test.coves.social",
468 nil,
469 )
470 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
471 handler := communityFeed.NewGetCommunityHandler(feedService)
472
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)
477
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)
482
483 assert.Equal(t, http.StatusBadRequest, rec.Code)
484
485 var errResp map[string]interface{}
486 err := json.Unmarshal(rec.Body.Bytes(), &errResp)
487 require.NoError(t, err)
488
489 assert.Equal(t, "InvalidRequest", errResp["error"])
490 assert.Contains(t, errResp["message"], "limit must not exceed 50")
491 })
492
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)
497
498 // Should succeed with default limit (15)
499 assert.Equal(t, http.StatusOK, rec.Code)
500 })
501}
502
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
505func TestGetCommunityFeed_HotPaginationBug(t *testing.T) {
506 if testing.Short() {
507 t.Skip("Skipping integration test in short mode")
508 }
509
510 db := setupTestDB(t)
511 t.Cleanup(func() { _ = db.Close() })
512
513 // Setup services
514 feedRepo := postgres.NewCommunityFeedRepository(db)
515 communityRepo := postgres.NewCommunityRepository(db)
516 communityService := communities.NewCommunityService(
517 communityRepo,
518 "http://localhost:3001",
519 "did:web:test.coves.social",
520 "test.coves.social",
521 nil,
522 )
523 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
524 handler := communityFeed.NewGetCommunityHandler(feedService)
525
526 // Setup test data
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)
531
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
536 //
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)
539
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))
543
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)
548
549 assert.Equal(t, http.StatusOK, rec.Code)
550
551 var page1 communityFeeds.FeedResponse
552 err = json.Unmarshal(rec.Body.Bytes(), &page1)
553 require.NoError(t, err)
554
555 assert.Len(t, page1.Feed, 1)
556 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
557
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)
562
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)
567
568 if rec.Code != http.StatusOK {
569 t.Fatalf("Page 2 failed: %s", rec.Body.String())
570 }
571
572 var page2 communityFeeds.FeedResponse
573 err = json.Unmarshal(rec.Body.Bytes(), &page2)
574 require.NoError(t, err)
575
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")
580
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)
590 }
591 seenURIs[p.Post.URI] = true
592 }
593
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!")
598 }
599
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))
602}
603
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
606func TestGetCommunityFeed_HotCursorPrecision(t *testing.T) {
607 if testing.Short() {
608 t.Skip("Skipping integration test in short mode")
609 }
610
611 db := setupTestDB(t)
612 t.Cleanup(func() { _ = db.Close() })
613
614 // Setup services
615 feedRepo := postgres.NewCommunityFeedRepository(db)
616 communityRepo := postgres.NewCommunityRepository(db)
617 communityService := communities.NewCommunityService(
618 communityRepo,
619 "http://localhost:3001",
620 "did:web:test.coves.social",
621 "test.coves.social",
622 nil,
623 )
624 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
625 handler := communityFeed.NewGetCommunityHandler(feedService)
626
627 // Setup test data
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)
632
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)
637
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)
640
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))
644
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))
647
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)
652
653 assert.Equal(t, http.StatusOK, rec.Code)
654
655 var page1 communityFeeds.FeedResponse
656 err = json.Unmarshal(rec.Body.Bytes(), &page1)
657 require.NoError(t, err)
658
659 assert.Len(t, page1.Feed, 1)
660 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
661
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)
665
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)
670
671 if rec.Code != http.StatusOK {
672 t.Fatalf("Page 2 failed: %s", rec.Body.String())
673 }
674
675 var page2 communityFeeds.FeedResponse
676 err = json.Unmarshal(rec.Body.Bytes(), &page2)
677 require.NoError(t, err)
678
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")
682
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)
688 }
689
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")
694
695 t.Logf("SUCCESS: All posts with similar hot ranks preserved (precision bug fixed)")
696}
697
698// Helper: createFeedTestCommunity creates a test community and returns its DID
699func 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")
707 if err != nil {
708 return "", err
709 }
710
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))
718
719 return communityDID, err
720}
721
722// Helper: createTestPost creates a test post and returns its URI
723func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title string, score int, createdAt time.Time) string {
724 t.Helper()
725
726 ctx := context.Background()
727
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")
734
735 // Generate URI
736 rkey := fmt.Sprintf("post-%d", time.Now().UnixNano())
737 uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey)
738
739 // Insert post
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)
745
746 return uri
747}