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