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