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}