A community based topic aggregation platform built on atproto
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}