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}