A community based topic aggregation platform built on atproto

feat(actor): implement social.coves.actor.getComments endpoint

Add user comment history endpoint for profile pages with:
- Lexicon definition following AT Protocol conventions
- Handler with proper error handling (400/404/500 distinction)
- Cursor-based pagination with composite key (createdAt|uri)
- Optional community filtering
- Viewer vote state population for authenticated users
- Comprehensive handler tests (15 tests)
- Comprehensive service tests (13 tests)

Key fixes from PR review:
- resolutionFailedError now returns 500 (not 400)
- Added warning log for missing votes table
- Improved SQL parameter documentation for cursor filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+1644 -11
+2 -1
cmd/server/main.go
··· 630 routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) 631 log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") 632 633 - routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, authMiddleware) 634 log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") 635 log.Println(" - GET /xrpc/social.coves.actor.getPosts") 636 637 routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver) 638 log.Println("Aggregator XRPC endpoints registered (query endpoints public, registration endpoint public)")
··· 630 routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) 631 log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") 632 633 + routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, commentService, authMiddleware) 634 log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") 635 log.Println(" - GET /xrpc/social.coves.actor.getPosts") 636 + log.Println(" - GET /xrpc/social.coves.actor.getComments") 637 638 routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver) 639 log.Println("Aggregator XRPC endpoints registered (query endpoints public, registration endpoint public)")
+265
internal/api/handlers/actor/get_comments.go
···
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/users" 14 + "Coves/internal/core/votes" 15 + ) 16 + 17 + // GetCommentsHandler handles actor comment retrieval 18 + type GetCommentsHandler struct { 19 + commentService comments.Service 20 + userService users.UserService 21 + voteService votes.Service 22 + } 23 + 24 + // NewGetCommentsHandler creates a new actor comments handler 25 + func NewGetCommentsHandler( 26 + commentService comments.Service, 27 + userService users.UserService, 28 + voteService votes.Service, 29 + ) *GetCommentsHandler { 30 + return &GetCommentsHandler{ 31 + commentService: commentService, 32 + userService: userService, 33 + voteService: voteService, 34 + } 35 + } 36 + 37 + // HandleGetComments retrieves comments by an actor (user) 38 + // GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=... 39 + func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { 40 + if r.Method != http.MethodGet { 41 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 42 + return 43 + } 44 + 45 + // Parse query parameters 46 + req, err := h.parseRequest(r) 47 + if err != nil { 48 + // Check if it's an actor not found error (from handle resolution) 49 + var actorNotFound *actorNotFoundError 50 + if errors.As(err, &actorNotFound) { 51 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 52 + return 53 + } 54 + 55 + // Check if it's an infrastructure failure during resolution 56 + // (database down, DNS failures, network errors, etc.) 57 + var resolutionFailed *resolutionFailedError 58 + if errors.As(err, &resolutionFailed) { 59 + log.Printf("ERROR: Actor resolution infrastructure failure: %v", err) 60 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity") 61 + return 62 + } 63 + 64 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 65 + return 66 + } 67 + 68 + // Get viewer DID for populating viewer state (optional) 69 + viewerDID := middleware.GetUserDID(r) 70 + if viewerDID != "" { 71 + req.ViewerDID = &viewerDID 72 + } 73 + 74 + // Get actor comments from service 75 + response, err := h.commentService.GetActorComments(r.Context(), req) 76 + if err != nil { 77 + handleCommentServiceError(w, err) 78 + return 79 + } 80 + 81 + // Populate viewer vote state if authenticated 82 + h.populateViewerVoteState(r, response) 83 + 84 + // Pre-encode response to buffer before writing headers 85 + // This ensures we can return a proper error if encoding fails 86 + responseBytes, err := json.Marshal(response) 87 + if err != nil { 88 + log.Printf("ERROR: Failed to encode actor comments response: %v", err) 89 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 90 + return 91 + } 92 + 93 + // Return comments 94 + w.Header().Set("Content-Type", "application/json") 95 + w.WriteHeader(http.StatusOK) 96 + if _, err := w.Write(responseBytes); err != nil { 97 + log.Printf("ERROR: Failed to write actor comments response: %v", err) 98 + } 99 + } 100 + 101 + // parseRequest parses query parameters into GetActorCommentsRequest 102 + func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) { 103 + req := &comments.GetActorCommentsRequest{} 104 + 105 + // Required: actor (handle or DID) 106 + actor := r.URL.Query().Get("actor") 107 + if actor == "" { 108 + return nil, &validationError{field: "actor", message: "actor parameter is required"} 109 + } 110 + // Validate actor length to prevent DoS via massive strings 111 + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 112 + // Max handle length is 253 chars (DNS limit) 113 + const maxActorLength = 2048 114 + if len(actor) > maxActorLength { 115 + return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"} 116 + } 117 + 118 + // Resolve actor to DID if it's a handle 119 + actorDID, err := h.resolveActor(r, actor) 120 + if err != nil { 121 + return nil, err 122 + } 123 + req.ActorDID = actorDID 124 + 125 + // Optional: community (handle or DID) 126 + req.Community = r.URL.Query().Get("community") 127 + 128 + // Optional: limit (default: 50, max: 100) 129 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 130 + limit, err := strconv.Atoi(limitStr) 131 + if err != nil { 132 + return nil, &validationError{field: "limit", message: "limit must be a valid integer"} 133 + } 134 + req.Limit = limit 135 + } 136 + 137 + // Optional: cursor 138 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { 139 + req.Cursor = &cursor 140 + } 141 + 142 + return req, nil 143 + } 144 + 145 + // resolveActor converts an actor identifier (handle or DID) to a DID 146 + func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) { 147 + // If it's already a DID, return it 148 + if strings.HasPrefix(actor, "did:") { 149 + return actor, nil 150 + } 151 + 152 + // It's a handle - resolve to DID using user service 153 + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 154 + if err != nil { 155 + // Check for context errors (timeouts, cancellation) - these are infrastructure errors 156 + if r.Context().Err() != nil { 157 + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 158 + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 159 + } 160 + 161 + // Check for common "not found" patterns in error message 162 + errStr := err.Error() 163 + isNotFound := strings.Contains(errStr, "not found") || 164 + strings.Contains(errStr, "no rows") || 165 + strings.Contains(errStr, "unable to resolve") 166 + 167 + if isNotFound { 168 + return "", &actorNotFoundError{actor: actor} 169 + } 170 + 171 + // For other errors (network, database, DNS failures), return infrastructure error 172 + // This ensures users see "internal error" not "actor not found" for real problems 173 + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 174 + return "", &resolutionFailedError{actor: actor, cause: err} 175 + } 176 + 177 + return did, nil 178 + } 179 + 180 + // populateViewerVoteState enriches comment views with the authenticated user's vote state 181 + func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) { 182 + if h.voteService == nil || response == nil || len(response.Comments) == 0 { 183 + return 184 + } 185 + 186 + session := middleware.GetOAuthSession(r) 187 + if session == nil { 188 + return 189 + } 190 + 191 + userDID := middleware.GetUserDID(r) 192 + if userDID == "" { 193 + return 194 + } 195 + 196 + // Ensure vote cache is populated from PDS 197 + if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil { 198 + log.Printf("Warning: failed to populate vote cache for actor comments: %v", err) 199 + return 200 + } 201 + 202 + // Collect comment URIs to batch lookup 203 + commentURIs := make([]string, 0, len(response.Comments)) 204 + for _, comment := range response.Comments { 205 + if comment != nil { 206 + commentURIs = append(commentURIs, comment.URI) 207 + } 208 + } 209 + 210 + // Get viewer votes for all comments 211 + viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs) 212 + 213 + // Populate viewer state on each comment 214 + for _, comment := range response.Comments { 215 + if comment != nil { 216 + if vote, exists := viewerVotes[comment.URI]; exists { 217 + comment.Viewer = &comments.CommentViewerState{ 218 + Vote: &vote.Direction, 219 + VoteURI: &vote.URI, 220 + } 221 + } 222 + } 223 + } 224 + } 225 + 226 + // handleCommentServiceError maps service errors to HTTP responses 227 + func handleCommentServiceError(w http.ResponseWriter, err error) { 228 + if err == nil { 229 + return 230 + } 231 + 232 + errStr := err.Error() 233 + 234 + // Check for validation errors 235 + if strings.Contains(errStr, "invalid request") { 236 + writeError(w, http.StatusBadRequest, "InvalidRequest", errStr) 237 + return 238 + } 239 + 240 + // Check for not found errors 241 + if comments.IsNotFound(err) || strings.Contains(errStr, "not found") { 242 + writeError(w, http.StatusNotFound, "NotFound", "Resource not found") 243 + return 244 + } 245 + 246 + // Check for authorization errors 247 + if errors.Is(err, comments.ErrNotAuthorized) { 248 + writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") 249 + return 250 + } 251 + 252 + // Default to internal server error 253 + log.Printf("ERROR: Comment service error: %v", err) 254 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") 255 + } 256 + 257 + // validationError represents a validation error for a specific field 258 + type validationError struct { 259 + field string 260 + message string 261 + } 262 + 263 + func (e *validationError) Error() string { 264 + return e.message 265 + }
+617
internal/api/handlers/actor/get_comments_test.go
···
··· 1 + package actor 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/posts" 14 + "Coves/internal/core/users" 15 + "Coves/internal/core/votes" 16 + 17 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + ) 19 + 20 + // mockCommentService implements a comment service interface for testing 21 + type mockCommentService struct { 22 + getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) 23 + } 24 + 25 + func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 26 + if m.getActorCommentsFunc != nil { 27 + return m.getActorCommentsFunc(ctx, req) 28 + } 29 + return &comments.GetActorCommentsResponse{ 30 + Comments: []*comments.CommentView{}, 31 + Cursor: nil, 32 + }, nil 33 + } 34 + 35 + // Implement other Service methods as no-ops 36 + func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) { 37 + return nil, nil 38 + } 39 + 40 + func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) { 41 + return nil, nil 42 + } 43 + 44 + func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) { 45 + return nil, nil 46 + } 47 + 48 + func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error { 49 + return nil 50 + } 51 + 52 + // mockUserServiceForComments implements users.UserService for testing getComments 53 + type mockUserServiceForComments struct { 54 + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) 55 + } 56 + 57 + func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 58 + return nil, nil 59 + } 60 + 61 + func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 62 + return nil, nil 63 + } 64 + 65 + func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 66 + return nil, nil 67 + } 68 + 69 + func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 70 + return nil, nil 71 + } 72 + 73 + func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 74 + if m.resolveHandleToDIDFunc != nil { 75 + return m.resolveHandleToDIDFunc(ctx, handle) 76 + } 77 + return "did:plc:testuser", nil 78 + } 79 + 80 + func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 81 + return nil, nil 82 + } 83 + 84 + func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 85 + return nil 86 + } 87 + 88 + func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 89 + return nil, nil 90 + } 91 + 92 + // mockVoteServiceForComments implements votes.Service for testing getComments 93 + type mockVoteServiceForComments struct{} 94 + 95 + func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { 96 + return nil, nil 97 + } 98 + 99 + func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { 100 + return nil 101 + } 102 + 103 + func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { 104 + return nil 105 + } 106 + 107 + func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { 108 + return nil 109 + } 110 + 111 + func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { 112 + return nil 113 + } 114 + 115 + func TestGetCommentsHandler_Success(t *testing.T) { 116 + createdAt := time.Now().Format(time.RFC3339) 117 + indexedAt := time.Now().Format(time.RFC3339) 118 + 119 + mockComments := &mockCommentService{ 120 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 121 + return &comments.GetActorCommentsResponse{ 122 + Comments: []*comments.CommentView{ 123 + { 124 + URI: "at://did:plc:testuser/social.coves.community.comment/abc123", 125 + CID: "bafytest123", 126 + Content: "Test comment content", 127 + CreatedAt: createdAt, 128 + IndexedAt: indexedAt, 129 + Author: &posts.AuthorView{ 130 + DID: "did:plc:testuser", 131 + Handle: "test.user", 132 + }, 133 + Stats: &comments.CommentStats{ 134 + Upvotes: 5, 135 + Downvotes: 1, 136 + Score: 4, 137 + ReplyCount: 2, 138 + }, 139 + }, 140 + }, 141 + }, nil 142 + }, 143 + } 144 + mockUsers := &mockUserServiceForComments{} 145 + mockVotes := &mockVoteServiceForComments{} 146 + 147 + handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes) 148 + 149 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil) 150 + rec := httptest.NewRecorder() 151 + 152 + handler.HandleGetComments(rec, req) 153 + 154 + if rec.Code != http.StatusOK { 155 + t.Errorf("Expected status 200, got %d", rec.Code) 156 + } 157 + 158 + var response comments.GetActorCommentsResponse 159 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 160 + t.Fatalf("Failed to decode response: %v", err) 161 + } 162 + 163 + if len(response.Comments) != 1 { 164 + t.Errorf("Expected 1 comment in response, got %d", len(response.Comments)) 165 + } 166 + 167 + if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" { 168 + t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI) 169 + } 170 + 171 + if response.Comments[0].Content != "Test comment content" { 172 + t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content) 173 + } 174 + } 175 + 176 + func TestGetCommentsHandler_MissingActor(t *testing.T) { 177 + handler := NewGetCommentsHandler( 178 + &mockCommentService{}, 179 + &mockUserServiceForComments{}, 180 + &mockVoteServiceForComments{}, 181 + ) 182 + 183 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments", nil) 184 + rec := httptest.NewRecorder() 185 + 186 + handler.HandleGetComments(rec, req) 187 + 188 + if rec.Code != http.StatusBadRequest { 189 + t.Errorf("Expected status 400, got %d", rec.Code) 190 + } 191 + 192 + var response ErrorResponse 193 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 194 + t.Fatalf("Failed to decode response: %v", err) 195 + } 196 + 197 + if response.Error != "InvalidRequest" { 198 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 199 + } 200 + } 201 + 202 + func TestGetCommentsHandler_InvalidLimit(t *testing.T) { 203 + handler := NewGetCommentsHandler( 204 + &mockCommentService{}, 205 + &mockUserServiceForComments{}, 206 + &mockVoteServiceForComments{}, 207 + ) 208 + 209 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil) 210 + rec := httptest.NewRecorder() 211 + 212 + handler.HandleGetComments(rec, req) 213 + 214 + if rec.Code != http.StatusBadRequest { 215 + t.Errorf("Expected status 400, got %d", rec.Code) 216 + } 217 + 218 + var response ErrorResponse 219 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 220 + t.Fatalf("Failed to decode response: %v", err) 221 + } 222 + 223 + if response.Error != "InvalidRequest" { 224 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 225 + } 226 + } 227 + 228 + func TestGetCommentsHandler_ActorNotFound(t *testing.T) { 229 + mockUsers := &mockUserServiceForComments{ 230 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 231 + return "", posts.ErrActorNotFound 232 + }, 233 + } 234 + 235 + handler := NewGetCommentsHandler( 236 + &mockCommentService{}, 237 + mockUsers, 238 + &mockVoteServiceForComments{}, 239 + ) 240 + 241 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil) 242 + rec := httptest.NewRecorder() 243 + 244 + handler.HandleGetComments(rec, req) 245 + 246 + if rec.Code != http.StatusNotFound { 247 + t.Errorf("Expected status 404, got %d", rec.Code) 248 + } 249 + 250 + var response ErrorResponse 251 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 252 + t.Fatalf("Failed to decode response: %v", err) 253 + } 254 + 255 + if response.Error != "ActorNotFound" { 256 + t.Errorf("Expected error 'ActorNotFound', got '%s'", response.Error) 257 + } 258 + } 259 + 260 + func TestGetCommentsHandler_ActorLengthExceedsMax(t *testing.T) { 261 + handler := NewGetCommentsHandler( 262 + &mockCommentService{}, 263 + &mockUserServiceForComments{}, 264 + &mockVoteServiceForComments{}, 265 + ) 266 + 267 + // Create an actor parameter that exceeds 2048 characters using valid URL characters 268 + longActorBytes := make([]byte, 2100) 269 + for i := range longActorBytes { 270 + longActorBytes[i] = 'a' 271 + } 272 + longActor := "did:plc:" + string(longActorBytes) 273 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil) 274 + rec := httptest.NewRecorder() 275 + 276 + handler.HandleGetComments(rec, req) 277 + 278 + if rec.Code != http.StatusBadRequest { 279 + t.Errorf("Expected status 400, got %d", rec.Code) 280 + } 281 + } 282 + 283 + func TestGetCommentsHandler_InvalidCursor(t *testing.T) { 284 + // The handleCommentServiceError function checks for "invalid request" in error message 285 + // to return a BadRequest. An invalid cursor error falls under this category. 286 + mockComments := &mockCommentService{ 287 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 288 + return nil, errors.New("invalid request: invalid cursor format") 289 + }, 290 + } 291 + 292 + handler := NewGetCommentsHandler( 293 + mockComments, 294 + &mockUserServiceForComments{}, 295 + &mockVoteServiceForComments{}, 296 + ) 297 + 298 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil) 299 + rec := httptest.NewRecorder() 300 + 301 + handler.HandleGetComments(rec, req) 302 + 303 + if rec.Code != http.StatusBadRequest { 304 + t.Errorf("Expected status 400, got %d", rec.Code) 305 + } 306 + 307 + var response ErrorResponse 308 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 309 + t.Fatalf("Failed to decode response: %v", err) 310 + } 311 + 312 + if response.Error != "InvalidRequest" { 313 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 314 + } 315 + } 316 + 317 + func TestGetCommentsHandler_MethodNotAllowed(t *testing.T) { 318 + handler := NewGetCommentsHandler( 319 + &mockCommentService{}, 320 + &mockUserServiceForComments{}, 321 + &mockVoteServiceForComments{}, 322 + ) 323 + 324 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getComments", nil) 325 + rec := httptest.NewRecorder() 326 + 327 + handler.HandleGetComments(rec, req) 328 + 329 + if rec.Code != http.StatusMethodNotAllowed { 330 + t.Errorf("Expected status 405, got %d", rec.Code) 331 + } 332 + } 333 + 334 + func TestGetCommentsHandler_HandleResolution(t *testing.T) { 335 + resolvedDID := "" 336 + mockComments := &mockCommentService{ 337 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 338 + resolvedDID = req.ActorDID 339 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 340 + }, 341 + } 342 + mockUsers := &mockUserServiceForComments{ 343 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 344 + if handle == "test.user" { 345 + return "did:plc:resolveduser123", nil 346 + } 347 + return "", posts.ErrActorNotFound 348 + }, 349 + } 350 + 351 + handler := NewGetCommentsHandler( 352 + mockComments, 353 + mockUsers, 354 + &mockVoteServiceForComments{}, 355 + ) 356 + 357 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 358 + rec := httptest.NewRecorder() 359 + 360 + handler.HandleGetComments(rec, req) 361 + 362 + if rec.Code != http.StatusOK { 363 + t.Errorf("Expected status 200, got %d", rec.Code) 364 + } 365 + 366 + if resolvedDID != "did:plc:resolveduser123" { 367 + t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) 368 + } 369 + } 370 + 371 + func TestGetCommentsHandler_DIDPassThrough(t *testing.T) { 372 + receivedDID := "" 373 + mockComments := &mockCommentService{ 374 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 375 + receivedDID = req.ActorDID 376 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 377 + }, 378 + } 379 + 380 + handler := NewGetCommentsHandler( 381 + mockComments, 382 + &mockUserServiceForComments{}, 383 + &mockVoteServiceForComments{}, 384 + ) 385 + 386 + // When actor is already a DID, it should pass through without resolution 387 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil) 388 + rec := httptest.NewRecorder() 389 + 390 + handler.HandleGetComments(rec, req) 391 + 392 + if rec.Code != http.StatusOK { 393 + t.Errorf("Expected status 200, got %d", rec.Code) 394 + } 395 + 396 + if receivedDID != "did:plc:directuser" { 397 + t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) 398 + } 399 + } 400 + 401 + func TestGetCommentsHandler_EmptyCommentsArray(t *testing.T) { 402 + mockComments := &mockCommentService{ 403 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 404 + return &comments.GetActorCommentsResponse{ 405 + Comments: []*comments.CommentView{}, 406 + }, nil 407 + }, 408 + } 409 + 410 + handler := NewGetCommentsHandler( 411 + mockComments, 412 + &mockUserServiceForComments{}, 413 + &mockVoteServiceForComments{}, 414 + ) 415 + 416 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil) 417 + rec := httptest.NewRecorder() 418 + 419 + handler.HandleGetComments(rec, req) 420 + 421 + if rec.Code != http.StatusOK { 422 + t.Errorf("Expected status 200, got %d", rec.Code) 423 + } 424 + 425 + var response comments.GetActorCommentsResponse 426 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 427 + t.Fatalf("Failed to decode response: %v", err) 428 + } 429 + 430 + if response.Comments == nil { 431 + t.Error("Expected comments array to be non-nil (empty array), got nil") 432 + } 433 + 434 + if len(response.Comments) != 0 { 435 + t.Errorf("Expected 0 comments for new user, got %d", len(response.Comments)) 436 + } 437 + } 438 + 439 + func TestGetCommentsHandler_WithCursor(t *testing.T) { 440 + receivedCursor := "" 441 + mockComments := &mockCommentService{ 442 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 443 + if req.Cursor != nil { 444 + receivedCursor = *req.Cursor 445 + } 446 + nextCursor := "page2cursor" 447 + return &comments.GetActorCommentsResponse{ 448 + Comments: []*comments.CommentView{}, 449 + Cursor: &nextCursor, 450 + }, nil 451 + }, 452 + } 453 + 454 + handler := NewGetCommentsHandler( 455 + mockComments, 456 + &mockUserServiceForComments{}, 457 + &mockVoteServiceForComments{}, 458 + ) 459 + 460 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil) 461 + rec := httptest.NewRecorder() 462 + 463 + handler.HandleGetComments(rec, req) 464 + 465 + if rec.Code != http.StatusOK { 466 + t.Errorf("Expected status 200, got %d", rec.Code) 467 + } 468 + 469 + if receivedCursor != "testcursor123" { 470 + t.Errorf("Expected cursor 'testcursor123', got '%s'", receivedCursor) 471 + } 472 + 473 + var response comments.GetActorCommentsResponse 474 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 475 + t.Fatalf("Failed to decode response: %v", err) 476 + } 477 + 478 + if response.Cursor == nil || *response.Cursor != "page2cursor" { 479 + t.Error("Expected response to include next cursor") 480 + } 481 + } 482 + 483 + func TestGetCommentsHandler_WithLimit(t *testing.T) { 484 + receivedLimit := 0 485 + mockComments := &mockCommentService{ 486 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 487 + receivedLimit = req.Limit 488 + return &comments.GetActorCommentsResponse{ 489 + Comments: []*comments.CommentView{}, 490 + }, nil 491 + }, 492 + } 493 + 494 + handler := NewGetCommentsHandler( 495 + mockComments, 496 + &mockUserServiceForComments{}, 497 + &mockVoteServiceForComments{}, 498 + ) 499 + 500 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil) 501 + rec := httptest.NewRecorder() 502 + 503 + handler.HandleGetComments(rec, req) 504 + 505 + if rec.Code != http.StatusOK { 506 + t.Errorf("Expected status 200, got %d", rec.Code) 507 + } 508 + 509 + if receivedLimit != 25 { 510 + t.Errorf("Expected limit 25, got %d", receivedLimit) 511 + } 512 + } 513 + 514 + func TestGetCommentsHandler_WithCommunityFilter(t *testing.T) { 515 + receivedCommunity := "" 516 + mockComments := &mockCommentService{ 517 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 518 + receivedCommunity = req.Community 519 + return &comments.GetActorCommentsResponse{ 520 + Comments: []*comments.CommentView{}, 521 + }, nil 522 + }, 523 + } 524 + 525 + handler := NewGetCommentsHandler( 526 + mockComments, 527 + &mockUserServiceForComments{}, 528 + &mockVoteServiceForComments{}, 529 + ) 530 + 531 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil) 532 + rec := httptest.NewRecorder() 533 + 534 + handler.HandleGetComments(rec, req) 535 + 536 + if rec.Code != http.StatusOK { 537 + t.Errorf("Expected status 200, got %d", rec.Code) 538 + } 539 + 540 + if receivedCommunity != "did:plc:community123" { 541 + t.Errorf("Expected community 'did:plc:community123', got '%s'", receivedCommunity) 542 + } 543 + } 544 + 545 + func TestGetCommentsHandler_ServiceError_Returns500(t *testing.T) { 546 + // Test that generic service errors (database failures, etc.) return 500 547 + mockComments := &mockCommentService{ 548 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 549 + return nil, errors.New("database connection failed") 550 + }, 551 + } 552 + 553 + handler := NewGetCommentsHandler( 554 + mockComments, 555 + &mockUserServiceForComments{}, 556 + &mockVoteServiceForComments{}, 557 + ) 558 + 559 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil) 560 + rec := httptest.NewRecorder() 561 + 562 + handler.HandleGetComments(rec, req) 563 + 564 + if rec.Code != http.StatusInternalServerError { 565 + t.Errorf("Expected status 500, got %d", rec.Code) 566 + } 567 + 568 + var response ErrorResponse 569 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 570 + t.Fatalf("Failed to decode response: %v", err) 571 + } 572 + 573 + if response.Error != "InternalServerError" { 574 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 575 + } 576 + 577 + // Verify error message doesn't leak internal details 578 + if response.Message == "database connection failed" { 579 + t.Error("Error message should not leak internal error details") 580 + } 581 + } 582 + 583 + func TestGetCommentsHandler_ResolutionFailedError_Returns500(t *testing.T) { 584 + // Test that infrastructure failures during handle resolution return 500, not 400 585 + mockUsers := &mockUserServiceForComments{ 586 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 587 + // Simulate a database failure during resolution 588 + return "", errors.New("connection refused") 589 + }, 590 + } 591 + 592 + handler := NewGetCommentsHandler( 593 + &mockCommentService{}, 594 + mockUsers, 595 + &mockVoteServiceForComments{}, 596 + ) 597 + 598 + // Use a handle (not a DID) to trigger resolution 599 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 600 + rec := httptest.NewRecorder() 601 + 602 + handler.HandleGetComments(rec, req) 603 + 604 + // Infrastructure failures should return 500, not 400 or 404 605 + if rec.Code != http.StatusInternalServerError { 606 + t.Errorf("Expected status 500 for infrastructure failure, got %d", rec.Code) 607 + } 608 + 609 + var response ErrorResponse 610 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 611 + t.Fatalf("Failed to decode response: %v", err) 612 + } 613 + 614 + if response.Error != "InternalServerError" { 615 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 616 + } 617 + }
+7
internal/api/routes/actor.go
··· 4 "Coves/internal/api/handlers/actor" 5 "Coves/internal/api/middleware" 6 "Coves/internal/core/blueskypost" 7 "Coves/internal/core/posts" 8 "Coves/internal/core/users" 9 "Coves/internal/core/votes" ··· 18 userService users.UserService, 19 voteService votes.Service, 20 blueskyService blueskypost.Service, 21 authMiddleware *middleware.OAuthAuthMiddleware, 22 ) { 23 // Create handlers 24 getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) 25 26 // GET /xrpc/social.coves.actor.getPosts 27 // Public endpoint with optional auth for viewer-specific state (vote state) 28 r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getPosts", getPostsHandler.HandleGetPosts) 29 }
··· 4 "Coves/internal/api/handlers/actor" 5 "Coves/internal/api/middleware" 6 "Coves/internal/core/blueskypost" 7 + "Coves/internal/core/comments" 8 "Coves/internal/core/posts" 9 "Coves/internal/core/users" 10 "Coves/internal/core/votes" ··· 19 userService users.UserService, 20 voteService votes.Service, 21 blueskyService blueskypost.Service, 22 + commentService comments.Service, 23 authMiddleware *middleware.OAuthAuthMiddleware, 24 ) { 25 // Create handlers 26 getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) 27 + getCommentsHandler := actor.NewGetCommentsHandler(commentService, userService, voteService) 28 29 // GET /xrpc/social.coves.actor.getPosts 30 // Public endpoint with optional auth for viewer-specific state (vote state) 31 r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getPosts", getPostsHandler.HandleGetPosts) 32 + 33 + // GET /xrpc/social.coves.actor.getComments 34 + // Public endpoint with optional auth for viewer-specific state (vote state) 35 + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getComments", getCommentsHandler.HandleGetComments) 36 }
+60
internal/atproto/lexicon/social/coves/actor/getComments.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.getComments", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a user's comments for their profile page.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "DID or handle of the user" 16 + }, 17 + "community": { 18 + "type": "string", 19 + "format": "at-identifier", 20 + "description": "Filter to comments in a specific community" 21 + }, 22 + "limit": { 23 + "type": "integer", 24 + "minimum": 1, 25 + "maximum": 100, 26 + "default": 50 27 + }, 28 + "cursor": { 29 + "type": "string" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["comments"], 38 + "properties": { 39 + "comments": { 40 + "type": "array", 41 + "items": { 42 + "type": "ref", 43 + "ref": "social.coves.community.comment.defs#commentView" 44 + } 45 + }, 46 + "cursor": { 47 + "type": "string" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "NotFound", 55 + "description": "Actor not found" 56 + } 57 + ] 58 + } 59 + } 60 + }
+9
internal/core/comments/comment.go
··· 79 Neg *bool `json:"neg,omitempty"` 80 Val string `json:"val"` 81 }
··· 79 Neg *bool `json:"neg,omitempty"` 80 Val string `json:"val"` 81 } 82 + 83 + // ListByCommenterRequest defines the parameters for fetching a user's comments 84 + // Used by social.coves.actor.getComments endpoint 85 + type ListByCommenterRequest struct { 86 + CommenterDID string // Required: DID of the commenter 87 + CommunityDID *string // Optional: filter to comments in a specific community 88 + Limit int // Max comments to return (1-100) 89 + Cursor *string // Pagination cursor from previous response 90 + }
+132
internal/core/comments/comment_service.go
··· 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 49 // CreateComment creates a new comment or reply 50 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 ··· 1014 // These fields are stored as JSONB in the database and need proper deserialization 1015 1016 return record 1017 } 1018 1019 // validateGetCommentsRequest validates and normalizes request parameters
··· 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 49 + // GetActorComments retrieves comments by a user for their profile page 50 + // Supports optional community filtering and cursor-based pagination 51 + GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) 52 + 53 // CreateComment creates a new comment or reply 54 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 55 ··· 1018 // These fields are stored as JSONB in the database and need proper deserialization 1019 1020 return record 1021 + } 1022 + 1023 + // GetActorComments retrieves comments by a user for their profile page 1024 + // Supports optional community filtering and cursor-based pagination 1025 + // Algorithm: 1026 + // 1. Validate and normalize request parameters (limit bounds) 1027 + // 2. Resolve community identifier to DID if provided 1028 + // 3. Fetch comments from repository with cursor-based pagination 1029 + // 4. Build CommentView for each comment with author info and stats 1030 + // 5. Return response with pagination cursor 1031 + func (s *commentService) GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) { 1032 + // 1. Validate and normalize request 1033 + if err := validateGetActorCommentsRequest(req); err != nil { 1034 + return nil, fmt.Errorf("invalid request: %w", err) 1035 + } 1036 + 1037 + // Add timeout to prevent runaway queries 1038 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 1039 + defer cancel() 1040 + 1041 + // 2. Resolve community identifier to DID if provided 1042 + var communityDID *string 1043 + if req.Community != "" { 1044 + // Check if it's already a DID 1045 + if strings.HasPrefix(req.Community, "did:") { 1046 + communityDID = &req.Community 1047 + } else { 1048 + // It's a handle - resolve to DID via community repository 1049 + community, err := s.communityRepo.GetByHandle(ctx, req.Community) 1050 + if err != nil { 1051 + // If community not found, return empty results rather than error 1052 + // This matches behavior of other endpoints 1053 + if errors.Is(err, communities.ErrCommunityNotFound) { 1054 + return &GetActorCommentsResponse{ 1055 + Comments: []*CommentView{}, 1056 + Cursor: nil, 1057 + }, nil 1058 + } 1059 + return nil, fmt.Errorf("failed to resolve community: %w", err) 1060 + } 1061 + communityDID = &community.DID 1062 + } 1063 + } 1064 + 1065 + // 3. Fetch comments from repository 1066 + repoReq := ListByCommenterRequest{ 1067 + CommenterDID: req.ActorDID, 1068 + CommunityDID: communityDID, 1069 + Limit: req.Limit, 1070 + Cursor: req.Cursor, 1071 + } 1072 + 1073 + dbComments, nextCursor, err := s.commentRepo.ListByCommenterWithCursor(ctx, repoReq) 1074 + if err != nil { 1075 + return nil, fmt.Errorf("failed to fetch comments: %w", err) 1076 + } 1077 + 1078 + // 4. Build CommentViews for each comment 1079 + // Batch fetch vote states if viewer is authenticated 1080 + var voteStates map[string]interface{} 1081 + if req.ViewerDID != nil && len(dbComments) > 0 { 1082 + commentURIs := make([]string, 0, len(dbComments)) 1083 + for _, comment := range dbComments { 1084 + commentURIs = append(commentURIs, comment.URI) 1085 + } 1086 + 1087 + var err error 1088 + voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *req.ViewerDID, commentURIs) 1089 + if err != nil { 1090 + // Log error but don't fail the request - vote state is optional 1091 + log.Printf("Warning: Failed to fetch vote states for actor comments: %v", err) 1092 + } 1093 + } 1094 + 1095 + // Batch fetch user data for comment authors (should all be the same user, but handle consistently) 1096 + usersByDID := make(map[string]*users.User) 1097 + if len(dbComments) > 0 { 1098 + // For actor comments, all comments are by the same user 1099 + // But we still use the batch pattern for consistency with other methods 1100 + user, err := s.userRepo.GetByDID(ctx, req.ActorDID) 1101 + if err != nil { 1102 + // Log error but don't fail request - user data is optional 1103 + log.Printf("Warning: Failed to fetch user for actor %s: %v", req.ActorDID, err) 1104 + } else if user != nil { 1105 + usersByDID[user.DID] = user 1106 + } 1107 + } 1108 + 1109 + // Build comment views 1110 + commentViews := make([]*CommentView, 0, len(dbComments)) 1111 + for _, comment := range dbComments { 1112 + commentView := s.buildCommentView(comment, req.ViewerDID, voteStates, usersByDID) 1113 + commentViews = append(commentViews, commentView) 1114 + } 1115 + 1116 + // 5. Return response with comments and cursor 1117 + return &GetActorCommentsResponse{ 1118 + Comments: commentViews, 1119 + Cursor: nextCursor, 1120 + }, nil 1121 + } 1122 + 1123 + // validateGetActorCommentsRequest validates and normalizes request parameters 1124 + // Applies default values and enforces bounds per API specification 1125 + func validateGetActorCommentsRequest(req *GetActorCommentsRequest) error { 1126 + if req == nil { 1127 + return errors.New("request cannot be nil") 1128 + } 1129 + 1130 + // ActorDID is required 1131 + if req.ActorDID == "" { 1132 + return errors.New("actor DID is required") 1133 + } 1134 + 1135 + // Validate DID format 1136 + if !strings.HasPrefix(req.ActorDID, "did:") { 1137 + return errors.New("invalid actor DID format") 1138 + } 1139 + 1140 + // Apply limit defaults and bounds (1-100, default 50) 1141 + if req.Limit <= 0 { 1142 + req.Limit = 50 1143 + } 1144 + if req.Limit > 100 { 1145 + req.Limit = 100 1146 + } 1147 + 1148 + return nil 1149 } 1150 1151 // validateGetCommentsRequest validates and normalizes request parameters
+373 -4
internal/core/comments/comment_service_test.go
··· 17 18 // mockCommentRepo is a mock implementation of the comment Repository interface 19 type mockCommentRepo struct { 20 - comments map[string]*Comment 21 - listByParentWithHotRankFunc func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) 22 - listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error) 23 - getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) 24 } 25 26 func newMockCommentRepo() *mockCommentRepo { ··· 94 95 func (m *mockCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) { 96 return nil, nil 97 } 98 99 func (m *mockCommentRepo) ListByParentWithHotRank( ··· 1454 func strPtr(s string) *string { 1455 return &s 1456 }
··· 17 18 // mockCommentRepo is a mock implementation of the comment Repository interface 19 type mockCommentRepo struct { 20 + comments map[string]*Comment 21 + listByParentWithHotRankFunc func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) 22 + listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error) 23 + getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) 24 + listByCommenterWithCursorFunc func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) 25 } 26 27 func newMockCommentRepo() *mockCommentRepo { ··· 95 96 func (m *mockCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) { 97 return nil, nil 98 + } 99 + 100 + func (m *mockCommentRepo) ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 101 + if m.listByCommenterWithCursorFunc != nil { 102 + return m.listByCommenterWithCursorFunc(ctx, req) 103 + } 104 + return []*Comment{}, nil, nil 105 } 106 107 func (m *mockCommentRepo) ListByParentWithHotRank( ··· 1462 func strPtr(s string) *string { 1463 return &s 1464 } 1465 + 1466 + // Test suite for GetActorComments 1467 + 1468 + func TestCommentService_GetActorComments_ValidRequest(t *testing.T) { 1469 + // Setup 1470 + actorDID := "did:plc:actor123" 1471 + viewerDID := "did:plc:viewer123" 1472 + postURI := "at://did:plc:post123/app.bsky.feed.post/test" 1473 + 1474 + commentRepo := newMockCommentRepo() 1475 + userRepo := newMockUserRepo() 1476 + postRepo := newMockPostRepo() 1477 + communityRepo := newMockCommunityRepo() 1478 + 1479 + // Add actor to user repo 1480 + actor := createTestUser(actorDID, "actor.test") 1481 + _, _ = userRepo.Create(context.Background(), actor) 1482 + 1483 + // Create test comments 1484 + comment1 := createTestComment("at://did:plc:actor123/comment/1", actorDID, "actor.test", postURI, postURI, 0) 1485 + comment2 := createTestComment("at://did:plc:actor123/comment/2", actorDID, "actor.test", postURI, postURI, 0) 1486 + 1487 + // Setup mock to return comments 1488 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1489 + if req.CommenterDID == actorDID { 1490 + return []*Comment{comment1, comment2}, nil, nil 1491 + } 1492 + return []*Comment{}, nil, nil 1493 + } 1494 + 1495 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1496 + 1497 + // Execute 1498 + req := &GetActorCommentsRequest{ 1499 + ActorDID: actorDID, 1500 + ViewerDID: &viewerDID, 1501 + Limit: 50, 1502 + } 1503 + 1504 + resp, err := service.GetActorComments(context.Background(), req) 1505 + 1506 + // Verify 1507 + assert.NoError(t, err) 1508 + assert.NotNil(t, resp) 1509 + assert.Len(t, resp.Comments, 2) 1510 + assert.Equal(t, comment1.URI, resp.Comments[0].URI) 1511 + assert.Equal(t, comment2.URI, resp.Comments[1].URI) 1512 + } 1513 + 1514 + func TestCommentService_GetActorComments_EmptyActorDID(t *testing.T) { 1515 + // Setup 1516 + commentRepo := newMockCommentRepo() 1517 + userRepo := newMockUserRepo() 1518 + postRepo := newMockPostRepo() 1519 + communityRepo := newMockCommunityRepo() 1520 + 1521 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1522 + 1523 + // Execute with empty ActorDID 1524 + req := &GetActorCommentsRequest{ 1525 + ActorDID: "", 1526 + Limit: 50, 1527 + } 1528 + 1529 + resp, err := service.GetActorComments(context.Background(), req) 1530 + 1531 + // Verify 1532 + assert.Error(t, err) 1533 + assert.Nil(t, resp) 1534 + assert.Contains(t, err.Error(), "actor DID is required") 1535 + } 1536 + 1537 + func TestCommentService_GetActorComments_InvalidActorDIDFormat(t *testing.T) { 1538 + // Setup 1539 + commentRepo := newMockCommentRepo() 1540 + userRepo := newMockUserRepo() 1541 + postRepo := newMockPostRepo() 1542 + communityRepo := newMockCommunityRepo() 1543 + 1544 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1545 + 1546 + // Execute with invalid DID format (missing did: prefix) 1547 + req := &GetActorCommentsRequest{ 1548 + ActorDID: "plc:actor123", 1549 + Limit: 50, 1550 + } 1551 + 1552 + resp, err := service.GetActorComments(context.Background(), req) 1553 + 1554 + // Verify 1555 + assert.Error(t, err) 1556 + assert.Nil(t, resp) 1557 + assert.Contains(t, err.Error(), "invalid actor DID format") 1558 + } 1559 + 1560 + func TestCommentService_GetActorComments_CommunityHandleResolution(t *testing.T) { 1561 + // Setup 1562 + actorDID := "did:plc:actor123" 1563 + communityDID := "did:plc:community123" 1564 + communityHandle := "c-test.coves.social" 1565 + 1566 + commentRepo := newMockCommentRepo() 1567 + userRepo := newMockUserRepo() 1568 + postRepo := newMockPostRepo() 1569 + communityRepo := newMockCommunityRepo() 1570 + 1571 + // Add community to repo 1572 + community := createTestCommunity(communityDID, communityHandle) 1573 + _, _ = communityRepo.Create(context.Background(), community) 1574 + 1575 + // Track what community filter was passed to repo 1576 + var receivedCommunityDID *string 1577 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1578 + receivedCommunityDID = req.CommunityDID 1579 + return []*Comment{}, nil, nil 1580 + } 1581 + 1582 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1583 + 1584 + // Execute with community handle (not DID) 1585 + req := &GetActorCommentsRequest{ 1586 + ActorDID: actorDID, 1587 + Community: communityHandle, 1588 + Limit: 50, 1589 + } 1590 + 1591 + resp, err := service.GetActorComments(context.Background(), req) 1592 + 1593 + // Verify 1594 + assert.NoError(t, err) 1595 + assert.NotNil(t, resp) 1596 + assert.NotNil(t, receivedCommunityDID) 1597 + assert.Equal(t, communityDID, *receivedCommunityDID) 1598 + } 1599 + 1600 + func TestCommentService_GetActorComments_CommunityDIDPassThrough(t *testing.T) { 1601 + // Setup 1602 + actorDID := "did:plc:actor123" 1603 + communityDID := "did:plc:community123" 1604 + 1605 + commentRepo := newMockCommentRepo() 1606 + userRepo := newMockUserRepo() 1607 + postRepo := newMockPostRepo() 1608 + communityRepo := newMockCommunityRepo() 1609 + 1610 + // Track what community filter was passed to repo 1611 + var receivedCommunityDID *string 1612 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1613 + receivedCommunityDID = req.CommunityDID 1614 + return []*Comment{}, nil, nil 1615 + } 1616 + 1617 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1618 + 1619 + // Execute with community DID (not handle) - should pass through without resolution 1620 + req := &GetActorCommentsRequest{ 1621 + ActorDID: actorDID, 1622 + Community: communityDID, 1623 + Limit: 50, 1624 + } 1625 + 1626 + resp, err := service.GetActorComments(context.Background(), req) 1627 + 1628 + // Verify 1629 + assert.NoError(t, err) 1630 + assert.NotNil(t, resp) 1631 + assert.NotNil(t, receivedCommunityDID) 1632 + assert.Equal(t, communityDID, *receivedCommunityDID) 1633 + } 1634 + 1635 + func TestCommentService_GetActorComments_CommunityNotFound(t *testing.T) { 1636 + // Setup 1637 + actorDID := "did:plc:actor123" 1638 + nonexistentCommunity := "nonexistent.coves.social" 1639 + 1640 + commentRepo := newMockCommentRepo() 1641 + userRepo := newMockUserRepo() 1642 + postRepo := newMockPostRepo() 1643 + communityRepo := newMockCommunityRepo() 1644 + 1645 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1646 + 1647 + // Execute with nonexistent community handle 1648 + req := &GetActorCommentsRequest{ 1649 + ActorDID: actorDID, 1650 + Community: nonexistentCommunity, 1651 + Limit: 50, 1652 + } 1653 + 1654 + resp, err := service.GetActorComments(context.Background(), req) 1655 + 1656 + // Verify - should return empty results, not error 1657 + assert.NoError(t, err) 1658 + assert.NotNil(t, resp) 1659 + assert.Len(t, resp.Comments, 0) 1660 + } 1661 + 1662 + func TestCommentService_GetActorComments_RepositoryError(t *testing.T) { 1663 + // Setup 1664 + actorDID := "did:plc:actor123" 1665 + 1666 + commentRepo := newMockCommentRepo() 1667 + userRepo := newMockUserRepo() 1668 + postRepo := newMockPostRepo() 1669 + communityRepo := newMockCommunityRepo() 1670 + 1671 + // Mock repository error 1672 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1673 + return nil, nil, errors.New("database connection failed") 1674 + } 1675 + 1676 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1677 + 1678 + // Execute 1679 + req := &GetActorCommentsRequest{ 1680 + ActorDID: actorDID, 1681 + Limit: 50, 1682 + } 1683 + 1684 + resp, err := service.GetActorComments(context.Background(), req) 1685 + 1686 + // Verify 1687 + assert.Error(t, err) 1688 + assert.Nil(t, resp) 1689 + assert.Contains(t, err.Error(), "failed to fetch comments") 1690 + } 1691 + 1692 + func TestCommentService_GetActorComments_LimitBoundsNormalization(t *testing.T) { 1693 + // Setup 1694 + actorDID := "did:plc:actor123" 1695 + 1696 + tests := []struct { 1697 + name string 1698 + inputLimit int 1699 + expectedLimit int 1700 + }{ 1701 + {"zero limit defaults to 50", 0, 50}, 1702 + {"negative limit defaults to 50", -10, 50}, 1703 + {"limit > 100 capped to 100", 200, 100}, 1704 + {"valid limit unchanged", 25, 25}, 1705 + } 1706 + 1707 + for _, tt := range tests { 1708 + t.Run(tt.name, func(t *testing.T) { 1709 + commentRepo := newMockCommentRepo() 1710 + userRepo := newMockUserRepo() 1711 + postRepo := newMockPostRepo() 1712 + communityRepo := newMockCommunityRepo() 1713 + 1714 + var receivedLimit int 1715 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1716 + receivedLimit = req.Limit 1717 + return []*Comment{}, nil, nil 1718 + } 1719 + 1720 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1721 + 1722 + req := &GetActorCommentsRequest{ 1723 + ActorDID: actorDID, 1724 + Limit: tt.inputLimit, 1725 + } 1726 + 1727 + _, err := service.GetActorComments(context.Background(), req) 1728 + 1729 + assert.NoError(t, err) 1730 + assert.Equal(t, tt.expectedLimit, receivedLimit) 1731 + }) 1732 + } 1733 + } 1734 + 1735 + func TestCommentService_GetActorComments_WithPagination(t *testing.T) { 1736 + // Setup 1737 + actorDID := "did:plc:actor123" 1738 + postURI := "at://did:plc:post123/app.bsky.feed.post/test" 1739 + 1740 + commentRepo := newMockCommentRepo() 1741 + userRepo := newMockUserRepo() 1742 + postRepo := newMockPostRepo() 1743 + communityRepo := newMockCommunityRepo() 1744 + 1745 + comment1 := createTestComment("at://did:plc:actor123/comment/1", actorDID, "actor.test", postURI, postURI, 0) 1746 + nextCursor := "cursor123" 1747 + 1748 + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { 1749 + return []*Comment{comment1}, &nextCursor, nil 1750 + } 1751 + 1752 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1753 + 1754 + // Execute 1755 + req := &GetActorCommentsRequest{ 1756 + ActorDID: actorDID, 1757 + Limit: 50, 1758 + } 1759 + 1760 + resp, err := service.GetActorComments(context.Background(), req) 1761 + 1762 + // Verify 1763 + assert.NoError(t, err) 1764 + assert.NotNil(t, resp) 1765 + assert.Len(t, resp.Comments, 1) 1766 + assert.NotNil(t, resp.Cursor) 1767 + assert.Equal(t, nextCursor, *resp.Cursor) 1768 + } 1769 + 1770 + func TestCommentService_GetActorComments_NilRequest(t *testing.T) { 1771 + // Setup 1772 + commentRepo := newMockCommentRepo() 1773 + userRepo := newMockUserRepo() 1774 + postRepo := newMockPostRepo() 1775 + communityRepo := newMockCommunityRepo() 1776 + 1777 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 1778 + 1779 + // Execute with nil request 1780 + resp, err := service.GetActorComments(context.Background(), nil) 1781 + 1782 + // Verify 1783 + assert.Error(t, err) 1784 + assert.Nil(t, resp) 1785 + assert.Contains(t, err.Error(), "request cannot be nil") 1786 + } 1787 + 1788 + func TestValidateGetActorCommentsRequest_Defaults(t *testing.T) { 1789 + req := &GetActorCommentsRequest{ 1790 + ActorDID: "did:plc:actor123", 1791 + // Limit is 0 (zero value) 1792 + } 1793 + 1794 + err := validateGetActorCommentsRequest(req) 1795 + assert.NoError(t, err) 1796 + 1797 + // Check defaults applied 1798 + assert.Equal(t, 50, req.Limit) 1799 + } 1800 + 1801 + func TestValidateGetActorCommentsRequest_BoundsEnforcement(t *testing.T) { 1802 + tests := []struct { 1803 + name string 1804 + limit int 1805 + expectedLimit int 1806 + }{ 1807 + {"zero limit defaults to 50", 0, 50}, 1808 + {"negative limit defaults to 50", -10, 50}, 1809 + {"limit too high capped to 100", 200, 100}, 1810 + {"valid limit unchanged", 25, 25}, 1811 + } 1812 + 1813 + for _, tt := range tests { 1814 + t.Run(tt.name, func(t *testing.T) { 1815 + req := &GetActorCommentsRequest{ 1816 + ActorDID: "did:plc:actor123", 1817 + Limit: tt.limit, 1818 + } 1819 + 1820 + err := validateGetActorCommentsRequest(req) 1821 + assert.NoError(t, err) 1822 + assert.Equal(t, tt.expectedLimit, req.Limit) 1823 + }) 1824 + } 1825 + }
+6 -1
internal/core/comments/interfaces.go
··· 50 CountByParent(ctx context.Context, parentURI string) (int, error) 51 52 // ListByCommenter retrieves all comments by a specific user 53 - // Future: Used for user comment history 54 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 55 56 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 57 // Supports hot, top, and new sorting with cursor-based pagination
··· 50 CountByParent(ctx context.Context, parentURI string) (int, error) 51 52 // ListByCommenter retrieves all comments by a specific user 53 + // Deprecated: Use ListByCommenterWithCursor for cursor-based pagination 54 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 55 + 56 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 57 + // Used for user profile comment history (social.coves.actor.getComments) 58 + // Supports optional community filtering and returns next page cursor 59 + ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) 60 61 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 62 // Supports hot, top, and new sorting with cursor-based pagination
+17
internal/core/comments/view_models.go
··· 67 Cursor *string `json:"cursor,omitempty"` 68 Comments []*ThreadViewComment `json:"comments"` 69 }
··· 67 Cursor *string `json:"cursor,omitempty"` 68 Comments []*ThreadViewComment `json:"comments"` 69 } 70 + 71 + // GetActorCommentsRequest defines the parameters for fetching a user's comments 72 + // Used by social.coves.actor.getComments endpoint 73 + type GetActorCommentsRequest struct { 74 + ActorDID string // Required: DID of the commenter 75 + Community string // Optional: filter to comments in a specific community (handle or DID) 76 + Limit int // Max comments to return (1-100, default 50) 77 + Cursor *string // Pagination cursor from previous response 78 + ViewerDID *string // Optional: DID of the viewer for populating viewer state 79 + } 80 + 81 + // GetActorCommentsResponse represents the response for fetching a user's comments 82 + // Matches social.coves.actor.getComments lexicon output 83 + type GetActorCommentsResponse struct { 84 + Comments []*CommentView `json:"comments"` 85 + Cursor *string `json:"cursor,omitempty"` 86 + }
+151
internal/db/postgres/comment_repo.go
··· 410 return result, nil 411 } 412 413 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 414 // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 415 // Uses cursor-based pagination with composite keys for consistent ordering ··· 964 // If votes table doesn't exist yet, return empty map instead of error 965 // This allows the API to work before votes indexing is fully implemented 966 if strings.Contains(err.Error(), "does not exist") { 967 return make(map[string]interface{}), nil 968 } 969 return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
··· 410 return result, nil 411 } 412 413 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 414 + // Used for user profile comment history (social.coves.actor.getComments) 415 + // Supports optional community filtering and returns next page cursor 416 + // Uses chronological ordering (newest first) with composite key cursor for stable pagination 417 + func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) { 418 + // Parse cursor for pagination 419 + cursorFilter, cursorValues, err := r.parseCommenterCursor(req.Cursor) 420 + if err != nil { 421 + return nil, nil, fmt.Errorf("invalid cursor: %w", err) 422 + } 423 + 424 + // Build community filter if provided 425 + // Parameter numbering: $1=commenterDID, $2=limit+1 (for pagination detection) 426 + // Cursor values (if present) use $3 and $4, community DID comes after 427 + var communityFilter string 428 + var communityValue []interface{} 429 + paramOffset := 2 + len(cursorValues) // Start after $1, $2, and any cursor params 430 + if req.CommunityDID != nil && *req.CommunityDID != "" { 431 + paramOffset++ 432 + communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset) 433 + communityValue = append(communityValue, *req.CommunityDID) 434 + } 435 + 436 + // Build complete query with JOINs and filters 437 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet 438 + query := fmt.Sprintf(` 439 + SELECT 440 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 441 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 442 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 443 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 444 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 445 + COALESCE(u.handle, c.commenter_did) as author_handle 446 + FROM comments c 447 + LEFT JOIN users u ON c.commenter_did = u.did 448 + WHERE c.commenter_did = $1 449 + AND c.deleted_at IS NULL 450 + %s 451 + %s 452 + ORDER BY c.created_at DESC, c.uri DESC 453 + LIMIT $2 454 + `, communityFilter, cursorFilter) 455 + 456 + // Prepare query arguments 457 + args := []interface{}{req.CommenterDID, req.Limit + 1} // +1 to detect next page 458 + args = append(args, cursorValues...) 459 + args = append(args, communityValue...) 460 + 461 + // Execute query 462 + rows, err := r.db.QueryContext(ctx, query, args...) 463 + if err != nil { 464 + return nil, nil, fmt.Errorf("failed to query comments by commenter: %w", err) 465 + } 466 + defer func() { 467 + if err := rows.Close(); err != nil { 468 + log.Printf("Failed to close rows: %v", err) 469 + } 470 + }() 471 + 472 + // Scan results 473 + var result []*comments.Comment 474 + for rows.Next() { 475 + var comment comments.Comment 476 + var langs pq.StringArray 477 + var authorHandle string 478 + 479 + err := rows.Scan( 480 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 481 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 482 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 483 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 484 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 485 + &authorHandle, 486 + ) 487 + if err != nil { 488 + return nil, nil, fmt.Errorf("failed to scan comment: %w", err) 489 + } 490 + 491 + comment.Langs = langs 492 + comment.CommenterHandle = authorHandle 493 + result = append(result, &comment) 494 + } 495 + 496 + if err = rows.Err(); err != nil { 497 + return nil, nil, fmt.Errorf("error iterating comments: %w", err) 498 + } 499 + 500 + // Handle pagination cursor 501 + var nextCursor *string 502 + if len(result) > req.Limit && req.Limit > 0 { 503 + result = result[:req.Limit] 504 + lastComment := result[len(result)-1] 505 + cursorStr := r.buildCommenterCursor(lastComment) 506 + nextCursor = &cursorStr 507 + } 508 + 509 + return result, nextCursor, nil 510 + } 511 + 512 + // parseCommenterCursor decodes pagination cursor for commenter comments 513 + // Cursor format: createdAt|uri (same as "new" sort for other comment queries) 514 + // 515 + // IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4). 516 + // The caller (ListByCommenterWithCursor) must ensure parameters are ordered as: 517 + // $1=commenterDID, $2=limit+1, $3=createdAt, $4=uri, then community DID if present. 518 + // If you modify the parameter order in the caller, you must update the filter here. 519 + func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) { 520 + if cursor == nil || *cursor == "" { 521 + return "", nil, nil 522 + } 523 + 524 + // Validate cursor size to prevent DoS via massive base64 strings 525 + const maxCursorSize = 1024 526 + if len(*cursor) > maxCursorSize { 527 + return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize) 528 + } 529 + 530 + // Decode base64 cursor 531 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 532 + if err != nil { 533 + return "", nil, fmt.Errorf("invalid cursor encoding") 534 + } 535 + 536 + // Parse cursor: createdAt|uri 537 + parts := strings.Split(string(decoded), "|") 538 + if len(parts) != 2 { 539 + return "", nil, fmt.Errorf("invalid cursor format") 540 + } 541 + 542 + createdAt := parts[0] 543 + uri := parts[1] 544 + 545 + // Validate AT-URI format 546 + if !strings.HasPrefix(uri, "at://") { 547 + return "", nil, fmt.Errorf("invalid cursor URI") 548 + } 549 + 550 + filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))` 551 + return filter, []interface{}{createdAt, uri}, nil 552 + } 553 + 554 + // buildCommenterCursor creates pagination cursor from last comment 555 + // Uses createdAt|uri format for stable pagination 556 + func (r *postgresCommentRepo) buildCommenterCursor(comment *comments.Comment) string { 557 + cursorStr := fmt.Sprintf("%s|%s", 558 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 559 + comment.URI) 560 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 561 + } 562 + 563 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 564 // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 565 // Uses cursor-based pagination with composite keys for consistent ordering ··· 1114 // If votes table doesn't exist yet, return empty map instead of error 1115 // This allows the API to work before votes indexing is fully implemented 1116 if strings.Contains(err.Error(), "does not exist") { 1117 + log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs)) 1118 return make(map[string]interface{}), nil 1119 } 1120 return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+5 -5
tests/integration/author_posts_e2e_test.go
··· 110 111 // Setup HTTP server with XRPC routes 112 r := chi.NewRouter() 113 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 114 httpServer := httptest.NewServer(r) 115 defer httpServer.Close() 116 ··· 322 // Setup HTTP server 323 e2eAuth := NewE2EOAuthMiddleware() 324 r := chi.NewRouter() 325 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 326 httpServer := httptest.NewServer(r) 327 defer httpServer.Close() 328 ··· 443 // Setup HTTP server 444 e2eAuth := NewE2EOAuthMiddleware() 445 r := chi.NewRouter() 446 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 447 httpServer := httptest.NewServer(r) 448 defer httpServer.Close() 449 ··· 606 // Verify post is now queryable via GetAuthorPosts 607 e2eAuth := NewE2EOAuthMiddleware() 608 r := chi.NewRouter() 609 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 610 httpServer := httptest.NewServer(r) 611 defer httpServer.Close() 612 ··· 679 // Setup HTTP server 680 e2eAuth := NewE2EOAuthMiddleware() 681 r := chi.NewRouter() 682 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 683 httpServer := httptest.NewServer(r) 684 defer httpServer.Close() 685
··· 110 111 // Setup HTTP server with XRPC routes 112 r := chi.NewRouter() 113 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 114 httpServer := httptest.NewServer(r) 115 defer httpServer.Close() 116 ··· 322 // Setup HTTP server 323 e2eAuth := NewE2EOAuthMiddleware() 324 r := chi.NewRouter() 325 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 326 httpServer := httptest.NewServer(r) 327 defer httpServer.Close() 328 ··· 443 // Setup HTTP server 444 e2eAuth := NewE2EOAuthMiddleware() 445 r := chi.NewRouter() 446 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 447 httpServer := httptest.NewServer(r) 448 defer httpServer.Close() 449 ··· 606 // Verify post is now queryable via GetAuthorPosts 607 e2eAuth := NewE2EOAuthMiddleware() 608 r := chi.NewRouter() 609 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 610 httpServer := httptest.NewServer(r) 611 defer httpServer.Close() 612 ··· 679 // Setup HTTP server 680 e2eAuth := NewE2EOAuthMiddleware() 681 r := chi.NewRouter() 682 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 683 httpServer := httptest.NewServer(r) 684 defer httpServer.Close() 685