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 630 routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) 631 631 log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") 632 632 633 - routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, authMiddleware) 633 + routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, commentService, authMiddleware) 634 634 log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") 635 635 log.Println(" - GET /xrpc/social.coves.actor.getPosts") 636 + log.Println(" - GET /xrpc/social.coves.actor.getComments") 636 637 637 638 routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver) 638 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 4 "Coves/internal/api/handlers/actor" 5 5 "Coves/internal/api/middleware" 6 6 "Coves/internal/core/blueskypost" 7 + "Coves/internal/core/comments" 7 8 "Coves/internal/core/posts" 8 9 "Coves/internal/core/users" 9 10 "Coves/internal/core/votes" ··· 18 19 userService users.UserService, 19 20 voteService votes.Service, 20 21 blueskyService blueskypost.Service, 22 + commentService comments.Service, 21 23 authMiddleware *middleware.OAuthAuthMiddleware, 22 24 ) { 23 25 // Create handlers 24 26 getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) 27 + getCommentsHandler := actor.NewGetCommentsHandler(commentService, userService, voteService) 25 28 26 29 // GET /xrpc/social.coves.actor.getPosts 27 30 // Public endpoint with optional auth for viewer-specific state (vote state) 28 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) 29 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 79 Neg *bool `json:"neg,omitempty"` 80 80 Val string `json:"val"` 81 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 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 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 + 49 53 // CreateComment creates a new comment or reply 50 54 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 55 ··· 1014 1018 // These fields are stored as JSONB in the database and need proper deserialization 1015 1019 1016 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 1017 1149 } 1018 1150 1019 1151 // validateGetCommentsRequest validates and normalizes request parameters
+373 -4
internal/core/comments/comment_service_test.go
··· 17 17 18 18 // mockCommentRepo is a mock implementation of the comment Repository interface 19 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) 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) 24 25 } 25 26 26 27 func newMockCommentRepo() *mockCommentRepo { ··· 94 95 95 96 func (m *mockCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) { 96 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 97 105 } 98 106 99 107 func (m *mockCommentRepo) ListByParentWithHotRank( ··· 1454 1462 func strPtr(s string) *string { 1455 1463 return &s 1456 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 50 CountByParent(ctx context.Context, parentURI string) (int, error) 51 51 52 52 // ListByCommenter retrieves all comments by a specific user 53 - // Future: Used for user comment history 53 + // Deprecated: Use ListByCommenterWithCursor for cursor-based pagination 54 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) 55 60 56 61 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 57 62 // Supports hot, top, and new sorting with cursor-based pagination
+17
internal/core/comments/view_models.go
··· 67 67 Cursor *string `json:"cursor,omitempty"` 68 68 Comments []*ThreadViewComment `json:"comments"` 69 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 410 return result, nil 411 411 } 412 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 + 413 563 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 414 564 // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 415 565 // Uses cursor-based pagination with composite keys for consistent ordering ··· 964 1114 // If votes table doesn't exist yet, return empty map instead of error 965 1115 // This allows the API to work before votes indexing is fully implemented 966 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)) 967 1118 return make(map[string]interface{}), nil 968 1119 } 969 1120 return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+5 -5
tests/integration/author_posts_e2e_test.go
··· 110 110 111 111 // Setup HTTP server with XRPC routes 112 112 r := chi.NewRouter() 113 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 113 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 114 114 httpServer := httptest.NewServer(r) 115 115 defer httpServer.Close() 116 116 ··· 322 322 // Setup HTTP server 323 323 e2eAuth := NewE2EOAuthMiddleware() 324 324 r := chi.NewRouter() 325 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 325 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 326 326 httpServer := httptest.NewServer(r) 327 327 defer httpServer.Close() 328 328 ··· 443 443 // Setup HTTP server 444 444 e2eAuth := NewE2EOAuthMiddleware() 445 445 r := chi.NewRouter() 446 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 446 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 447 447 httpServer := httptest.NewServer(r) 448 448 defer httpServer.Close() 449 449 ··· 606 606 // Verify post is now queryable via GetAuthorPosts 607 607 e2eAuth := NewE2EOAuthMiddleware() 608 608 r := chi.NewRouter() 609 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 609 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 610 610 httpServer := httptest.NewServer(r) 611 611 defer httpServer.Close() 612 612 ··· 679 679 // Setup HTTP server 680 680 e2eAuth := NewE2EOAuthMiddleware() 681 681 r := chi.NewRouter() 682 - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) 682 + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) 683 683 httpServer := httptest.NewServer(r) 684 684 defer httpServer.Close() 685 685