A community based topic aggregation platform built on atproto

fix(community/list): improve error handling and add subscribed filter

Address PR review feedback for community list endpoint:

Critical fixes:
- Log JSON encoding errors instead of silently discarding them
- Add security test for subscribed=true without auth (returns 401)
- Add core functionality test for subscribed filter

Important fixes:
- Return consistent JSON errors (using writeError) for auth failures
- Return 400 for invalid limit parameter (e.g., limit=abc)
- Return 400 for invalid cursor parameter (non-numeric or negative)

New feature:
- Add subscribed=true filter to list only communities user is subscribed to
- Requires authentication when enabled
- Uses INNER JOIN with community_subscriptions table

Test coverage:
- TestListHandler_SubscribedWithoutAuth_Returns401
- TestListHandler_SubscribedWithAuth_FiltersCorrectly
- TestListHandler_InvalidLimit_Returns400
- TestListHandler_InvalidCursor_Returns400
- TestListHandler_ValidLimitBoundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+549 -38
+41 -18
internal/api/handlers/community/list.go
··· 2 3 import ( 4 "Coves/internal/api/handlers/common" 5 "Coves/internal/core/communities" 6 "encoding/json" 7 "net/http" 8 "strconv" 9 ) ··· 36 // Parse limit (1-100, default 50) 37 limit := 50 38 if limitStr := query.Get("limit"); limitStr != "" { 39 - if l, err := strconv.Atoi(limitStr); err == nil { 40 - if l < 1 { 41 - limit = 1 42 - } else if l > 100 { 43 - limit = 100 44 - } else { 45 - limit = l 46 - } 47 } 48 } 49 50 // Parse cursor (offset-based for now) 51 offset := 0 52 if cursorStr := query.Get("cursor"); cursorStr != "" { 53 - if o, err := strconv.Atoi(cursorStr); err == nil && o >= 0 { 54 - offset = o 55 } 56 } 57 58 // Parse sort enum (default: popular) ··· 87 } 88 } 89 90 req := communities.ListCommunitiesRequest{ 91 - Limit: limit, 92 - Offset: offset, 93 - Sort: sort, 94 - Visibility: visibility, 95 - Category: query.Get("category"), 96 - Language: query.Get("language"), 97 } 98 99 // Get communities from AppView DB ··· 123 w.WriteHeader(http.StatusOK) 124 if err := json.NewEncoder(w).Encode(response); err != nil { 125 // Log encoding errors but don't return error response (headers already sent) 126 - // This follows Go's standard practice for HTTP handlers 127 - _ = err 128 } 129 }
··· 2 3 import ( 4 "Coves/internal/api/handlers/common" 5 + "Coves/internal/api/middleware" 6 "Coves/internal/core/communities" 7 "encoding/json" 8 + "log" 9 "net/http" 10 "strconv" 11 ) ··· 38 // Parse limit (1-100, default 50) 39 limit := 50 40 if limitStr := query.Get("limit"); limitStr != "" { 41 + l, err := strconv.Atoi(limitStr) 42 + if err != nil { 43 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be an integer") 44 + return 45 + } 46 + if l < 1 { 47 + limit = 1 48 + } else if l > 100 { 49 + limit = 100 50 + } else { 51 + limit = l 52 } 53 } 54 55 // Parse cursor (offset-based for now) 56 offset := 0 57 if cursorStr := query.Get("cursor"); cursorStr != "" { 58 + o, err := strconv.Atoi(cursorStr) 59 + if err != nil { 60 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be an integer") 61 + return 62 + } 63 + if o < 0 { 64 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be non-negative") 65 + return 66 } 67 + offset = o 68 } 69 70 // Parse sort enum (default: popular) ··· 99 } 100 } 101 102 + // Parse subscribed filter (requires authentication) 103 + subscribedOnly := query.Get("subscribed") == "true" 104 + var subscriberDID string 105 + if subscribedOnly { 106 + subscriberDID = middleware.GetUserDID(r) 107 + if subscriberDID == "" { 108 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required for subscribed filter") 109 + return 110 + } 111 + } 112 + 113 req := communities.ListCommunitiesRequest{ 114 + Limit: limit, 115 + Offset: offset, 116 + Sort: sort, 117 + Visibility: visibility, 118 + Category: query.Get("category"), 119 + Language: query.Get("language"), 120 + SubscriberDID: subscriberDID, 121 } 122 123 // Get communities from AppView DB ··· 147 w.WriteHeader(http.StatusOK) 148 if err := json.NewEncoder(w).Encode(response); err != nil { 149 // Log encoding errors but don't return error response (headers already sent) 150 + log.Printf("Failed to encode community list response: %v", err) 151 } 152 }
+474
internal/api/handlers/community/list_test.go
···
··· 1 + package community 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 6 + "context" 7 + "encoding/json" 8 + "net/http" 9 + "net/http/httptest" 10 + "testing" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + // listTestService implements communities.Service for list handler tests 18 + type listTestService struct { 19 + listFunc func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) 20 + } 21 + 22 + func (m *listTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 23 + return nil, nil 24 + } 25 + 26 + func (m *listTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 27 + return nil, nil 28 + } 29 + 30 + func (m *listTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 31 + return nil, nil 32 + } 33 + 34 + func (m *listTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 35 + if m.listFunc != nil { 36 + return m.listFunc(ctx, req) 37 + } 38 + return []*communities.Community{}, nil 39 + } 40 + 41 + func (m *listTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 42 + return nil, 0, nil 43 + } 44 + 45 + func (m *listTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 46 + return nil, nil 47 + } 48 + 49 + func (m *listTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 50 + return nil 51 + } 52 + 53 + func (m *listTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 54 + return nil, nil 55 + } 56 + 57 + func (m *listTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 58 + return nil, nil 59 + } 60 + 61 + func (m *listTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 62 + return nil, nil 63 + } 64 + 65 + func (m *listTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 66 + return nil 67 + } 68 + 69 + func (m *listTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 70 + return nil, nil 71 + } 72 + 73 + func (m *listTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 74 + return false, nil 75 + } 76 + 77 + func (m *listTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 78 + return nil, nil 79 + } 80 + 81 + func (m *listTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 82 + return nil, nil 83 + } 84 + 85 + func (m *listTestService) ValidateHandle(handle string) error { 86 + return nil 87 + } 88 + 89 + func (m *listTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 90 + return identifier, nil 91 + } 92 + 93 + func (m *listTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 94 + return community, nil 95 + } 96 + 97 + func (m *listTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 98 + return nil, nil 99 + } 100 + 101 + // listTestRepo implements communities.Repository for list handler tests 102 + type listTestRepo struct{} 103 + 104 + func (r *listTestRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) { 105 + return nil, nil 106 + } 107 + func (r *listTestRepo) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 108 + return nil, nil 109 + } 110 + func (r *listTestRepo) GetByHandle(ctx context.Context, handle string) (*communities.Community, error) { 111 + return nil, nil 112 + } 113 + func (r *listTestRepo) Update(ctx context.Context, community *communities.Community) (*communities.Community, error) { 114 + return nil, nil 115 + } 116 + func (r *listTestRepo) Delete(ctx context.Context, did string) error { return nil } 117 + func (r *listTestRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error { 118 + return nil 119 + } 120 + func (r *listTestRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 121 + return nil, nil 122 + } 123 + func (r *listTestRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 124 + return nil, 0, nil 125 + } 126 + func (r *listTestRepo) Subscribe(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) { 127 + return nil, nil 128 + } 129 + func (r *listTestRepo) SubscribeWithCount(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) { 130 + return nil, nil 131 + } 132 + func (r *listTestRepo) Unsubscribe(ctx context.Context, userDID, communityDID string) error { return nil } 133 + func (r *listTestRepo) UnsubscribeWithCount(ctx context.Context, userDID, communityDID string) error { 134 + return nil 135 + } 136 + func (r *listTestRepo) GetSubscription(ctx context.Context, userDID, communityDID string) (*communities.Subscription, error) { 137 + return nil, nil 138 + } 139 + func (r *listTestRepo) GetSubscriptionByURI(ctx context.Context, recordURI string) (*communities.Subscription, error) { 140 + return nil, nil 141 + } 142 + func (r *listTestRepo) ListSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 143 + return nil, nil 144 + } 145 + func (r *listTestRepo) ListSubscribers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Subscription, error) { 146 + return nil, nil 147 + } 148 + func (r *listTestRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) { 149 + return nil, nil 150 + } 151 + func (r *listTestRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) { 152 + return nil, nil 153 + } 154 + func (r *listTestRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error { 155 + return nil 156 + } 157 + func (r *listTestRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) { 158 + return nil, nil 159 + } 160 + func (r *listTestRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) { 161 + return nil, nil 162 + } 163 + func (r *listTestRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 164 + return nil, nil 165 + } 166 + func (r *listTestRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) { 167 + return false, nil 168 + } 169 + func (r *listTestRepo) CreateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) { 170 + return nil, nil 171 + } 172 + func (r *listTestRepo) GetMembership(ctx context.Context, userDID, communityDID string) (*communities.Membership, error) { 173 + return nil, nil 174 + } 175 + func (r *listTestRepo) UpdateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) { 176 + return nil, nil 177 + } 178 + func (r *listTestRepo) ListMembers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Membership, error) { 179 + return nil, nil 180 + } 181 + func (r *listTestRepo) CreateModerationAction(ctx context.Context, action *communities.ModerationAction) (*communities.ModerationAction, error) { 182 + return nil, nil 183 + } 184 + func (r *listTestRepo) ListModerationActions(ctx context.Context, communityDID string, limit, offset int) ([]*communities.ModerationAction, error) { 185 + return nil, nil 186 + } 187 + func (r *listTestRepo) IncrementMemberCount(ctx context.Context, communityDID string) error { 188 + return nil 189 + } 190 + func (r *listTestRepo) DecrementMemberCount(ctx context.Context, communityDID string) error { 191 + return nil 192 + } 193 + func (r *listTestRepo) IncrementSubscriberCount(ctx context.Context, communityDID string) error { 194 + return nil 195 + } 196 + func (r *listTestRepo) DecrementSubscriberCount(ctx context.Context, communityDID string) error { 197 + return nil 198 + } 199 + func (r *listTestRepo) IncrementPostCount(ctx context.Context, communityDID string) error { 200 + return nil 201 + } 202 + 203 + // createListTestOAuthSession creates a mock OAuth session for testing 204 + func createListTestOAuthSession(did string) *oauth.ClientSessionData { 205 + parsedDID, _ := syntax.ParseDID(did) 206 + return &oauth.ClientSessionData{ 207 + AccountDID: parsedDID, 208 + SessionID: "test-session", 209 + HostURL: "http://localhost:3001", 210 + AccessToken: "test-access-token", 211 + } 212 + } 213 + 214 + func TestListHandler_SubscribedWithoutAuth_Returns401(t *testing.T) { 215 + mockService := &listTestService{} 216 + mockRepo := &listTestRepo{} 217 + handler := NewListHandler(mockService, mockRepo) 218 + 219 + // Request subscribed filter without authentication 220 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=true", nil) 221 + 222 + w := httptest.NewRecorder() 223 + handler.HandleList(w, req) 224 + 225 + if w.Code != http.StatusUnauthorized { 226 + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) 227 + } 228 + 229 + // Verify JSON error response format 230 + var errResp struct { 231 + Error string `json:"error"` 232 + Message string `json:"message"` 233 + } 234 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 235 + t.Fatalf("Failed to decode error response: %v", err) 236 + } 237 + if errResp.Error != "AuthRequired" { 238 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 239 + } 240 + if errResp.Message == "" { 241 + t.Error("Expected non-empty error message") 242 + } 243 + } 244 + 245 + func TestListHandler_SubscribedWithAuth_FiltersCorrectly(t *testing.T) { 246 + userDID := "did:plc:testuser123" 247 + 248 + // Create communities - some subscribed, some not 249 + allCommunities := []*communities.Community{ 250 + { 251 + DID: "did:plc:community1", 252 + Handle: "c-subscribed1.coves.social", 253 + Name: "subscribed1", 254 + CreatedAt: time.Now(), 255 + }, 256 + { 257 + DID: "did:plc:community2", 258 + Handle: "c-subscribed2.coves.social", 259 + Name: "subscribed2", 260 + CreatedAt: time.Now(), 261 + }, 262 + } 263 + 264 + var receivedRequest communities.ListCommunitiesRequest 265 + mockService := &listTestService{ 266 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 267 + receivedRequest = req 268 + // Service should receive the SubscriberDID and filter accordingly 269 + if req.SubscriberDID != "" { 270 + // Return only subscribed communities 271 + return allCommunities, nil 272 + } 273 + // Return all communities if no filter 274 + return allCommunities, nil 275 + }, 276 + } 277 + mockRepo := &listTestRepo{} 278 + handler := NewListHandler(mockService, mockRepo) 279 + 280 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=true", nil) 281 + 282 + // Add authentication using the test helper 283 + ctx := middleware.SetTestUserDID(req.Context(), userDID) 284 + req = req.WithContext(ctx) 285 + 286 + w := httptest.NewRecorder() 287 + handler.HandleList(w, req) 288 + 289 + if w.Code != http.StatusOK { 290 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 291 + } 292 + 293 + // Verify that the service received the SubscriberDID 294 + if receivedRequest.SubscriberDID != userDID { 295 + t.Errorf("Expected SubscriberDID %q to be passed to service, got %q", userDID, receivedRequest.SubscriberDID) 296 + } 297 + 298 + // Verify response contains communities 299 + var resp struct { 300 + Communities []map[string]interface{} `json:"communities"` 301 + Cursor string `json:"cursor"` 302 + } 303 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 304 + t.Fatalf("Failed to decode response: %v", err) 305 + } 306 + if len(resp.Communities) != 2 { 307 + t.Errorf("Expected 2 communities in response, got %d", len(resp.Communities)) 308 + } 309 + } 310 + 311 + func TestListHandler_SubscribedFalse_NoFilter(t *testing.T) { 312 + var receivedRequest communities.ListCommunitiesRequest 313 + mockService := &listTestService{ 314 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 315 + receivedRequest = req 316 + return []*communities.Community{}, nil 317 + }, 318 + } 319 + mockRepo := &listTestRepo{} 320 + handler := NewListHandler(mockService, mockRepo) 321 + 322 + // Request with subscribed=false should not require auth and should not filter 323 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?subscribed=false", nil) 324 + 325 + w := httptest.NewRecorder() 326 + handler.HandleList(w, req) 327 + 328 + if w.Code != http.StatusOK { 329 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 330 + } 331 + 332 + // Verify that SubscriberDID is empty (no filter) 333 + if receivedRequest.SubscriberDID != "" { 334 + t.Errorf("Expected empty SubscriberDID, got %q", receivedRequest.SubscriberDID) 335 + } 336 + } 337 + 338 + func TestListHandler_InvalidLimit_Returns400(t *testing.T) { 339 + mockService := &listTestService{} 340 + mockRepo := &listTestRepo{} 341 + handler := NewListHandler(mockService, mockRepo) 342 + 343 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?limit=abc", nil) 344 + 345 + w := httptest.NewRecorder() 346 + handler.HandleList(w, req) 347 + 348 + if w.Code != http.StatusBadRequest { 349 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 350 + } 351 + 352 + // Verify JSON error response format 353 + var errResp struct { 354 + Error string `json:"error"` 355 + Message string `json:"message"` 356 + } 357 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 358 + t.Fatalf("Failed to decode error response: %v", err) 359 + } 360 + if errResp.Error != "InvalidRequest" { 361 + t.Errorf("Expected error InvalidRequest, got %s", errResp.Error) 362 + } 363 + } 364 + 365 + func TestListHandler_InvalidCursor_Returns400(t *testing.T) { 366 + mockService := &listTestService{} 367 + mockRepo := &listTestRepo{} 368 + handler := NewListHandler(mockService, mockRepo) 369 + 370 + tests := []struct { 371 + name string 372 + cursor string 373 + }{ 374 + { 375 + name: "non-numeric cursor", 376 + cursor: "abc", 377 + }, 378 + { 379 + name: "negative cursor", 380 + cursor: "-5", 381 + }, 382 + } 383 + 384 + for _, tc := range tests { 385 + t.Run(tc.name, func(t *testing.T) { 386 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?cursor="+tc.cursor, nil) 387 + 388 + w := httptest.NewRecorder() 389 + handler.HandleList(w, req) 390 + 391 + if w.Code != http.StatusBadRequest { 392 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 393 + } 394 + 395 + // Verify JSON error response format 396 + var errResp struct { 397 + Error string `json:"error"` 398 + Message string `json:"message"` 399 + } 400 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 401 + t.Fatalf("Failed to decode error response: %v", err) 402 + } 403 + if errResp.Error != "InvalidRequest" { 404 + t.Errorf("Expected error InvalidRequest, got %s", errResp.Error) 405 + } 406 + }) 407 + } 408 + } 409 + 410 + func TestListHandler_ValidLimitBoundaries(t *testing.T) { 411 + tests := []struct { 412 + name string 413 + limitParam string 414 + expectedLimit int 415 + }{ 416 + { 417 + name: "limit below minimum clamped to 1", 418 + limitParam: "0", 419 + expectedLimit: 1, 420 + }, 421 + { 422 + name: "limit above maximum clamped to 100", 423 + limitParam: "150", 424 + expectedLimit: 100, 425 + }, 426 + { 427 + name: "valid limit in range", 428 + limitParam: "25", 429 + expectedLimit: 25, 430 + }, 431 + } 432 + 433 + for _, tc := range tests { 434 + t.Run(tc.name, func(t *testing.T) { 435 + var receivedRequest communities.ListCommunitiesRequest 436 + mockService := &listTestService{ 437 + listFunc: func(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 438 + receivedRequest = req 439 + return []*communities.Community{}, nil 440 + }, 441 + } 442 + mockRepo := &listTestRepo{} 443 + handler := NewListHandler(mockService, mockRepo) 444 + 445 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?limit="+tc.limitParam, nil) 446 + 447 + w := httptest.NewRecorder() 448 + handler.HandleList(w, req) 449 + 450 + if w.Code != http.StatusOK { 451 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 452 + } 453 + 454 + if receivedRequest.Limit != tc.expectedLimit { 455 + t.Errorf("Expected limit %d, got %d", tc.expectedLimit, receivedRequest.Limit) 456 + } 457 + }) 458 + } 459 + } 460 + 461 + func TestListHandler_MethodNotAllowed(t *testing.T) { 462 + mockService := &listTestService{} 463 + mockRepo := &listTestRepo{} 464 + handler := NewListHandler(mockService, mockRepo) 465 + 466 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.list", nil) 467 + 468 + w := httptest.NewRecorder() 469 + handler.HandleList(w, req) 470 + 471 + if w.Code != http.StatusMethodNotAllowed { 472 + t.Errorf("Expected status 405, got %d", w.Code) 473 + } 474 + }
+4
internal/atproto/lexicon/social/coves/community/list.json
··· 38 "type": "string", 39 "format": "language", 40 "description": "Filter by language" 41 } 42 } 43 },
··· 38 "type": "string", 39 "format": "language", 40 "description": "Filter by language" 41 + }, 42 + "subscribed": { 43 + "type": "boolean", 44 + "description": "If true, only return communities the viewer is subscribed to. Requires authentication." 45 } 46 } 47 },
+7 -6
internal/core/communities/community.go
··· 141 142 // ListCommunitiesRequest represents query parameters for listing communities 143 type ListCommunitiesRequest struct { 144 - Sort string `json:"sort,omitempty"` // Enum: popular, active, new, alphabetical 145 - Visibility string `json:"visibility,omitempty"` // Filter: public, unlisted, private 146 - Category string `json:"category,omitempty"` // Optional: filter by category (future) 147 - Language string `json:"language,omitempty"` // Optional: filter by language (future) 148 - Limit int `json:"limit"` // 1-100, default 50 149 - Offset int `json:"offset"` // Pagination offset 150 } 151 152 // SearchCommunitiesRequest represents query parameters for searching communities
··· 141 142 // ListCommunitiesRequest represents query parameters for listing communities 143 type ListCommunitiesRequest struct { 144 + Sort string `json:"sort,omitempty"` // Enum: popular, active, new, alphabetical 145 + Visibility string `json:"visibility,omitempty"` // Filter: public, unlisted, private 146 + Category string `json:"category,omitempty"` // Optional: filter by category (future) 147 + Language string `json:"language,omitempty"` // Optional: filter by language (future) 148 + SubscriberDID string `json:"subscriberDid,omitempty"` // If set, filter to only subscribed communities 149 + Limit int `json:"limit"` // 1-100, default 50 150 + Offset int `json:"offset"` // Pagination offset 151 } 152 153 // SearchCommunitiesRequest represents query parameters for searching communities
+23 -14
internal/db/postgres/community_repo.go
··· 350 args := []interface{}{} 351 argCount := 1 352 353 if req.Visibility != "" { 354 - whereClauses = append(whereClauses, fmt.Sprintf("visibility = $%d", argCount)) 355 args = append(args, req.Visibility) 356 argCount++ 357 } ··· 373 switch req.Sort { 374 case "popular": 375 // Most subscribers (default) 376 - sortColumn = "subscriber_count" 377 sortOrder = "DESC" 378 case "active": 379 // Most posts/activity 380 - sortColumn = "post_count" 381 sortOrder = "DESC" 382 case "new": 383 // Recently created 384 - sortColumn = "created_at" 385 sortOrder = "DESC" 386 case "alphabetical": 387 // Sorted by name A-Z 388 - sortColumn = "name" 389 sortOrder = "ASC" 390 default: 391 // Fallback to popular if empty or invalid (should be validated in handler) 392 - sortColumn = "subscriber_count" 393 sortOrder = "DESC" 394 } 395 396 // Get communities with pagination 397 query := fmt.Sprintf(` 398 - SELECT id, did, handle, name, display_name, description, description_facets, 399 - avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 400 - visibility, allow_external_discovery, moderation_type, content_warnings, 401 - member_count, subscriber_count, post_count, 402 - federated_from, federated_id, created_at, updated_at, 403 - record_uri, record_cid 404 - FROM communities 405 %s 406 ORDER BY %s %s 407 LIMIT $%d OFFSET $%d`, 408 - whereClause, sortColumn, sortOrder, argCount, argCount+1) 409 410 args = append(args, req.Limit, req.Offset) 411
··· 350 args := []interface{}{} 351 argCount := 1 352 353 + // Build JOIN clause for subscribed filter 354 + joinClause := "" 355 + if req.SubscriberDID != "" { 356 + joinClause = fmt.Sprintf("INNER JOIN community_subscriptions cs ON c.did = cs.community_did AND cs.user_did = $%d", argCount) 357 + args = append(args, req.SubscriberDID) 358 + argCount++ 359 + } 360 + 361 if req.Visibility != "" { 362 + whereClauses = append(whereClauses, fmt.Sprintf("c.visibility = $%d", argCount)) 363 args = append(args, req.Visibility) 364 argCount++ 365 } ··· 381 switch req.Sort { 382 case "popular": 383 // Most subscribers (default) 384 + sortColumn = "c.subscriber_count" 385 sortOrder = "DESC" 386 case "active": 387 // Most posts/activity 388 + sortColumn = "c.post_count" 389 sortOrder = "DESC" 390 case "new": 391 // Recently created 392 + sortColumn = "c.created_at" 393 sortOrder = "DESC" 394 case "alphabetical": 395 // Sorted by name A-Z 396 + sortColumn = "c.name" 397 sortOrder = "ASC" 398 default: 399 // Fallback to popular if empty or invalid (should be validated in handler) 400 + sortColumn = "c.subscriber_count" 401 sortOrder = "DESC" 402 } 403 404 // Get communities with pagination 405 query := fmt.Sprintf(` 406 + SELECT c.id, c.did, c.handle, c.name, c.display_name, c.description, c.description_facets, 407 + c.avatar_cid, c.banner_cid, c.owner_did, c.created_by_did, c.hosted_by_did, 408 + c.visibility, c.allow_external_discovery, c.moderation_type, c.content_warnings, 409 + c.member_count, c.subscriber_count, c.post_count, 410 + c.federated_from, c.federated_id, c.created_at, c.updated_at, 411 + c.record_uri, c.record_cid 412 + FROM communities c 413 + %s 414 %s 415 ORDER BY %s %s 416 LIMIT $%d OFFSET $%d`, 417 + joinClause, whereClause, sortColumn, sortOrder, argCount, argCount+1) 418 419 args = append(args, req.Limit, req.Offset) 420