A community based topic aggregation platform built on atproto
at main 17 kB view raw
1package integration 2 3import ( 4 "Coves/internal/api/handlers/discover" 5 "Coves/internal/api/middleware" 6 "Coves/internal/core/votes" 7 "Coves/internal/db/postgres" 8 "context" 9 "encoding/json" 10 "fmt" 11 "net/http" 12 "net/http/httptest" 13 "testing" 14 "time" 15 16 discoverCore "Coves/internal/core/discover" 17 18 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 19 "github.com/bluesky-social/indigo/atproto/syntax" 20 "github.com/stretchr/testify/assert" 21 "github.com/stretchr/testify/require" 22) 23 24// mockVoteService implements votes.Service for testing viewer vote state 25type mockVoteService struct { 26 cachedVotes map[string]*votes.CachedVote // userDID:subjectURI -> vote 27} 28 29func newMockVoteService() *mockVoteService { 30 return &mockVoteService{ 31 cachedVotes: make(map[string]*votes.CachedVote), 32 } 33} 34 35func (m *mockVoteService) AddVote(userDID, subjectURI, direction, voteURI string) { 36 key := userDID + ":" + subjectURI 37 m.cachedVotes[key] = &votes.CachedVote{ 38 Direction: direction, 39 URI: voteURI, 40 } 41} 42 43func (m *mockVoteService) CreateVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { 44 return &votes.CreateVoteResponse{}, nil 45} 46 47func (m *mockVoteService) DeleteVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.DeleteVoteRequest) error { 48 return nil 49} 50 51func (m *mockVoteService) EnsureCachePopulated(_ context.Context, _ *oauthlib.ClientSessionData) error { 52 return nil // Mock always succeeds - votes pre-populated via AddVote 53} 54 55func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { 56 key := userDID + ":" + subjectURI 57 return m.cachedVotes[key] 58} 59 60func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { 61 result := make(map[string]*votes.CachedVote) 62 for _, uri := range subjectURIs { 63 key := userDID + ":" + uri 64 if vote, exists := m.cachedVotes[key]; exists { 65 result[uri] = vote 66 } 67 } 68 return result 69} 70 71// TestGetDiscover_ShowsAllCommunities tests discover feed shows posts from ALL communities 72func TestGetDiscover_ShowsAllCommunities(t *testing.T) { 73 if testing.Short() { 74 t.Skip("Skipping integration test in short mode") 75 } 76 77 db := setupTestDB(t) 78 t.Cleanup(func() { _ = db.Close() }) 79 80 // Setup services 81 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 82 discoverService := discoverCore.NewDiscoverService(discoverRepo) 83 handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state 84 85 ctx := context.Background() 86 testID := time.Now().UnixNano() 87 88 // Create three communities 89 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 90 require.NoError(t, err) 91 92 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 93 require.NoError(t, err) 94 95 community3DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cooking-%d", testID), fmt.Sprintf("charlie-%d.test", testID)) 96 require.NoError(t, err) 97 98 // Create posts in all three communities 99 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post", 50, time.Now().Add(-1*time.Hour)) 100 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Tech post", 30, time.Now().Add(-2*time.Hour)) 101 post3URI := createTestPost(t, db, community3DID, "did:plc:charlie", "Cooking post", 100, time.Now().Add(-30*time.Minute)) 102 103 // Request discover feed (no auth required!) 104 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 105 rec := httptest.NewRecorder() 106 handler.HandleGetDiscover(rec, req) 107 108 // Assertions 109 assert.Equal(t, http.StatusOK, rec.Code) 110 111 var response discoverCore.DiscoverResponse 112 err = json.Unmarshal(rec.Body.Bytes(), &response) 113 require.NoError(t, err) 114 115 // Verify all our posts are present (may include posts from other tests) 116 uris := make(map[string]bool) 117 for _, post := range response.Feed { 118 uris[post.Post.URI] = true 119 } 120 assert.True(t, uris[post1URI], "Should contain gaming post") 121 assert.True(t, uris[post2URI], "Should contain tech post") 122 assert.True(t, uris[post3URI], "Should contain cooking post") 123 124 // Verify newest post appears before older posts in the feed 125 var post3Index, post1Index, post2Index int = -1, -1, -1 126 for i, post := range response.Feed { 127 switch post.Post.URI { 128 case post3URI: 129 post3Index = i 130 case post1URI: 131 post1Index = i 132 case post2URI: 133 post2Index = i 134 } 135 } 136 if post3Index >= 0 && post1Index >= 0 && post2Index >= 0 { 137 assert.Less(t, post3Index, post1Index, "Newest post (30min ago) should appear before 1hr old post") 138 assert.Less(t, post1Index, post2Index, "1hr old post should appear before 2hr old post") 139 } 140} 141 142// TestGetDiscover_NoAuthRequired tests discover feed works without authentication 143func TestGetDiscover_NoAuthRequired(t *testing.T) { 144 if testing.Short() { 145 t.Skip("Skipping integration test in short mode") 146 } 147 148 db := setupTestDB(t) 149 t.Cleanup(func() { _ = db.Close() }) 150 151 // Setup services 152 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 153 discoverService := discoverCore.NewDiscoverService(discoverRepo) 154 handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state 155 156 ctx := context.Background() 157 testID := time.Now().UnixNano() 158 159 // Create community and post 160 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("public-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 161 require.NoError(t, err) 162 163 postURI := createTestPost(t, db, communityDID, "did:plc:alice", "Public post", 10, time.Now()) 164 165 // Request discover WITHOUT any authentication 166 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 167 // Note: No auth context set! 168 rec := httptest.NewRecorder() 169 handler.HandleGetDiscover(rec, req) 170 171 // Should succeed without auth 172 assert.Equal(t, http.StatusOK, rec.Code, "Discover should work without authentication") 173 174 var response discoverCore.DiscoverResponse 175 err = json.Unmarshal(rec.Body.Bytes(), &response) 176 require.NoError(t, err) 177 178 // Verify our post is present 179 found := false 180 for _, post := range response.Feed { 181 if post.Post.URI == postURI { 182 found = true 183 break 184 } 185 } 186 assert.True(t, found, "Should show post even without authentication") 187} 188 189// TestGetDiscover_HotSort tests hot sorting across all communities 190func TestGetDiscover_HotSort(t *testing.T) { 191 if testing.Short() { 192 t.Skip("Skipping integration test in short mode") 193 } 194 195 db := setupTestDB(t) 196 t.Cleanup(func() { _ = db.Close() }) 197 198 // Setup services 199 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 200 discoverService := discoverCore.NewDiscoverService(discoverRepo) 201 handler := discover.NewGetDiscoverHandler(discoverService, nil) 202 203 ctx := context.Background() 204 testID := time.Now().UnixNano() 205 206 // Create communities 207 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 208 require.NoError(t, err) 209 210 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 211 require.NoError(t, err) 212 213 // Create posts with different scores/ages 214 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Recent trending", 50, time.Now().Add(-1*time.Hour)) 215 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Old popular", 100, time.Now().Add(-24*time.Hour)) 216 post3URI := createTestPost(t, db, community1DID, "did:plc:charlie", "Brand new", 5, time.Now().Add(-10*time.Minute)) 217 218 // Request hot discover 219 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=hot&limit=50", nil) 220 rec := httptest.NewRecorder() 221 handler.HandleGetDiscover(rec, req) 222 223 assert.Equal(t, http.StatusOK, rec.Code) 224 225 var response discoverCore.DiscoverResponse 226 err = json.Unmarshal(rec.Body.Bytes(), &response) 227 require.NoError(t, err) 228 229 // Verify all our posts are present 230 uris := make(map[string]bool) 231 for _, post := range response.Feed { 232 uris[post.Post.URI] = true 233 } 234 assert.True(t, uris[post1URI], "Should contain recent trending post") 235 assert.True(t, uris[post2URI], "Should contain old popular post") 236 assert.True(t, uris[post3URI], "Should contain brand new post") 237} 238 239// TestGetDiscover_Pagination tests cursor-based pagination 240func TestGetDiscover_Pagination(t *testing.T) { 241 if testing.Short() { 242 t.Skip("Skipping integration test in short mode") 243 } 244 245 db := setupTestDB(t) 246 t.Cleanup(func() { _ = db.Close() }) 247 248 // Setup services 249 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 250 discoverService := discoverCore.NewDiscoverService(discoverRepo) 251 handler := discover.NewGetDiscoverHandler(discoverService, nil) 252 253 ctx := context.Background() 254 testID := time.Now().UnixNano() 255 256 // Create community 257 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("test-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 258 require.NoError(t, err) 259 260 // Create 5 posts 261 for i := 0; i < 5; i++ { 262 createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), 10-i, time.Now().Add(-time.Duration(i)*time.Hour)) 263 } 264 265 // First page: limit 2 266 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=2", nil) 267 rec := httptest.NewRecorder() 268 handler.HandleGetDiscover(rec, req) 269 270 assert.Equal(t, http.StatusOK, rec.Code) 271 272 var page1 discoverCore.DiscoverResponse 273 err = json.Unmarshal(rec.Body.Bytes(), &page1) 274 require.NoError(t, err) 275 276 assert.Len(t, page1.Feed, 2, "First page should have 2 posts") 277 assert.NotNil(t, page1.Cursor, "Should have cursor for next page") 278 279 // Second page: use cursor 280 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getDiscover?sort=new&limit=2&cursor=%s", *page1.Cursor), nil) 281 rec = httptest.NewRecorder() 282 handler.HandleGetDiscover(rec, req) 283 284 assert.Equal(t, http.StatusOK, rec.Code) 285 286 var page2 discoverCore.DiscoverResponse 287 err = json.Unmarshal(rec.Body.Bytes(), &page2) 288 require.NoError(t, err) 289 290 assert.Len(t, page2.Feed, 2, "Second page should have 2 posts") 291 292 // Verify no overlap 293 assert.NotEqual(t, page1.Feed[0].Post.URI, page2.Feed[0].Post.URI, "Pages should not overlap") 294} 295 296// TestGetDiscover_LimitValidation tests limit parameter validation 297func TestGetDiscover_LimitValidation(t *testing.T) { 298 if testing.Short() { 299 t.Skip("Skipping integration test in short mode") 300 } 301 302 db := setupTestDB(t) 303 t.Cleanup(func() { _ = db.Close() }) 304 305 // Setup services 306 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 307 discoverService := discoverCore.NewDiscoverService(discoverRepo) 308 handler := discover.NewGetDiscoverHandler(discoverService, nil) 309 310 t.Run("Limit exceeds maximum", func(t *testing.T) { 311 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil) 312 rec := httptest.NewRecorder() 313 handler.HandleGetDiscover(rec, req) 314 315 assert.Equal(t, http.StatusBadRequest, rec.Code) 316 317 var errorResp map[string]string 318 err := json.Unmarshal(rec.Body.Bytes(), &errorResp) 319 require.NoError(t, err) 320 321 assert.Equal(t, "InvalidRequest", errorResp["error"]) 322 assert.Contains(t, errorResp["message"], "limit") 323 }) 324} 325 326// TestGetDiscover_ViewerVoteState tests that authenticated users see their vote state on posts 327func TestGetDiscover_ViewerVoteState(t *testing.T) { 328 if testing.Short() { 329 t.Skip("Skipping integration test in short mode") 330 } 331 332 db := setupTestDB(t) 333 t.Cleanup(func() { _ = db.Close() }) 334 335 ctx := context.Background() 336 testID := time.Now().UnixNano() 337 338 // Create community and posts 339 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("votes-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 340 require.NoError(t, err) 341 342 post1URI := createTestPost(t, db, communityDID, "did:plc:author1", "Post with upvote", 10, time.Now().Add(-1*time.Hour)) 343 post2URI := createTestPost(t, db, communityDID, "did:plc:author2", "Post with downvote", 5, time.Now().Add(-2*time.Hour)) 344 _ = createTestPost(t, db, communityDID, "did:plc:author3", "Post without vote", 3, time.Now().Add(-3*time.Hour)) 345 346 // Setup mock vote service with pre-populated votes 347 viewerDID := "did:plc:viewer123" 348 mockVotes := newMockVoteService() 349 mockVotes.AddVote(viewerDID, post1URI, "up", "at://"+viewerDID+"/social.coves.vote/vote1") 350 mockVotes.AddVote(viewerDID, post2URI, "down", "at://"+viewerDID+"/social.coves.vote/vote2") 351 352 // Setup handler with mock vote service 353 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 354 discoverService := discoverCore.NewDiscoverService(discoverRepo) 355 handler := discover.NewGetDiscoverHandler(discoverService, mockVotes) 356 357 // Create request with authenticated user context 358 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 359 360 // Inject OAuth session into context (simulates OptionalAuth middleware) 361 did, _ := syntax.ParseDID(viewerDID) 362 session := &oauthlib.ClientSessionData{ 363 AccountDID: did, 364 AccessToken: "test_token", 365 } 366 reqCtx := context.WithValue(req.Context(), middleware.UserDIDKey, viewerDID) 367 reqCtx = context.WithValue(reqCtx, middleware.OAuthSessionKey, session) 368 req = req.WithContext(reqCtx) 369 370 rec := httptest.NewRecorder() 371 handler.HandleGetDiscover(rec, req) 372 373 // Assertions 374 assert.Equal(t, http.StatusOK, rec.Code) 375 376 var response discoverCore.DiscoverResponse 377 err = json.Unmarshal(rec.Body.Bytes(), &response) 378 require.NoError(t, err) 379 380 // Find our test posts and verify vote state 381 var foundPost1, foundPost2, foundPost3 bool 382 for _, feedPost := range response.Feed { 383 switch feedPost.Post.URI { 384 case post1URI: 385 foundPost1 = true 386 require.NotNil(t, feedPost.Post.Viewer, "Post1 should have viewer state") 387 require.NotNil(t, feedPost.Post.Viewer.Vote, "Post1 should have vote direction") 388 assert.Equal(t, "up", *feedPost.Post.Viewer.Vote, "Post1 should show upvote") 389 require.NotNil(t, feedPost.Post.Viewer.VoteURI, "Post1 should have vote URI") 390 assert.Contains(t, *feedPost.Post.Viewer.VoteURI, "vote1", "Post1 should have correct vote URI") 391 392 case post2URI: 393 foundPost2 = true 394 require.NotNil(t, feedPost.Post.Viewer, "Post2 should have viewer state") 395 require.NotNil(t, feedPost.Post.Viewer.Vote, "Post2 should have vote direction") 396 assert.Equal(t, "down", *feedPost.Post.Viewer.Vote, "Post2 should show downvote") 397 require.NotNil(t, feedPost.Post.Viewer.VoteURI, "Post2 should have vote URI") 398 399 default: 400 // Posts without votes should have nil Viewer or nil Vote 401 if feedPost.Post.Viewer != nil && feedPost.Post.Viewer.Vote != nil { 402 // This post has a vote from our viewer - it's not post3 403 continue 404 } 405 foundPost3 = true 406 } 407 } 408 409 assert.True(t, foundPost1, "Should find post1 with upvote") 410 assert.True(t, foundPost2, "Should find post2 with downvote") 411 assert.True(t, foundPost3, "Should find post3 without vote") 412} 413 414// TestGetDiscover_NoViewerStateWithoutAuth tests that unauthenticated users don't get viewer state 415func TestGetDiscover_NoViewerStateWithoutAuth(t *testing.T) { 416 if testing.Short() { 417 t.Skip("Skipping integration test in short mode") 418 } 419 420 db := setupTestDB(t) 421 t.Cleanup(func() { _ = db.Close() }) 422 423 ctx := context.Background() 424 testID := time.Now().UnixNano() 425 426 // Create community and post 427 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("noauth-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 428 require.NoError(t, err) 429 430 postURI := createTestPost(t, db, communityDID, "did:plc:author", "Some post", 10, time.Now()) 431 432 // Setup mock vote service with a vote (but request will be unauthenticated) 433 mockVotes := newMockVoteService() 434 mockVotes.AddVote("did:plc:someuser", postURI, "up", "at://did:plc:someuser/social.coves.vote/vote1") 435 436 // Setup handler with mock vote service 437 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 438 discoverService := discoverCore.NewDiscoverService(discoverRepo) 439 handler := discover.NewGetDiscoverHandler(discoverService, mockVotes) 440 441 // Create request WITHOUT auth context 442 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 443 rec := httptest.NewRecorder() 444 handler.HandleGetDiscover(rec, req) 445 446 // Should succeed 447 assert.Equal(t, http.StatusOK, rec.Code) 448 449 var response discoverCore.DiscoverResponse 450 err = json.Unmarshal(rec.Body.Bytes(), &response) 451 require.NoError(t, err) 452 453 // Find our post and verify NO viewer state (unauthenticated) 454 for _, feedPost := range response.Feed { 455 if feedPost.Post.URI == postURI { 456 assert.Nil(t, feedPost.Post.Viewer, "Unauthenticated request should not have viewer state") 457 return 458 } 459 } 460 t.Fatal("Test post not found in response") 461}