A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/api/handlers/timeline" 5 "Coves/internal/api/middleware" 6 "Coves/internal/db/postgres" 7 "context" 8 "encoding/json" 9 "fmt" 10 "net/http" 11 "net/http/httptest" 12 "testing" 13 "time" 14 15 timelineCore "Coves/internal/core/timeline" 16 17 "github.com/stretchr/testify/assert" 18 "github.com/stretchr/testify/require" 19) 20 21// TestGetTimeline_Basic tests timeline feed shows posts from subscribed communities 22func TestGetTimeline_Basic(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 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 32 timelineService := timelineCore.NewTimelineService(timelineRepo) 33 handler := timeline.NewGetTimelineHandler(timelineService) 34 35 ctx := context.Background() 36 testID := time.Now().UnixNano() 37 userDID := fmt.Sprintf("did:plc:user-%d", testID) 38 39 // Create user 40 _, err := db.ExecContext(ctx, ` 41 INSERT INTO users (did, handle, pds_url) 42 VALUES ($1, $2, $3) 43 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 44 require.NoError(t, err) 45 46 // Create two communities 47 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 48 require.NoError(t, err) 49 50 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 51 require.NoError(t, err) 52 53 // Create a third community that user is NOT subscribed to 54 community3DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cooking-%d", testID), fmt.Sprintf("charlie-%d.test", testID)) 55 require.NoError(t, err) 56 57 // Subscribe user to community1 and community2 (but not community3) 58 _, err = db.ExecContext(ctx, ` 59 INSERT INTO community_subscriptions (user_did, community_did, content_visibility) 60 VALUES ($1, $2, 3), ($1, $3, 3) 61 `, userDID, community1DID, community2DID) 62 require.NoError(t, err) 63 64 // Create posts in all three communities 65 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post 1", 50, time.Now().Add(-1*time.Hour)) 66 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Tech post 1", 30, time.Now().Add(-2*time.Hour)) 67 post3URI := createTestPost(t, db, community3DID, "did:plc:charlie", "Cooking post (should not appear)", 100, time.Now().Add(-30*time.Minute)) 68 post4URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post 2", 20, time.Now().Add(-3*time.Hour)) 69 70 // Request timeline with auth 71 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil) 72 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 73 rec := httptest.NewRecorder() 74 handler.HandleGetTimeline(rec, req) 75 76 // Assertions 77 assert.Equal(t, http.StatusOK, rec.Code) 78 79 var response timelineCore.TimelineResponse 80 err = json.Unmarshal(rec.Body.Bytes(), &response) 81 require.NoError(t, err) 82 83 // Should show 3 posts (from community1 and community2, NOT community3) 84 assert.Len(t, response.Feed, 3, "Timeline should show posts from subscribed communities only") 85 86 // Verify correct posts are shown 87 uris := []string{response.Feed[0].Post.URI, response.Feed[1].Post.URI, response.Feed[2].Post.URI} 88 assert.Contains(t, uris, post1URI, "Should contain gaming post 1") 89 assert.Contains(t, uris, post2URI, "Should contain tech post 1") 90 assert.Contains(t, uris, post4URI, "Should contain gaming post 2") 91 assert.NotContains(t, uris, post3URI, "Should NOT contain post from unsubscribed community") 92 93 // Verify posts are sorted by creation time (newest first for "new" sort) 94 assert.Equal(t, post1URI, response.Feed[0].Post.URI, "Newest post should be first") 95 assert.Equal(t, post2URI, response.Feed[1].Post.URI, "Second newest post") 96 assert.Equal(t, post4URI, response.Feed[2].Post.URI, "Oldest post should be last") 97 98 // Verify Record field is populated (schema compliance) 99 for i, feedPost := range response.Feed { 100 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) 101 record, ok := feedPost.Post.Record.(map[string]interface{}) 102 require.True(t, ok, "Record should be a map") 103 assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type") 104 assert.NotEmpty(t, record["community"], "Record should have community") 105 assert.NotEmpty(t, record["author"], "Record should have author") 106 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt") 107 } 108} 109 110// TestGetTimeline_HotSort tests hot sorting across multiple communities 111func TestGetTimeline_HotSort(t *testing.T) { 112 if testing.Short() { 113 t.Skip("Skipping integration test in short mode") 114 } 115 116 db := setupTestDB(t) 117 t.Cleanup(func() { _ = db.Close() }) 118 119 // Setup services 120 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 121 timelineService := timelineCore.NewTimelineService(timelineRepo) 122 handler := timeline.NewGetTimelineHandler(timelineService) 123 124 ctx := context.Background() 125 testID := time.Now().UnixNano() 126 userDID := fmt.Sprintf("did:plc:user-%d", testID) 127 128 // Create user 129 _, err := db.ExecContext(ctx, ` 130 INSERT INTO users (did, handle, pds_url) 131 VALUES ($1, $2, $3) 132 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 133 require.NoError(t, err) 134 135 // Create communities 136 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 137 require.NoError(t, err) 138 139 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 140 require.NoError(t, err) 141 142 // Subscribe to both 143 _, err = db.ExecContext(ctx, ` 144 INSERT INTO community_subscriptions (user_did, community_did, content_visibility) 145 VALUES ($1, $2, 3), ($1, $3, 3) 146 `, userDID, community1DID, community2DID) 147 require.NoError(t, err) 148 149 // Create posts with different scores and ages 150 // Recent with medium score from gaming (should rank high) 151 createTestPost(t, db, community1DID, "did:plc:alice", "Recent trending gaming", 50, time.Now().Add(-1*time.Hour)) 152 153 // Old with high score from tech (age penalty) 154 createTestPost(t, db, community2DID, "did:plc:bob", "Old popular tech", 100, time.Now().Add(-24*time.Hour)) 155 156 // Very recent with low score from gaming 157 createTestPost(t, db, community1DID, "did:plc:charlie", "Brand new gaming", 5, time.Now().Add(-10*time.Minute)) 158 159 // Request hot timeline 160 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=hot&limit=10", nil) 161 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 162 rec := httptest.NewRecorder() 163 handler.HandleGetTimeline(rec, req) 164 165 // Assertions 166 assert.Equal(t, http.StatusOK, rec.Code) 167 168 var response timelineCore.TimelineResponse 169 err = json.Unmarshal(rec.Body.Bytes(), &response) 170 require.NoError(t, err) 171 172 assert.Len(t, response.Feed, 3, "Timeline should show all posts from subscribed communities") 173 174 // All posts should have community context 175 for _, feedPost := range response.Feed { 176 assert.NotNil(t, feedPost.Post.Community, "Post should have community context") 177 assert.Contains(t, []string{community1DID, community2DID}, feedPost.Post.Community.DID) 178 } 179} 180 181// TestGetTimeline_Pagination tests cursor-based pagination 182func TestGetTimeline_Pagination(t *testing.T) { 183 if testing.Short() { 184 t.Skip("Skipping integration test in short mode") 185 } 186 187 db := setupTestDB(t) 188 t.Cleanup(func() { _ = db.Close() }) 189 190 // Setup services 191 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 192 timelineService := timelineCore.NewTimelineService(timelineRepo) 193 handler := timeline.NewGetTimelineHandler(timelineService) 194 195 ctx := context.Background() 196 testID := time.Now().UnixNano() 197 userDID := fmt.Sprintf("did:plc:user-%d", testID) 198 199 // Create user 200 _, err := db.ExecContext(ctx, ` 201 INSERT INTO users (did, handle, pds_url) 202 VALUES ($1, $2, $3) 203 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 204 require.NoError(t, err) 205 206 // Create community 207 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 208 require.NoError(t, err) 209 210 // Subscribe 211 _, err = db.ExecContext(ctx, ` 212 INSERT INTO community_subscriptions (user_did, community_did, content_visibility) 213 VALUES ($1, $2, 3) 214 `, userDID, communityDID) 215 require.NoError(t, err) 216 217 // Create 5 posts 218 for i := 0; i < 5; i++ { 219 createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), 10-i, time.Now().Add(-time.Duration(i)*time.Hour)) 220 } 221 222 // First page: limit 2 223 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=2", nil) 224 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 225 rec := httptest.NewRecorder() 226 handler.HandleGetTimeline(rec, req) 227 228 assert.Equal(t, http.StatusOK, rec.Code) 229 230 var page1 timelineCore.TimelineResponse 231 err = json.Unmarshal(rec.Body.Bytes(), &page1) 232 require.NoError(t, err) 233 234 assert.Len(t, page1.Feed, 2, "First page should have 2 posts") 235 assert.NotNil(t, page1.Cursor, "Should have cursor for next page") 236 237 // Second page: use cursor 238 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getTimeline?sort=new&limit=2&cursor=%s", *page1.Cursor), nil) 239 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 240 rec = httptest.NewRecorder() 241 handler.HandleGetTimeline(rec, req) 242 243 assert.Equal(t, http.StatusOK, rec.Code) 244 245 var page2 timelineCore.TimelineResponse 246 err = json.Unmarshal(rec.Body.Bytes(), &page2) 247 require.NoError(t, err) 248 249 assert.Len(t, page2.Feed, 2, "Second page should have 2 posts") 250 assert.NotNil(t, page2.Cursor, "Should have cursor for next page") 251 252 // Verify no overlap 253 assert.NotEqual(t, page1.Feed[0].Post.URI, page2.Feed[0].Post.URI, "Pages should not overlap") 254 assert.NotEqual(t, page1.Feed[1].Post.URI, page2.Feed[1].Post.URI, "Pages should not overlap") 255} 256 257// TestGetTimeline_EmptyWhenNoSubscriptions tests timeline is empty when user has no subscriptions 258func TestGetTimeline_EmptyWhenNoSubscriptions(t *testing.T) { 259 if testing.Short() { 260 t.Skip("Skipping integration test in short mode") 261 } 262 263 db := setupTestDB(t) 264 t.Cleanup(func() { _ = db.Close() }) 265 266 // Setup services 267 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 268 timelineService := timelineCore.NewTimelineService(timelineRepo) 269 handler := timeline.NewGetTimelineHandler(timelineService) 270 271 ctx := context.Background() 272 testID := time.Now().UnixNano() 273 userDID := fmt.Sprintf("did:plc:user-%d", testID) 274 275 // Create user (but don't subscribe to any communities) 276 _, err := db.ExecContext(ctx, ` 277 INSERT INTO users (did, handle, pds_url) 278 VALUES ($1, $2, $3) 279 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 280 require.NoError(t, err) 281 282 // Request timeline 283 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil) 284 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 285 rec := httptest.NewRecorder() 286 handler.HandleGetTimeline(rec, req) 287 288 // Assertions 289 assert.Equal(t, http.StatusOK, rec.Code) 290 291 var response timelineCore.TimelineResponse 292 err = json.Unmarshal(rec.Body.Bytes(), &response) 293 require.NoError(t, err) 294 295 assert.Empty(t, response.Feed, "Timeline should be empty when user has no subscriptions") 296 assert.Nil(t, response.Cursor, "Should not have cursor when no results") 297} 298 299// TestGetTimeline_Unauthorized tests timeline requires authentication 300func TestGetTimeline_Unauthorized(t *testing.T) { 301 if testing.Short() { 302 t.Skip("Skipping integration test in short mode") 303 } 304 305 db := setupTestDB(t) 306 t.Cleanup(func() { _ = db.Close() }) 307 308 // Setup services 309 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 310 timelineService := timelineCore.NewTimelineService(timelineRepo) 311 handler := timeline.NewGetTimelineHandler(timelineService) 312 313 // Request timeline WITHOUT auth context 314 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil) 315 rec := httptest.NewRecorder() 316 handler.HandleGetTimeline(rec, req) 317 318 // Should return 401 Unauthorized 319 assert.Equal(t, http.StatusUnauthorized, rec.Code) 320 321 var errorResp map[string]string 322 err := json.Unmarshal(rec.Body.Bytes(), &errorResp) 323 require.NoError(t, err) 324 325 assert.Equal(t, "AuthenticationRequired", errorResp["error"]) 326} 327 328// TestGetTimeline_LimitValidation tests limit parameter validation 329func TestGetTimeline_LimitValidation(t *testing.T) { 330 if testing.Short() { 331 t.Skip("Skipping integration test in short mode") 332 } 333 334 db := setupTestDB(t) 335 t.Cleanup(func() { _ = db.Close() }) 336 337 // Setup services 338 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 339 timelineService := timelineCore.NewTimelineService(timelineRepo) 340 handler := timeline.NewGetTimelineHandler(timelineService) 341 342 ctx := context.Background() 343 testID := time.Now().UnixNano() 344 userDID := fmt.Sprintf("did:plc:user-%d", testID) 345 346 // Create user 347 _, err := db.ExecContext(ctx, ` 348 INSERT INTO users (did, handle, pds_url) 349 VALUES ($1, $2, $3) 350 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 351 require.NoError(t, err) 352 353 t.Run("Limit exceeds maximum", func(t *testing.T) { 354 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=100", nil) 355 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 356 rec := httptest.NewRecorder() 357 handler.HandleGetTimeline(rec, req) 358 359 assert.Equal(t, http.StatusBadRequest, rec.Code) 360 361 var errorResp map[string]string 362 err := json.Unmarshal(rec.Body.Bytes(), &errorResp) 363 require.NoError(t, err) 364 365 assert.Equal(t, "InvalidRequest", errorResp["error"]) 366 assert.Contains(t, errorResp["message"], "limit") 367 }) 368} 369 370// TestGetTimeline_MultiCommunity_E2E tests the complete multi-community timeline flow 371// This is the comprehensive E2E test specified in PRD_ALPHA_GO_LIVE.md (lines 236-246) 372// 373// Test Coverage: 374// - Creates 3+ communities with different posts 375// - Subscribes user to all communities 376// - Creates posts with varied ages and scores across communities 377// - Verifies timeline shows posts from ALL subscribed communities 378// - Tests all sorting modes (hot, top, new) across communities 379// - Ensures proper aggregation and no cross-contamination 380func TestGetTimeline_MultiCommunity_E2E(t *testing.T) { 381 if testing.Short() { 382 t.Skip("Skipping integration test in short mode") 383 } 384 385 db := setupTestDB(t) 386 t.Cleanup(func() { _ = db.Close() }) 387 388 // Setup services 389 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret") 390 timelineService := timelineCore.NewTimelineService(timelineRepo) 391 handler := timeline.NewGetTimelineHandler(timelineService) 392 393 ctx := context.Background() 394 testID := time.Now().UnixNano() 395 userDID := fmt.Sprintf("did:plc:user-%d", testID) 396 397 // Create test user 398 _, err := db.ExecContext(ctx, ` 399 INSERT INTO users (did, handle, pds_url) 400 VALUES ($1, $2, $3) 401 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social") 402 require.NoError(t, err) 403 404 // Create 4 communities (user will subscribe to 3, not subscribe to 1) 405 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 406 require.NoError(t, err, "Failed to create gaming community") 407 408 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 409 require.NoError(t, err, "Failed to create tech community") 410 411 community3DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("music-%d", testID), fmt.Sprintf("charlie-%d.test", testID)) 412 require.NoError(t, err, "Failed to create music community") 413 414 community4DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cooking-%d", testID), fmt.Sprintf("dave-%d.test", testID)) 415 require.NoError(t, err, "Failed to create cooking community (unsubscribed)") 416 417 t.Logf("Created 4 communities: gaming=%s, tech=%s, music=%s, cooking=%s", 418 community1DID, community2DID, community3DID, community4DID) 419 420 // Subscribe user to first 3 communities (NOT community4) 421 _, err = db.ExecContext(ctx, ` 422 INSERT INTO community_subscriptions (user_did, community_did, content_visibility) 423 VALUES ($1, $2, 3), ($1, $3, 3), ($1, $4, 3) 424 `, userDID, community1DID, community2DID, community3DID) 425 require.NoError(t, err, "Failed to create subscriptions") 426 427 t.Log("✓ User subscribed to gaming, tech, and music communities") 428 429 // Create posts across all 4 communities with varied ages and scores 430 // This tests that timeline correctly: 431 // 1. Aggregates posts from multiple subscribed communities 432 // 2. Excludes posts from unsubscribed communities 433 // 3. Handles different sorting algorithms across community boundaries 434 435 // Gaming community posts (2 posts) 436 gamingPost1 := createTestPost(t, db, community1DID, "did:plc:gamer1", "Epic gaming moment", 100, time.Now().Add(-2*time.Hour)) 437 gamingPost2 := createTestPost(t, db, community1DID, "did:plc:gamer2", "New game release", 75, time.Now().Add(-30*time.Minute)) 438 439 // Tech community posts (3 posts) 440 techPost1 := createTestPost(t, db, community2DID, "did:plc:dev1", "Golang best practices", 150, time.Now().Add(-4*time.Hour)) 441 techPost2 := createTestPost(t, db, community2DID, "did:plc:dev2", "atProto deep dive", 200, time.Now().Add(-1*time.Hour)) 442 techPost3 := createTestPost(t, db, community2DID, "did:plc:dev3", "Docker tips", 50, time.Now().Add(-15*time.Minute)) 443 444 // Music community posts (2 posts) 445 musicPost1 := createTestPost(t, db, community3DID, "did:plc:artist1", "Album review", 80, time.Now().Add(-3*time.Hour)) 446 musicPost2 := createTestPost(t, db, community3DID, "did:plc:artist2", "Live concert tonight", 120, time.Now().Add(-10*time.Minute)) 447 448 // Cooking community posts (should NOT appear - user not subscribed) 449 cookingPost := createTestPost(t, db, community4DID, "did:plc:chef1", "Best pizza recipe", 500, time.Now().Add(-5*time.Minute)) 450 451 t.Logf("✓ Created 8 posts: 2 gaming, 3 tech, 2 music, 1 cooking (unsubscribed)") 452 453 // Test 1: NEW sorting - chronological order across communities 454 t.Run("NEW sort - chronological across all subscribed communities", func(t *testing.T) { 455 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=20", nil) 456 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 457 rec := httptest.NewRecorder() 458 handler.HandleGetTimeline(rec, req) 459 460 assert.Equal(t, http.StatusOK, rec.Code) 461 462 var response timelineCore.TimelineResponse 463 err := json.Unmarshal(rec.Body.Bytes(), &response) 464 require.NoError(t, err) 465 466 // Should have exactly 7 posts (excluding cooking community) 467 assert.Len(t, response.Feed, 7, "Timeline should show 7 posts from 3 subscribed communities") 468 469 // Verify chronological order (newest first) 470 expectedOrder := []string{ 471 musicPost2, // 10 minutes ago 472 techPost3, // 15 minutes ago 473 gamingPost2, // 30 minutes ago 474 techPost2, // 1 hour ago 475 gamingPost1, // 2 hours ago 476 musicPost1, // 3 hours ago 477 techPost1, // 4 hours ago 478 } 479 480 for i, expectedURI := range expectedOrder { 481 assert.Equal(t, expectedURI, response.Feed[i].Post.URI, 482 "Post %d should be %s in chronological order", i, expectedURI) 483 } 484 485 // Verify cooking post is NOT present 486 for _, feedPost := range response.Feed { 487 assert.NotEqual(t, cookingPost, feedPost.Post.URI, 488 "Cooking post from unsubscribed community should NOT appear") 489 } 490 491 // Verify each post has community context from the correct community 492 communityCountsByDID := make(map[string]int) 493 for _, feedPost := range response.Feed { 494 require.NotNil(t, feedPost.Post.Community, "Post should have community context") 495 communityCountsByDID[feedPost.Post.Community.DID]++ 496 } 497 498 assert.Equal(t, 2, communityCountsByDID[community1DID], "Should have 2 gaming posts") 499 assert.Equal(t, 3, communityCountsByDID[community2DID], "Should have 3 tech posts") 500 assert.Equal(t, 2, communityCountsByDID[community3DID], "Should have 2 music posts") 501 assert.Equal(t, 0, communityCountsByDID[community4DID], "Should have 0 cooking posts") 502 503 t.Log("✓ NEW sort works correctly across multiple communities") 504 }) 505 506 // Test 2: HOT sorting - balances recency and score across communities 507 t.Run("HOT sort - recency+score algorithm across communities", func(t *testing.T) { 508 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=hot&limit=20", nil) 509 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 510 rec := httptest.NewRecorder() 511 handler.HandleGetTimeline(rec, req) 512 513 assert.Equal(t, http.StatusOK, rec.Code) 514 515 var response timelineCore.TimelineResponse 516 err := json.Unmarshal(rec.Body.Bytes(), &response) 517 require.NoError(t, err) 518 519 // Should still have exactly 7 posts 520 assert.Len(t, response.Feed, 7, "Timeline should show 7 posts from 3 subscribed communities") 521 522 // Hot algorithm should rank recent high-scoring posts higher 523 // techPost2: 1 hour old, score 200 - should rank very high 524 // musicPost2: 10 minutes old, score 120 - should rank high (recent + good score) 525 // gamingPost1: 2 hours old, score 100 - should rank medium 526 // techPost1: 4 hours old, score 150 - age penalty 527 528 // Verify top post is one of the high hot-rank posts 529 topPostURIs := []string{musicPost2, techPost2, gamingPost2} 530 assert.Contains(t, topPostURIs, response.Feed[0].Post.URI, 531 "Top post should be one of the recent high-scoring posts") 532 533 // Verify all posts are from subscribed communities 534 for _, feedPost := range response.Feed { 535 assert.Contains(t, []string{community1DID, community2DID, community3DID}, 536 feedPost.Post.Community.DID, 537 "All posts should be from subscribed communities") 538 assert.NotEqual(t, cookingPost, feedPost.Post.URI, 539 "Cooking post should NOT appear") 540 } 541 542 t.Log("✓ HOT sort works correctly across multiple communities") 543 }) 544 545 // Test 3: TOP sorting with timeframe - highest scores across communities 546 t.Run("TOP sort - highest scores across all communities", func(t *testing.T) { 547 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=all&limit=20", nil) 548 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 549 rec := httptest.NewRecorder() 550 handler.HandleGetTimeline(rec, req) 551 552 assert.Equal(t, http.StatusOK, rec.Code) 553 554 var response timelineCore.TimelineResponse 555 err := json.Unmarshal(rec.Body.Bytes(), &response) 556 require.NoError(t, err) 557 558 // Should still have exactly 7 posts 559 assert.Len(t, response.Feed, 7, "Timeline should show 7 posts from 3 subscribed communities") 560 561 // Verify top-ranked posts by score (highest first) 562 // techPost2: 200 score 563 // techPost1: 150 score 564 // musicPost2: 120 score 565 // gamingPost1: 100 score 566 // musicPost1: 80 score 567 // gamingPost2: 75 score 568 // techPost3: 50 score 569 570 assert.Equal(t, techPost2, response.Feed[0].Post.URI, "Top post should be techPost2 (score 200)") 571 assert.Equal(t, techPost1, response.Feed[1].Post.URI, "Second post should be techPost1 (score 150)") 572 assert.Equal(t, musicPost2, response.Feed[2].Post.URI, "Third post should be musicPost2 (score 120)") 573 574 // Verify scores are descending 575 for i := 0; i < len(response.Feed)-1; i++ { 576 currentScore := response.Feed[i].Post.Stats.Score 577 nextScore := response.Feed[i+1].Post.Stats.Score 578 assert.GreaterOrEqual(t, currentScore, nextScore, 579 "Scores should be in descending order (post %d score=%d, post %d score=%d)", 580 i, currentScore, i+1, nextScore) 581 } 582 583 // Verify cooking post is NOT present (even though it has highest score) 584 for _, feedPost := range response.Feed { 585 assert.NotEqual(t, cookingPost, feedPost.Post.URI, 586 "Cooking post should NOT appear even with high score") 587 } 588 589 t.Log("✓ TOP sort works correctly across multiple communities") 590 }) 591 592 // Test 4: TOP with day timeframe - filters old posts 593 t.Run("TOP sort with day timeframe - filters across communities", func(t *testing.T) { 594 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=day&limit=20", nil) 595 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 596 rec := httptest.NewRecorder() 597 handler.HandleGetTimeline(rec, req) 598 599 assert.Equal(t, http.StatusOK, rec.Code) 600 601 var response timelineCore.TimelineResponse 602 err := json.Unmarshal(rec.Body.Bytes(), &response) 603 require.NoError(t, err) 604 605 // All our test posts are within the last day, so should have all 7 606 assert.Len(t, response.Feed, 7, "All posts are within last day") 607 608 // Verify all posts are within last 24 hours 609 dayAgo := time.Now().Add(-24 * time.Hour) 610 for _, feedPost := range response.Feed { 611 postTime := feedPost.Post.IndexedAt 612 assert.True(t, postTime.After(dayAgo), 613 "Post should be within last 24 hours") 614 } 615 616 t.Log("✓ TOP sort with timeframe works correctly across multiple communities") 617 }) 618 619 // Test 5: Pagination works across multiple communities 620 t.Run("Pagination across multiple communities", func(t *testing.T) { 621 // First page: limit 3 622 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=3", nil) 623 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 624 rec := httptest.NewRecorder() 625 handler.HandleGetTimeline(rec, req) 626 627 assert.Equal(t, http.StatusOK, rec.Code) 628 629 var page1 timelineCore.TimelineResponse 630 err := json.Unmarshal(rec.Body.Bytes(), &page1) 631 require.NoError(t, err) 632 633 assert.Len(t, page1.Feed, 3, "First page should have 3 posts") 634 assert.NotNil(t, page1.Cursor, "Should have cursor for next page") 635 636 // Second page 637 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getTimeline?sort=new&limit=3&cursor=%s", *page1.Cursor), nil) 638 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 639 rec = httptest.NewRecorder() 640 handler.HandleGetTimeline(rec, req) 641 642 assert.Equal(t, http.StatusOK, rec.Code) 643 644 var page2 timelineCore.TimelineResponse 645 err = json.Unmarshal(rec.Body.Bytes(), &page2) 646 require.NoError(t, err) 647 648 assert.Len(t, page2.Feed, 3, "Second page should have 3 posts") 649 assert.NotNil(t, page2.Cursor, "Should have cursor for third page") 650 651 // Verify no overlap between pages 652 page1URIs := make(map[string]bool) 653 for _, p := range page1.Feed { 654 page1URIs[p.Post.URI] = true 655 } 656 for _, p := range page2.Feed { 657 assert.False(t, page1URIs[p.Post.URI], "Pages should not overlap") 658 } 659 660 // Third page (remaining post) 661 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getTimeline?sort=new&limit=3&cursor=%s", *page2.Cursor), nil) 662 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 663 rec = httptest.NewRecorder() 664 handler.HandleGetTimeline(rec, req) 665 666 assert.Equal(t, http.StatusOK, rec.Code) 667 668 var page3 timelineCore.TimelineResponse 669 err = json.Unmarshal(rec.Body.Bytes(), &page3) 670 require.NoError(t, err) 671 672 assert.Len(t, page3.Feed, 1, "Third page should have 1 remaining post") 673 assert.Nil(t, page3.Cursor, "Should not have cursor on last page") 674 675 t.Log("✓ Pagination works correctly across multiple communities") 676 }) 677 678 // Test 6: Verify post record schema compliance across communities 679 t.Run("Record schema compliance across communities", func(t *testing.T) { 680 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=20", nil) 681 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID)) 682 rec := httptest.NewRecorder() 683 handler.HandleGetTimeline(rec, req) 684 685 assert.Equal(t, http.StatusOK, rec.Code) 686 687 var response timelineCore.TimelineResponse 688 err := json.Unmarshal(rec.Body.Bytes(), &response) 689 require.NoError(t, err) 690 691 // Verify every post has proper Record structure 692 for i, feedPost := range response.Feed { 693 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) 694 695 record, ok := feedPost.Post.Record.(map[string]interface{}) 696 require.True(t, ok, "Record should be a map") 697 698 assert.Equal(t, "social.coves.community.post", record["$type"], 699 "Record should have correct $type") 700 assert.NotEmpty(t, record["community"], "Record should have community") 701 assert.NotEmpty(t, record["author"], "Record should have author") 702 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt") 703 704 // Verify community reference 705 assert.NotNil(t, feedPost.Post.Community, "Post should have community reference") 706 assert.NotEmpty(t, feedPost.Post.Community.DID, "Community should have DID") 707 assert.NotEmpty(t, feedPost.Post.Community.Handle, "Community should have handle") 708 assert.NotEmpty(t, feedPost.Post.Community.Name, "Community should have name") 709 710 // Verify community DID matches one of our subscribed communities 711 assert.Contains(t, []string{community1DID, community2DID, community3DID}, 712 feedPost.Post.Community.DID, 713 "Post should be from one of the subscribed communities") 714 } 715 716 t.Log("✓ All posts have proper record schema and community references") 717 }) 718 719 t.Log("\n✅ Multi-Community Timeline E2E Test Complete!") 720 t.Log("Summary:") 721 t.Log(" ✓ Created 4 communities (3 subscribed, 1 unsubscribed)") 722 t.Log(" ✓ Created 8 posts across communities (7 in subscribed, 1 in unsubscribed)") 723 t.Log(" ✓ NEW sort: Chronological order across all subscribed communities") 724 t.Log(" ✓ HOT sort: Recency+score algorithm works across communities") 725 t.Log(" ✓ TOP sort: Highest scores across communities (with timeframe filtering)") 726 t.Log(" ✓ Pagination: Works correctly across community boundaries") 727 t.Log(" ✓ Schema: All posts have proper record structure and community refs") 728 t.Log(" ✓ Security: Unsubscribed community posts correctly excluded") 729}