A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/api/handlers/discover" 5 "Coves/internal/db/postgres" 6 "context" 7 "encoding/json" 8 "fmt" 9 "net/http" 10 "net/http/httptest" 11 "testing" 12 "time" 13 14 discoverCore "Coves/internal/core/discover" 15 16 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/require" 18) 19 20// TestGetDiscover_ShowsAllCommunities tests discover feed shows posts from ALL communities 21func TestGetDiscover_ShowsAllCommunities(t *testing.T) { 22 if testing.Short() { 23 t.Skip("Skipping integration test in short mode") 24 } 25 26 db := setupTestDB(t) 27 t.Cleanup(func() { _ = db.Close() }) 28 29 // Setup services 30 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 31 discoverService := discoverCore.NewDiscoverService(discoverRepo) 32 handler := discover.NewGetDiscoverHandler(discoverService) 33 34 ctx := context.Background() 35 testID := time.Now().UnixNano() 36 37 // Create three communities 38 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 39 require.NoError(t, err) 40 41 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 42 require.NoError(t, err) 43 44 community3DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("cooking-%d", testID), fmt.Sprintf("charlie-%d.test", testID)) 45 require.NoError(t, err) 46 47 // Create posts in all three communities 48 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Gaming post", 50, time.Now().Add(-1*time.Hour)) 49 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Tech post", 30, time.Now().Add(-2*time.Hour)) 50 post3URI := createTestPost(t, db, community3DID, "did:plc:charlie", "Cooking post", 100, time.Now().Add(-30*time.Minute)) 51 52 // Request discover feed (no auth required!) 53 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 54 rec := httptest.NewRecorder() 55 handler.HandleGetDiscover(rec, req) 56 57 // Assertions 58 assert.Equal(t, http.StatusOK, rec.Code) 59 60 var response discoverCore.DiscoverResponse 61 err = json.Unmarshal(rec.Body.Bytes(), &response) 62 require.NoError(t, err) 63 64 // Verify all our posts are present (may include posts from other tests) 65 uris := make(map[string]bool) 66 for _, post := range response.Feed { 67 uris[post.Post.URI] = true 68 } 69 assert.True(t, uris[post1URI], "Should contain gaming post") 70 assert.True(t, uris[post2URI], "Should contain tech post") 71 assert.True(t, uris[post3URI], "Should contain cooking post") 72 73 // Verify newest post appears before older posts in the feed 74 var post3Index, post1Index, post2Index int = -1, -1, -1 75 for i, post := range response.Feed { 76 switch post.Post.URI { 77 case post3URI: 78 post3Index = i 79 case post1URI: 80 post1Index = i 81 case post2URI: 82 post2Index = i 83 } 84 } 85 if post3Index >= 0 && post1Index >= 0 && post2Index >= 0 { 86 assert.Less(t, post3Index, post1Index, "Newest post (30min ago) should appear before 1hr old post") 87 assert.Less(t, post1Index, post2Index, "1hr old post should appear before 2hr old post") 88 } 89} 90 91// TestGetDiscover_NoAuthRequired tests discover feed works without authentication 92func TestGetDiscover_NoAuthRequired(t *testing.T) { 93 if testing.Short() { 94 t.Skip("Skipping integration test in short mode") 95 } 96 97 db := setupTestDB(t) 98 t.Cleanup(func() { _ = db.Close() }) 99 100 // Setup services 101 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 102 discoverService := discoverCore.NewDiscoverService(discoverRepo) 103 handler := discover.NewGetDiscoverHandler(discoverService) 104 105 ctx := context.Background() 106 testID := time.Now().UnixNano() 107 108 // Create community and post 109 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("public-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 110 require.NoError(t, err) 111 112 postURI := createTestPost(t, db, communityDID, "did:plc:alice", "Public post", 10, time.Now()) 113 114 // Request discover WITHOUT any authentication 115 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil) 116 // Note: No auth context set! 117 rec := httptest.NewRecorder() 118 handler.HandleGetDiscover(rec, req) 119 120 // Should succeed without auth 121 assert.Equal(t, http.StatusOK, rec.Code, "Discover should work without authentication") 122 123 var response discoverCore.DiscoverResponse 124 err = json.Unmarshal(rec.Body.Bytes(), &response) 125 require.NoError(t, err) 126 127 // Verify our post is present 128 found := false 129 for _, post := range response.Feed { 130 if post.Post.URI == postURI { 131 found = true 132 break 133 } 134 } 135 assert.True(t, found, "Should show post even without authentication") 136} 137 138// TestGetDiscover_HotSort tests hot sorting across all communities 139func TestGetDiscover_HotSort(t *testing.T) { 140 if testing.Short() { 141 t.Skip("Skipping integration test in short mode") 142 } 143 144 db := setupTestDB(t) 145 t.Cleanup(func() { _ = db.Close() }) 146 147 // Setup services 148 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 149 discoverService := discoverCore.NewDiscoverService(discoverRepo) 150 handler := discover.NewGetDiscoverHandler(discoverService) 151 152 ctx := context.Background() 153 testID := time.Now().UnixNano() 154 155 // Create communities 156 community1DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("gaming-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 157 require.NoError(t, err) 158 159 community2DID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("tech-%d", testID), fmt.Sprintf("bob-%d.test", testID)) 160 require.NoError(t, err) 161 162 // Create posts with different scores/ages 163 post1URI := createTestPost(t, db, community1DID, "did:plc:alice", "Recent trending", 50, time.Now().Add(-1*time.Hour)) 164 post2URI := createTestPost(t, db, community2DID, "did:plc:bob", "Old popular", 100, time.Now().Add(-24*time.Hour)) 165 post3URI := createTestPost(t, db, community1DID, "did:plc:charlie", "Brand new", 5, time.Now().Add(-10*time.Minute)) 166 167 // Request hot discover 168 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=hot&limit=50", nil) 169 rec := httptest.NewRecorder() 170 handler.HandleGetDiscover(rec, req) 171 172 assert.Equal(t, http.StatusOK, rec.Code) 173 174 var response discoverCore.DiscoverResponse 175 err = json.Unmarshal(rec.Body.Bytes(), &response) 176 require.NoError(t, err) 177 178 // Verify all our posts are present 179 uris := make(map[string]bool) 180 for _, post := range response.Feed { 181 uris[post.Post.URI] = true 182 } 183 assert.True(t, uris[post1URI], "Should contain recent trending post") 184 assert.True(t, uris[post2URI], "Should contain old popular post") 185 assert.True(t, uris[post3URI], "Should contain brand new post") 186} 187 188// TestGetDiscover_Pagination tests cursor-based pagination 189func TestGetDiscover_Pagination(t *testing.T) { 190 if testing.Short() { 191 t.Skip("Skipping integration test in short mode") 192 } 193 194 db := setupTestDB(t) 195 t.Cleanup(func() { _ = db.Close() }) 196 197 // Setup services 198 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 199 discoverService := discoverCore.NewDiscoverService(discoverRepo) 200 handler := discover.NewGetDiscoverHandler(discoverService) 201 202 ctx := context.Background() 203 testID := time.Now().UnixNano() 204 205 // Create community 206 communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("test-%d", testID), fmt.Sprintf("alice-%d.test", testID)) 207 require.NoError(t, err) 208 209 // Create 5 posts 210 for i := 0; i < 5; i++ { 211 createTestPost(t, db, communityDID, "did:plc:alice", fmt.Sprintf("Post %d", i), 10-i, time.Now().Add(-time.Duration(i)*time.Hour)) 212 } 213 214 // First page: limit 2 215 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=2", nil) 216 rec := httptest.NewRecorder() 217 handler.HandleGetDiscover(rec, req) 218 219 assert.Equal(t, http.StatusOK, rec.Code) 220 221 var page1 discoverCore.DiscoverResponse 222 err = json.Unmarshal(rec.Body.Bytes(), &page1) 223 require.NoError(t, err) 224 225 assert.Len(t, page1.Feed, 2, "First page should have 2 posts") 226 assert.NotNil(t, page1.Cursor, "Should have cursor for next page") 227 228 // Second page: use cursor 229 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/xrpc/social.coves.feed.getDiscover?sort=new&limit=2&cursor=%s", *page1.Cursor), nil) 230 rec = httptest.NewRecorder() 231 handler.HandleGetDiscover(rec, req) 232 233 assert.Equal(t, http.StatusOK, rec.Code) 234 235 var page2 discoverCore.DiscoverResponse 236 err = json.Unmarshal(rec.Body.Bytes(), &page2) 237 require.NoError(t, err) 238 239 assert.Len(t, page2.Feed, 2, "Second page should have 2 posts") 240 241 // Verify no overlap 242 assert.NotEqual(t, page1.Feed[0].Post.URI, page2.Feed[0].Post.URI, "Pages should not overlap") 243} 244 245// TestGetDiscover_LimitValidation tests limit parameter validation 246func TestGetDiscover_LimitValidation(t *testing.T) { 247 if testing.Short() { 248 t.Skip("Skipping integration test in short mode") 249 } 250 251 db := setupTestDB(t) 252 t.Cleanup(func() { _ = db.Close() }) 253 254 // Setup services 255 discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret") 256 discoverService := discoverCore.NewDiscoverService(discoverRepo) 257 handler := discover.NewGetDiscoverHandler(discoverService) 258 259 t.Run("Limit exceeds maximum", func(t *testing.T) { 260 req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil) 261 rec := httptest.NewRecorder() 262 handler.HandleGetDiscover(rec, req) 263 264 assert.Equal(t, http.StatusBadRequest, rec.Code) 265 266 var errorResp map[string]string 267 err := json.Unmarshal(rec.Body.Bytes(), &errorResp) 268 require.NoError(t, err) 269 270 assert.Equal(t, "InvalidRequest", errorResp["error"]) 271 assert.Contains(t, errorResp["message"], "limit") 272 }) 273}