···
12
+
"Coves/internal/api/handlers/timeline"
13
+
"Coves/internal/api/middleware"
14
+
timelineCore "Coves/internal/core/timeline"
15
+
"Coves/internal/db/postgres"
17
+
"github.com/stretchr/testify/assert"
18
+
"github.com/stretchr/testify/require"
21
+
// TestGetTimeline_Basic tests timeline feed shows posts from subscribed communities
22
+
func TestGetTimeline_Basic(t *testing.T) {
23
+
if testing.Short() {
24
+
t.Skip("Skipping integration test in short mode")
27
+
db := setupTestDB(t)
28
+
t.Cleanup(func() { _ = db.Close() })
31
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
32
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
33
+
handler := timeline.NewGetTimelineHandler(timelineService)
35
+
ctx := context.Background()
36
+
testID := time.Now().UnixNano()
37
+
userDID := fmt.Sprintf("did:plc:user-%d", testID)
40
+
_, err := db.ExecContext(ctx, `
41
+
INSERT INTO users (did, handle, pds_url)
43
+
`, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
44
+
require.NoError(t, err)
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)
50
+
community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID))
51
+
require.NoError(t, err)
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)
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)
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))
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)
77
+
assert.Equal(t, http.StatusOK, rec.Code)
79
+
var response timelineCore.TimelineResponse
80
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
81
+
require.NoError(t, err)
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")
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")
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")
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.post.record", 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")
110
+
// TestGetTimeline_HotSort tests hot sorting across multiple communities
111
+
func TestGetTimeline_HotSort(t *testing.T) {
112
+
if testing.Short() {
113
+
t.Skip("Skipping integration test in short mode")
116
+
db := setupTestDB(t)
117
+
t.Cleanup(func() { _ = db.Close() })
120
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
121
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
122
+
handler := timeline.NewGetTimelineHandler(timelineService)
124
+
ctx := context.Background()
125
+
testID := time.Now().UnixNano()
126
+
userDID := fmt.Sprintf("did:plc:user-%d", testID)
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)
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)
139
+
community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID))
140
+
require.NoError(t, err)
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)
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))
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))
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))
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)
166
+
assert.Equal(t, http.StatusOK, rec.Code)
168
+
var response timelineCore.TimelineResponse
169
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
170
+
require.NoError(t, err)
172
+
assert.Len(t, response.Feed, 3, "Timeline should show all posts from subscribed communities")
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)
181
+
// TestGetTimeline_Pagination tests cursor-based pagination
182
+
func TestGetTimeline_Pagination(t *testing.T) {
183
+
if testing.Short() {
184
+
t.Skip("Skipping integration test in short mode")
187
+
db := setupTestDB(t)
188
+
t.Cleanup(func() { _ = db.Close() })
191
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
192
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
193
+
handler := timeline.NewGetTimelineHandler(timelineService)
195
+
ctx := context.Background()
196
+
testID := time.Now().UnixNano()
197
+
userDID := fmt.Sprintf("did:plc:user-%d", testID)
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)
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)
211
+
_, err = db.ExecContext(ctx, `
212
+
INSERT INTO community_subscriptions (user_did, community_did, content_visibility)
214
+
`, userDID, communityDID)
215
+
require.NoError(t, err)
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))
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)
228
+
assert.Equal(t, http.StatusOK, rec.Code)
230
+
var page1 timelineCore.TimelineResponse
231
+
err = json.Unmarshal(rec.Body.Bytes(), &page1)
232
+
require.NoError(t, err)
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")
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)
243
+
assert.Equal(t, http.StatusOK, rec.Code)
245
+
var page2 timelineCore.TimelineResponse
246
+
err = json.Unmarshal(rec.Body.Bytes(), &page2)
247
+
require.NoError(t, err)
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")
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")
257
+
// TestGetTimeline_EmptyWhenNoSubscriptions tests timeline is empty when user has no subscriptions
258
+
func TestGetTimeline_EmptyWhenNoSubscriptions(t *testing.T) {
259
+
if testing.Short() {
260
+
t.Skip("Skipping integration test in short mode")
263
+
db := setupTestDB(t)
264
+
t.Cleanup(func() { _ = db.Close() })
267
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
268
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
269
+
handler := timeline.NewGetTimelineHandler(timelineService)
271
+
ctx := context.Background()
272
+
testID := time.Now().UnixNano()
273
+
userDID := fmt.Sprintf("did:plc:user-%d", testID)
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)
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)
289
+
assert.Equal(t, http.StatusOK, rec.Code)
291
+
var response timelineCore.TimelineResponse
292
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
293
+
require.NoError(t, err)
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")
299
+
// TestGetTimeline_Unauthorized tests timeline requires authentication
300
+
func TestGetTimeline_Unauthorized(t *testing.T) {
301
+
if testing.Short() {
302
+
t.Skip("Skipping integration test in short mode")
305
+
db := setupTestDB(t)
306
+
t.Cleanup(func() { _ = db.Close() })
309
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
310
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
311
+
handler := timeline.NewGetTimelineHandler(timelineService)
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)
318
+
// Should return 401 Unauthorized
319
+
assert.Equal(t, http.StatusUnauthorized, rec.Code)
321
+
var errorResp map[string]string
322
+
err := json.Unmarshal(rec.Body.Bytes(), &errorResp)
323
+
require.NoError(t, err)
325
+
assert.Equal(t, "AuthenticationRequired", errorResp["error"])
328
+
// TestGetTimeline_LimitValidation tests limit parameter validation
329
+
func TestGetTimeline_LimitValidation(t *testing.T) {
330
+
if testing.Short() {
331
+
t.Skip("Skipping integration test in short mode")
334
+
db := setupTestDB(t)
335
+
t.Cleanup(func() { _ = db.Close() })
338
+
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
339
+
timelineService := timelineCore.NewTimelineService(timelineRepo)
340
+
handler := timeline.NewGetTimelineHandler(timelineService)
342
+
ctx := context.Background()
343
+
testID := time.Now().UnixNano()
344
+
userDID := fmt.Sprintf("did:plc:user-%d", testID)
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)
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)
359
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
361
+
var errorResp map[string]string
362
+
err := json.Unmarshal(rec.Body.Bytes(), &errorResp)
363
+
require.NoError(t, err)
365
+
assert.Equal(t, "InvalidRequest", errorResp["error"])
366
+
assert.Contains(t, errorResp["message"], "limit")