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}