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}