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