A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10 "time"
11
12 "Coves/internal/api/handlers/timeline"
13 "Coves/internal/api/middleware"
14 "Coves/internal/db/postgres"
15
16 timelineCore "Coves/internal/core/timeline"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20)
21
22// TestGetTimeline_Basic tests timeline feed shows posts from subscribed communities
23func TestGetTimeline_Basic(t *testing.T) {
24 if testing.Short() {
25 t.Skip("Skipping integration test in short mode")
26 }
27
28 db := setupTestDB(t)
29 t.Cleanup(func() { _ = db.Close() })
30
31 // Setup services
32 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
33 timelineService := timelineCore.NewTimelineService(timelineRepo)
34 handler := timeline.NewGetTimelineHandler(timelineService)
35
36 ctx := context.Background()
37 testID := time.Now().UnixNano()
38 userDID := fmt.Sprintf("did:plc:user-%d", testID)
39
40 // Create user
41 _, err := db.ExecContext(ctx, `
42 INSERT INTO users (did, handle, pds_url)
43 VALUES ($1, $2, $3)
44 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
45 require.NoError(t, err)
46
47 // Create two communities
48 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID))
49 require.NoError(t, err)
50
51 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID))
52 require.NoError(t, err)
53
54 // Create a third community that user is NOT subscribed to
55 community3DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cooking-%d", testID), fmt.Sprintf("charlie-%d.test", testID))
56 require.NoError(t, err)
57
58 // Subscribe user to community1 and community2 (but not community3)
59 _, err = db.ExecContext(ctx, `
60 INSERT INTO community_subscriptions (user_did, community_did, content_visibility)
61 VALUES ($1, $2, 3), ($1, $3, 3)
62 `, userDID, community1DID, community2DID)
63 require.NoError(t, err)
64
65 // Create posts in all three communities
66 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post 1", 50, time.Now().Add(-1*time.Hour))
67 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Tech post 1", 30, time.Now().Add(-2*time.Hour))
68 post3URI := createTestPost(t, db, community3DID, "did:plc:charlie", "Cooking post (should not appear)", 100, time.Now().Add(-30*time.Minute))
69 post4URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post 2", 20, time.Now().Add(-3*time.Hour))
70
71 // Request timeline with auth
72 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
73 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
74 rec := httptest.NewRecorder()
75 handler.HandleGetTimeline(rec, req)
76
77 // Assertions
78 assert.Equal(t, http.StatusOK, rec.Code)
79
80 var response timelineCore.TimelineResponse
81 err = json.Unmarshal(rec.Body.Bytes(), &response)
82 require.NoError(t, err)
83
84 // Should show 3 posts (from community1 and community2, NOT community3)
85 assert.Len(t, response.Feed, 3, "Timeline should show posts from subscribed communities only")
86
87 // Verify correct posts are shown
88 uris := []string{response.Feed[0].Post.URI, response.Feed[1].Post.URI, response.Feed[2].Post.URI}
89 assert.Contains(t, uris, post1URI, "Should contain gaming post 1")
90 assert.Contains(t, uris, post2URI, "Should contain tech post 1")
91 assert.Contains(t, uris, post4URI, "Should contain gaming post 2")
92 assert.NotContains(t, uris, post3URI, "Should NOT contain post from unsubscribed community")
93
94 // Verify posts are sorted by creation time (newest first for "new" sort)
95 assert.Equal(t, post1URI, response.Feed[0].Post.URI, "Newest post should be first")
96 assert.Equal(t, post2URI, response.Feed[1].Post.URI, "Second newest post")
97 assert.Equal(t, post4URI, response.Feed[2].Post.URI, "Oldest post should be last")
98
99 // Verify Record field is populated (schema compliance)
100 for i, feedPost := range response.Feed {
101 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
102 record, ok := feedPost.Post.Record.(map[string]interface{})
103 require.True(t, ok, "Record should be a map")
104 assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
105 assert.NotEmpty(t, record["community"], "Record should have community")
106 assert.NotEmpty(t, record["author"], "Record should have author")
107 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
108 }
109}
110
111// TestGetTimeline_HotSort tests hot sorting across multiple communities
112func TestGetTimeline_HotSort(t *testing.T) {
113 if testing.Short() {
114 t.Skip("Skipping integration test in short mode")
115 }
116
117 db := setupTestDB(t)
118 t.Cleanup(func() { _ = db.Close() })
119
120 // Setup services
121 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
122 timelineService := timelineCore.NewTimelineService(timelineRepo)
123 handler := timeline.NewGetTimelineHandler(timelineService)
124
125 ctx := context.Background()
126 testID := time.Now().UnixNano()
127 userDID := fmt.Sprintf("did:plc:user-%d", testID)
128
129 // Create user
130 _, err := db.ExecContext(ctx, `
131 INSERT INTO users (did, handle, pds_url)
132 VALUES ($1, $2, $3)
133 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
134 require.NoError(t, err)
135
136 // Create communities
137 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID))
138 require.NoError(t, err)
139
140 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID))
141 require.NoError(t, err)
142
143 // Subscribe to both
144 _, err = db.ExecContext(ctx, `
145 INSERT INTO community_subscriptions (user_did, community_did, content_visibility)
146 VALUES ($1, $2, 3), ($1, $3, 3)
147 `, userDID, community1DID, community2DID)
148 require.NoError(t, err)
149
150 // Create posts with different scores and ages
151 // Recent with medium score from gaming (should rank high)
152 createTestPost(t, db, community1DID, "did:plc:alice", "Recent trending gaming", 50, time.Now().Add(-1*time.Hour))
153
154 // Old with high score from tech (age penalty)
155 createTestPost(t, db, community2DID, "did:plc:bob", "Old popular tech", 100, time.Now().Add(-24*time.Hour))
156
157 // Very recent with low score from gaming
158 createTestPost(t, db, community1DID, "did:plc:charlie", "Brand new gaming", 5, time.Now().Add(-10*time.Minute))
159
160 // Request hot timeline
161 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=hot&limit=10", nil)
162 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
163 rec := httptest.NewRecorder()
164 handler.HandleGetTimeline(rec, req)
165
166 // Assertions
167 assert.Equal(t, http.StatusOK, rec.Code)
168
169 var response timelineCore.TimelineResponse
170 err = json.Unmarshal(rec.Body.Bytes(), &response)
171 require.NoError(t, err)
172
173 assert.Len(t, response.Feed, 3, "Timeline should show all posts from subscribed communities")
174
175 // All posts should have community context
176 for _, feedPost := range response.Feed {
177 assert.NotNil(t, feedPost.Post.Community, "Post should have community context")
178 assert.Contains(t, []string{community1DID, community2DID}, feedPost.Post.Community.DID)
179 }
180}
181
182// TestGetTimeline_Pagination tests cursor-based pagination
183func TestGetTimeline_Pagination(t *testing.T) {
184 if testing.Short() {
185 t.Skip("Skipping integration test in short mode")
186 }
187
188 db := setupTestDB(t)
189 t.Cleanup(func() { _ = db.Close() })
190
191 // Setup services
192 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
193 timelineService := timelineCore.NewTimelineService(timelineRepo)
194 handler := timeline.NewGetTimelineHandler(timelineService)
195
196 ctx := context.Background()
197 testID := time.Now().UnixNano()
198 userDID := fmt.Sprintf("did:plc:user-%d", testID)
199
200 // Create user
201 _, err := db.ExecContext(ctx, `
202 INSERT INTO users (did, handle, pds_url)
203 VALUES ($1, $2, $3)
204 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
205 require.NoError(t, err)
206
207 // Create community
208 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID))
209 require.NoError(t, err)
210
211 // Subscribe
212 _, err = db.ExecContext(ctx, `
213 INSERT INTO community_subscriptions (user_did, community_did, content_visibility)
214 VALUES ($1, $2, 3)
215 `, userDID, communityDID)
216 require.NoError(t, err)
217
218 // Create 5 posts
219 for i := 0; i < 5; i++ {
220 createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), 10-i, time.Now().Add(-time.Duration(i)*time.Hour))
221 }
222
223 // First page: limit 2
224 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=2", nil)
225 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
226 rec := httptest.NewRecorder()
227 handler.HandleGetTimeline(rec, req)
228
229 assert.Equal(t, http.StatusOK, rec.Code)
230
231 var page1 timelineCore.TimelineResponse
232 err = json.Unmarshal(rec.Body.Bytes(), &page1)
233 require.NoError(t, err)
234
235 assert.Len(t, page1.Feed, 2, "First page should have 2 posts")
236 assert.NotNil(t, page1.Cursor, "Should have cursor for next page")
237
238 // Second page: use cursor
239 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getTimeline?sort=new&limit=2&cursor=%s", *page1.Cursor), nil)
240 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
241 rec = httptest.NewRecorder()
242 handler.HandleGetTimeline(rec, req)
243
244 assert.Equal(t, http.StatusOK, rec.Code)
245
246 var page2 timelineCore.TimelineResponse
247 err = json.Unmarshal(rec.Body.Bytes(), &page2)
248 require.NoError(t, err)
249
250 assert.Len(t, page2.Feed, 2, "Second page should have 2 posts")
251 assert.NotNil(t, page2.Cursor, "Should have cursor for next page")
252
253 // Verify no overlap
254 assert.NotEqual(t, page1.Feed[0].Post.URI, page2.Feed[0].Post.URI, "Pages should not overlap")
255 assert.NotEqual(t, page1.Feed[1].Post.URI, page2.Feed[1].Post.URI, "Pages should not overlap")
256}
257
258// TestGetTimeline_EmptyWhenNoSubscriptions tests timeline is empty when user has no subscriptions
259func TestGetTimeline_EmptyWhenNoSubscriptions(t *testing.T) {
260 if testing.Short() {
261 t.Skip("Skipping integration test in short mode")
262 }
263
264 db := setupTestDB(t)
265 t.Cleanup(func() { _ = db.Close() })
266
267 // Setup services
268 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
269 timelineService := timelineCore.NewTimelineService(timelineRepo)
270 handler := timeline.NewGetTimelineHandler(timelineService)
271
272 ctx := context.Background()
273 testID := time.Now().UnixNano()
274 userDID := fmt.Sprintf("did:plc:user-%d", testID)
275
276 // Create user (but don't subscribe to any communities)
277 _, err := db.ExecContext(ctx, `
278 INSERT INTO users (did, handle, pds_url)
279 VALUES ($1, $2, $3)
280 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
281 require.NoError(t, err)
282
283 // Request timeline
284 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
285 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
286 rec := httptest.NewRecorder()
287 handler.HandleGetTimeline(rec, req)
288
289 // Assertions
290 assert.Equal(t, http.StatusOK, rec.Code)
291
292 var response timelineCore.TimelineResponse
293 err = json.Unmarshal(rec.Body.Bytes(), &response)
294 require.NoError(t, err)
295
296 assert.Empty(t, response.Feed, "Timeline should be empty when user has no subscriptions")
297 assert.Nil(t, response.Cursor, "Should not have cursor when no results")
298}
299
300// TestGetTimeline_Unauthorized tests timeline requires authentication
301func TestGetTimeline_Unauthorized(t *testing.T) {
302 if testing.Short() {
303 t.Skip("Skipping integration test in short mode")
304 }
305
306 db := setupTestDB(t)
307 t.Cleanup(func() { _ = db.Close() })
308
309 // Setup services
310 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
311 timelineService := timelineCore.NewTimelineService(timelineRepo)
312 handler := timeline.NewGetTimelineHandler(timelineService)
313
314 // Request timeline WITHOUT auth context
315 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
316 rec := httptest.NewRecorder()
317 handler.HandleGetTimeline(rec, req)
318
319 // Should return 401 Unauthorized
320 assert.Equal(t, http.StatusUnauthorized, rec.Code)
321
322 var errorResp map[string]string
323 err := json.Unmarshal(rec.Body.Bytes(), &errorResp)
324 require.NoError(t, err)
325
326 assert.Equal(t, "AuthenticationRequired", errorResp["error"])
327}
328
329// TestGetTimeline_LimitValidation tests limit parameter validation
330func TestGetTimeline_LimitValidation(t *testing.T) {
331 if testing.Short() {
332 t.Skip("Skipping integration test in short mode")
333 }
334
335 db := setupTestDB(t)
336 t.Cleanup(func() { _ = db.Close() })
337
338 // Setup services
339 timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
340 timelineService := timelineCore.NewTimelineService(timelineRepo)
341 handler := timeline.NewGetTimelineHandler(timelineService)
342
343 ctx := context.Background()
344 testID := time.Now().UnixNano()
345 userDID := fmt.Sprintf("did:plc:user-%d", testID)
346
347 // Create user
348 _, err := db.ExecContext(ctx, `
349 INSERT INTO users (did, handle, pds_url)
350 VALUES ($1, $2, $3)
351 `, userDID, fmt.Sprintf("testuser-%d.test", testID), "https://bsky.social")
352 require.NoError(t, err)
353
354 t.Run("Limit exceeds maximum", func(t *testing.T) {
355 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=100", nil)
356 req = req.WithContext(middleware.SetTestUserDID(req.Context(), userDID))
357 rec := httptest.NewRecorder()
358 handler.HandleGetTimeline(rec, req)
359
360 assert.Equal(t, http.StatusBadRequest, rec.Code)
361
362 var errorResp map[string]string
363 err := json.Unmarshal(rec.Body.Bytes(), &errorResp)
364 require.NoError(t, err)
365
366 assert.Equal(t, "InvalidRequest", errorResp["error"])
367 assert.Contains(t, errorResp["message"], "limit")
368 })
369}