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}