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}