From 150020a072dde9c1ade529d70c74c59cb2f72cc5 Mon Sep 17 00:00:00 2001 From: Bretton Date: Sun, 4 Jan 2026 12:48:27 -0800 Subject: [PATCH] feat(actor): implement social.coves.actor.getComments endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/server/main.go | 3 +- internal/api/handlers/actor/get_comments.go | 265 ++++++++ .../api/handlers/actor/get_comments_test.go | 617 ++++++++++++++++++ internal/api/routes/actor.go | 7 + .../social/coves/actor/getComments.json | 60 ++ internal/core/comments/comment.go | 9 + internal/core/comments/comment_service.go | 132 ++++ .../core/comments/comment_service_test.go | 377 ++++++++++- internal/core/comments/interfaces.go | 7 +- internal/core/comments/view_models.go | 17 + internal/db/postgres/comment_repo.go | 151 +++++ tests/integration/author_posts_e2e_test.go | 10 +- 12 files changed, 1644 insertions(+), 11 deletions(-) create mode 100644 internal/api/handlers/actor/get_comments.go create mode 100644 internal/api/handlers/actor/get_comments_test.go create mode 100644 internal/atproto/lexicon/social/coves/actor/getComments.json diff --git a/cmd/server/main.go b/cmd/server/main.go index 2c17686..b4af6f3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -630,9 +630,10 @@ func main() { routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") - routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, authMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, commentService, authMiddleware) log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") log.Println(" - GET /xrpc/social.coves.actor.getPosts") + log.Println(" - GET /xrpc/social.coves.actor.getComments") routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver) log.Println("Aggregator XRPC endpoints registered (query endpoints public, registration endpoint public)") diff --git a/internal/api/handlers/actor/get_comments.go b/internal/api/handlers/actor/get_comments.go new file mode 100644 index 0000000..c24abe1 --- /dev/null +++ b/internal/api/handlers/actor/get_comments.go @@ -0,0 +1,265 @@ +package actor + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "strings" + + "Coves/internal/api/middleware" + "Coves/internal/core/comments" + "Coves/internal/core/users" + "Coves/internal/core/votes" +) + +// GetCommentsHandler handles actor comment retrieval +type GetCommentsHandler struct { + commentService comments.Service + userService users.UserService + voteService votes.Service +} + +// NewGetCommentsHandler creates a new actor comments handler +func NewGetCommentsHandler( + commentService comments.Service, + userService users.UserService, + voteService votes.Service, +) *GetCommentsHandler { + return &GetCommentsHandler{ + commentService: commentService, + userService: userService, + voteService: voteService, + } +} + +// HandleGetComments retrieves comments by an actor (user) +// GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=... +func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + req, err := h.parseRequest(r) + if err != nil { + // Check if it's an actor not found error (from handle resolution) + var actorNotFound *actorNotFoundError + if errors.As(err, &actorNotFound) { + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") + return + } + + // Check if it's an infrastructure failure during resolution + // (database down, DNS failures, network errors, etc.) + var resolutionFailed *resolutionFailedError + if errors.As(err, &resolutionFailed) { + log.Printf("ERROR: Actor resolution infrastructure failure: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity") + return + } + + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + return + } + + // Get viewer DID for populating viewer state (optional) + viewerDID := middleware.GetUserDID(r) + if viewerDID != "" { + req.ViewerDID = &viewerDID + } + + // Get actor comments from service + response, err := h.commentService.GetActorComments(r.Context(), req) + if err != nil { + handleCommentServiceError(w, err) + return + } + + // Populate viewer vote state if authenticated + h.populateViewerVoteState(r, response) + + // Pre-encode response to buffer before writing headers + // This ensures we can return a proper error if encoding fails + responseBytes, err := json.Marshal(response) + if err != nil { + log.Printf("ERROR: Failed to encode actor comments response: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") + return + } + + // Return comments + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(responseBytes); err != nil { + log.Printf("ERROR: Failed to write actor comments response: %v", err) + } +} + +// parseRequest parses query parameters into GetActorCommentsRequest +func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) { + req := &comments.GetActorCommentsRequest{} + + // Required: actor (handle or DID) + actor := r.URL.Query().Get("actor") + if actor == "" { + return nil, &validationError{field: "actor", message: "actor parameter is required"} + } + // Validate actor length to prevent DoS via massive strings + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) + // Max handle length is 253 chars (DNS limit) + const maxActorLength = 2048 + if len(actor) > maxActorLength { + return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"} + } + + // Resolve actor to DID if it's a handle + actorDID, err := h.resolveActor(r, actor) + if err != nil { + return nil, err + } + req.ActorDID = actorDID + + // Optional: community (handle or DID) + req.Community = r.URL.Query().Get("community") + + // Optional: limit (default: 50, max: 100) + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil { + return nil, &validationError{field: "limit", message: "limit must be a valid integer"} + } + req.Limit = limit + } + + // Optional: cursor + if cursor := r.URL.Query().Get("cursor"); cursor != "" { + req.Cursor = &cursor + } + + return req, nil +} + +// resolveActor converts an actor identifier (handle or DID) to a DID +func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) { + // If it's already a DID, return it + if strings.HasPrefix(actor, "did:") { + return actor, nil + } + + // It's a handle - resolve to DID using user service + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) + if err != nil { + // Check for context errors (timeouts, cancellation) - these are infrastructure errors + if r.Context().Err() != nil { + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} + } + + // Check for common "not found" patterns in error message + errStr := err.Error() + isNotFound := strings.Contains(errStr, "not found") || + strings.Contains(errStr, "no rows") || + strings.Contains(errStr, "unable to resolve") + + if isNotFound { + return "", &actorNotFoundError{actor: actor} + } + + // For other errors (network, database, DNS failures), return infrastructure error + // This ensures users see "internal error" not "actor not found" for real problems + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) + return "", &resolutionFailedError{actor: actor, cause: err} + } + + return did, nil +} + +// populateViewerVoteState enriches comment views with the authenticated user's vote state +func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) { + if h.voteService == nil || response == nil || len(response.Comments) == 0 { + return + } + + session := middleware.GetOAuthSession(r) + if session == nil { + return + } + + userDID := middleware.GetUserDID(r) + if userDID == "" { + return + } + + // Ensure vote cache is populated from PDS + if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil { + log.Printf("Warning: failed to populate vote cache for actor comments: %v", err) + return + } + + // Collect comment URIs to batch lookup + commentURIs := make([]string, 0, len(response.Comments)) + for _, comment := range response.Comments { + if comment != nil { + commentURIs = append(commentURIs, comment.URI) + } + } + + // Get viewer votes for all comments + viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs) + + // Populate viewer state on each comment + for _, comment := range response.Comments { + if comment != nil { + if vote, exists := viewerVotes[comment.URI]; exists { + comment.Viewer = &comments.CommentViewerState{ + Vote: &vote.Direction, + VoteURI: &vote.URI, + } + } + } + } +} + +// handleCommentServiceError maps service errors to HTTP responses +func handleCommentServiceError(w http.ResponseWriter, err error) { + if err == nil { + return + } + + errStr := err.Error() + + // Check for validation errors + if strings.Contains(errStr, "invalid request") { + writeError(w, http.StatusBadRequest, "InvalidRequest", errStr) + return + } + + // Check for not found errors + if comments.IsNotFound(err) || strings.Contains(errStr, "not found") { + writeError(w, http.StatusNotFound, "NotFound", "Resource not found") + return + } + + // Check for authorization errors + if errors.Is(err, comments.ErrNotAuthorized) { + writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") + return + } + + // Default to internal server error + log.Printf("ERROR: Comment service error: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") +} + +// validationError represents a validation error for a specific field +type validationError struct { + field string + message string +} + +func (e *validationError) Error() string { + return e.message +} diff --git a/internal/api/handlers/actor/get_comments_test.go b/internal/api/handlers/actor/get_comments_test.go new file mode 100644 index 0000000..e5afcdd --- /dev/null +++ b/internal/api/handlers/actor/get_comments_test.go @@ -0,0 +1,617 @@ +package actor + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" + + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" +) + +// mockCommentService implements a comment service interface for testing +type mockCommentService struct { + getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) +} + +func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + if m.getActorCommentsFunc != nil { + return m.getActorCommentsFunc(ctx, req) + } + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{}, + Cursor: nil, + }, nil +} + +// Implement other Service methods as no-ops +func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) { + return nil, nil +} + +func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) { + return nil, nil +} + +func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) { + return nil, nil +} + +func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error { + return nil +} + +// mockUserServiceForComments implements users.UserService for testing getComments +type mockUserServiceForComments struct { + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) +} + +func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { + return nil, nil +} + +func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { + if m.resolveHandleToDIDFunc != nil { + return m.resolveHandleToDIDFunc(ctx, handle) + } + return "did:plc:testuser", nil +} + +func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { + return nil, nil +} + +func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error { + return nil +} + +func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { + return nil, nil +} + +// mockVoteServiceForComments implements votes.Service for testing getComments +type mockVoteServiceForComments struct{} + +func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { + return nil, nil +} + +func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { + return nil +} + +func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { + return nil +} + +func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { + return nil +} + +func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { + return nil +} + +func TestGetCommentsHandler_Success(t *testing.T) { + createdAt := time.Now().Format(time.RFC3339) + indexedAt := time.Now().Format(time.RFC3339) + + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{ + { + URI: "at://did:plc:testuser/social.coves.community.comment/abc123", + CID: "bafytest123", + Content: "Test comment content", + CreatedAt: createdAt, + IndexedAt: indexedAt, + Author: &posts.AuthorView{ + DID: "did:plc:testuser", + Handle: "test.user", + }, + Stats: &comments.CommentStats{ + Upvotes: 5, + Downvotes: 1, + Score: 4, + ReplyCount: 2, + }, + }, + }, + }, nil + }, + } + mockUsers := &mockUserServiceForComments{} + mockVotes := &mockVoteServiceForComments{} + + handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var response comments.GetActorCommentsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(response.Comments) != 1 { + t.Errorf("Expected 1 comment in response, got %d", len(response.Comments)) + } + + if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" { + t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI) + } + + if response.Comments[0].Content != "Test comment content" { + t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content) + } +} + +func TestGetCommentsHandler_MissingActor(t *testing.T) { + handler := NewGetCommentsHandler( + &mockCommentService{}, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "InvalidRequest" { + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) + } +} + +func TestGetCommentsHandler_InvalidLimit(t *testing.T) { + handler := NewGetCommentsHandler( + &mockCommentService{}, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "InvalidRequest" { + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) + } +} + +func TestGetCommentsHandler_ActorNotFound(t *testing.T) { + mockUsers := &mockUserServiceForComments{ + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { + return "", posts.ErrActorNotFound + }, + } + + handler := NewGetCommentsHandler( + &mockCommentService{}, + mockUsers, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "ActorNotFound" { + t.Errorf("Expected error 'ActorNotFound', got '%s'", response.Error) + } +} + +func TestGetCommentsHandler_ActorLengthExceedsMax(t *testing.T) { + handler := NewGetCommentsHandler( + &mockCommentService{}, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + // Create an actor parameter that exceeds 2048 characters using valid URL characters + longActorBytes := make([]byte, 2100) + for i := range longActorBytes { + longActorBytes[i] = 'a' + } + longActor := "did:plc:" + string(longActorBytes) + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } +} + +func TestGetCommentsHandler_InvalidCursor(t *testing.T) { + // The handleCommentServiceError function checks for "invalid request" in error message + // to return a BadRequest. An invalid cursor error falls under this category. + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + return nil, errors.New("invalid request: invalid cursor format") + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "InvalidRequest" { + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) + } +} + +func TestGetCommentsHandler_MethodNotAllowed(t *testing.T) { + handler := NewGetCommentsHandler( + &mockCommentService{}, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getComments", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected status 405, got %d", rec.Code) + } +} + +func TestGetCommentsHandler_HandleResolution(t *testing.T) { + resolvedDID := "" + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + resolvedDID = req.ActorDID + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil + }, + } + mockUsers := &mockUserServiceForComments{ + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { + if handle == "test.user" { + return "did:plc:resolveduser123", nil + } + return "", posts.ErrActorNotFound + }, + } + + handler := NewGetCommentsHandler( + mockComments, + mockUsers, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if resolvedDID != "did:plc:resolveduser123" { + t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) + } +} + +func TestGetCommentsHandler_DIDPassThrough(t *testing.T) { + receivedDID := "" + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + receivedDID = req.ActorDID + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + // When actor is already a DID, it should pass through without resolution + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if receivedDID != "did:plc:directuser" { + t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) + } +} + +func TestGetCommentsHandler_EmptyCommentsArray(t *testing.T) { + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{}, + }, nil + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var response comments.GetActorCommentsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Comments == nil { + t.Error("Expected comments array to be non-nil (empty array), got nil") + } + + if len(response.Comments) != 0 { + t.Errorf("Expected 0 comments for new user, got %d", len(response.Comments)) + } +} + +func TestGetCommentsHandler_WithCursor(t *testing.T) { + receivedCursor := "" + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + if req.Cursor != nil { + receivedCursor = *req.Cursor + } + nextCursor := "page2cursor" + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{}, + Cursor: &nextCursor, + }, nil + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if receivedCursor != "testcursor123" { + t.Errorf("Expected cursor 'testcursor123', got '%s'", receivedCursor) + } + + var response comments.GetActorCommentsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Cursor == nil || *response.Cursor != "page2cursor" { + t.Error("Expected response to include next cursor") + } +} + +func TestGetCommentsHandler_WithLimit(t *testing.T) { + receivedLimit := 0 + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + receivedLimit = req.Limit + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{}, + }, nil + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if receivedLimit != 25 { + t.Errorf("Expected limit 25, got %d", receivedLimit) + } +} + +func TestGetCommentsHandler_WithCommunityFilter(t *testing.T) { + receivedCommunity := "" + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + receivedCommunity = req.Community + return &comments.GetActorCommentsResponse{ + Comments: []*comments.CommentView{}, + }, nil + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if receivedCommunity != "did:plc:community123" { + t.Errorf("Expected community 'did:plc:community123', got '%s'", receivedCommunity) + } +} + +func TestGetCommentsHandler_ServiceError_Returns500(t *testing.T) { + // Test that generic service errors (database failures, etc.) return 500 + mockComments := &mockCommentService{ + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { + return nil, errors.New("database connection failed") + }, + } + + handler := NewGetCommentsHandler( + mockComments, + &mockUserServiceForComments{}, + &mockVoteServiceForComments{}, + ) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "InternalServerError" { + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) + } + + // Verify error message doesn't leak internal details + if response.Message == "database connection failed" { + t.Error("Error message should not leak internal error details") + } +} + +func TestGetCommentsHandler_ResolutionFailedError_Returns500(t *testing.T) { + // Test that infrastructure failures during handle resolution return 500, not 400 + mockUsers := &mockUserServiceForComments{ + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { + // Simulate a database failure during resolution + return "", errors.New("connection refused") + }, + } + + handler := NewGetCommentsHandler( + &mockCommentService{}, + mockUsers, + &mockVoteServiceForComments{}, + ) + + // Use a handle (not a DID) to trigger resolution + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) + rec := httptest.NewRecorder() + + handler.HandleGetComments(rec, req) + + // Infrastructure failures should return 500, not 400 or 404 + if rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500 for infrastructure failure, got %d", rec.Code) + } + + var response ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Error != "InternalServerError" { + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) + } +} diff --git a/internal/api/routes/actor.go b/internal/api/routes/actor.go index c07cf7c..3de59f9 100644 --- a/internal/api/routes/actor.go +++ b/internal/api/routes/actor.go @@ -4,6 +4,7 @@ import ( "Coves/internal/api/handlers/actor" "Coves/internal/api/middleware" "Coves/internal/core/blueskypost" + "Coves/internal/core/comments" "Coves/internal/core/posts" "Coves/internal/core/users" "Coves/internal/core/votes" @@ -18,12 +19,18 @@ func RegisterActorRoutes( userService users.UserService, voteService votes.Service, blueskyService blueskypost.Service, + commentService comments.Service, authMiddleware *middleware.OAuthAuthMiddleware, ) { // Create handlers getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) + getCommentsHandler := actor.NewGetCommentsHandler(commentService, userService, voteService) // GET /xrpc/social.coves.actor.getPosts // Public endpoint with optional auth for viewer-specific state (vote state) r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getPosts", getPostsHandler.HandleGetPosts) + + // GET /xrpc/social.coves.actor.getComments + // Public endpoint with optional auth for viewer-specific state (vote state) + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getComments", getCommentsHandler.HandleGetComments) } diff --git a/internal/atproto/lexicon/social/coves/actor/getComments.json b/internal/atproto/lexicon/social/coves/actor/getComments.json new file mode 100644 index 0000000..4be1bfe --- /dev/null +++ b/internal/atproto/lexicon/social/coves/actor/getComments.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "social.coves.actor.getComments", + "defs": { + "main": { + "type": "query", + "description": "Get a user's comments for their profile page.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the user" + }, + "community": { + "type": "string", + "format": "at-identifier", + "description": "Filter to comments in a specific community" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["comments"], + "properties": { + "comments": { + "type": "array", + "items": { + "type": "ref", + "ref": "social.coves.community.comment.defs#commentView" + } + }, + "cursor": { + "type": "string" + } + } + } + }, + "errors": [ + { + "name": "NotFound", + "description": "Actor not found" + } + ] + } + } +} diff --git a/internal/core/comments/comment.go b/internal/core/comments/comment.go index 101a356..762e41c 100644 --- a/internal/core/comments/comment.go +++ b/internal/core/comments/comment.go @@ -79,3 +79,12 @@ type SelfLabel struct { Neg *bool `json:"neg,omitempty"` Val string `json:"val"` } + +// ListByCommenterRequest defines the parameters for fetching a user's comments +// Used by social.coves.actor.getComments endpoint +type ListByCommenterRequest struct { + CommenterDID string // Required: DID of the commenter + CommunityDID *string // Optional: filter to comments in a specific community + Limit int // Max comments to return (1-100) + Cursor *string // Pagination cursor from previous response +} diff --git a/internal/core/comments/comment_service.go b/internal/core/comments/comment_service.go index ffbd586..7d38892 100644 --- a/internal/core/comments/comment_service.go +++ b/internal/core/comments/comment_service.go @@ -46,6 +46,10 @@ type Service interface { // Supports hot, top, and new sorting with configurable depth and pagination GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) + // GetActorComments retrieves comments by a user for their profile page + // Supports optional community filtering and cursor-based pagination + GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) + // CreateComment creates a new comment or reply CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) @@ -1016,6 +1020,134 @@ func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { return record } +// GetActorComments retrieves comments by a user for their profile page +// Supports optional community filtering and cursor-based pagination +// Algorithm: +// 1. Validate and normalize request parameters (limit bounds) +// 2. Resolve community identifier to DID if provided +// 3. Fetch comments from repository with cursor-based pagination +// 4. Build CommentView for each comment with author info and stats +// 5. Return response with pagination cursor +func (s *commentService) GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) { + // 1. Validate and normalize request + if err := validateGetActorCommentsRequest(req); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + // Add timeout to prevent runaway queries + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // 2. Resolve community identifier to DID if provided + var communityDID *string + if req.Community != "" { + // Check if it's already a DID + if strings.HasPrefix(req.Community, "did:") { + communityDID = &req.Community + } else { + // It's a handle - resolve to DID via community repository + community, err := s.communityRepo.GetByHandle(ctx, req.Community) + if err != nil { + // If community not found, return empty results rather than error + // This matches behavior of other endpoints + if errors.Is(err, communities.ErrCommunityNotFound) { + return &GetActorCommentsResponse{ + Comments: []*CommentView{}, + Cursor: nil, + }, nil + } + return nil, fmt.Errorf("failed to resolve community: %w", err) + } + communityDID = &community.DID + } + } + + // 3. Fetch comments from repository + repoReq := ListByCommenterRequest{ + CommenterDID: req.ActorDID, + CommunityDID: communityDID, + Limit: req.Limit, + Cursor: req.Cursor, + } + + dbComments, nextCursor, err := s.commentRepo.ListByCommenterWithCursor(ctx, repoReq) + if err != nil { + return nil, fmt.Errorf("failed to fetch comments: %w", err) + } + + // 4. Build CommentViews for each comment + // Batch fetch vote states if viewer is authenticated + var voteStates map[string]interface{} + if req.ViewerDID != nil && len(dbComments) > 0 { + commentURIs := make([]string, 0, len(dbComments)) + for _, comment := range dbComments { + commentURIs = append(commentURIs, comment.URI) + } + + var err error + voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *req.ViewerDID, commentURIs) + if err != nil { + // Log error but don't fail the request - vote state is optional + log.Printf("Warning: Failed to fetch vote states for actor comments: %v", err) + } + } + + // Batch fetch user data for comment authors (should all be the same user, but handle consistently) + usersByDID := make(map[string]*users.User) + if len(dbComments) > 0 { + // For actor comments, all comments are by the same user + // But we still use the batch pattern for consistency with other methods + user, err := s.userRepo.GetByDID(ctx, req.ActorDID) + if err != nil { + // Log error but don't fail request - user data is optional + log.Printf("Warning: Failed to fetch user for actor %s: %v", req.ActorDID, err) + } else if user != nil { + usersByDID[user.DID] = user + } + } + + // Build comment views + commentViews := make([]*CommentView, 0, len(dbComments)) + for _, comment := range dbComments { + commentView := s.buildCommentView(comment, req.ViewerDID, voteStates, usersByDID) + commentViews = append(commentViews, commentView) + } + + // 5. Return response with comments and cursor + return &GetActorCommentsResponse{ + Comments: commentViews, + Cursor: nextCursor, + }, nil +} + +// validateGetActorCommentsRequest validates and normalizes request parameters +// Applies default values and enforces bounds per API specification +func validateGetActorCommentsRequest(req *GetActorCommentsRequest) error { + if req == nil { + return errors.New("request cannot be nil") + } + + // ActorDID is required + if req.ActorDID == "" { + return errors.New("actor DID is required") + } + + // Validate DID format + if !strings.HasPrefix(req.ActorDID, "did:") { + return errors.New("invalid actor DID format") + } + + // Apply limit defaults and bounds (1-100, default 50) + if req.Limit <= 0 { + req.Limit = 50 + } + if req.Limit > 100 { + req.Limit = 100 + } + + return nil +} + // validateGetCommentsRequest validates and normalizes request parameters // Applies default values and enforces bounds per API specification func validateGetCommentsRequest(req *GetCommentsRequest) error { diff --git a/internal/core/comments/comment_service_test.go b/internal/core/comments/comment_service_test.go index 37a4506..a72b4c4 100644 --- a/internal/core/comments/comment_service_test.go +++ b/internal/core/comments/comment_service_test.go @@ -17,10 +17,11 @@ import ( // mockCommentRepo is a mock implementation of the comment Repository interface type mockCommentRepo struct { - comments map[string]*Comment - listByParentWithHotRankFunc func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) - listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error) - getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) + comments map[string]*Comment + listByParentWithHotRankFunc func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) + listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error) + getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) + listByCommenterWithCursorFunc func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) } func newMockCommentRepo() *mockCommentRepo { @@ -96,6 +97,13 @@ func (m *mockCommentRepo) ListByCommenter(ctx context.Context, commenterDID stri return nil, nil } +func (m *mockCommentRepo) ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + if m.listByCommenterWithCursorFunc != nil { + return m.listByCommenterWithCursorFunc(ctx, req) + } + return []*Comment{}, nil, nil +} + func (m *mockCommentRepo) ListByParentWithHotRank( ctx context.Context, parentURI string, @@ -1454,3 +1462,364 @@ func TestBuildCommentView_EmptyStringVsNilHandling(t *testing.T) { func strPtr(s string) *string { return &s } + +// Test suite for GetActorComments + +func TestCommentService_GetActorComments_ValidRequest(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + viewerDID := "did:plc:viewer123" + postURI := "at://did:plc:post123/app.bsky.feed.post/test" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + // Add actor to user repo + actor := createTestUser(actorDID, "actor.test") + _, _ = userRepo.Create(context.Background(), actor) + + // Create test comments + comment1 := createTestComment("at://did:plc:actor123/comment/1", actorDID, "actor.test", postURI, postURI, 0) + comment2 := createTestComment("at://did:plc:actor123/comment/2", actorDID, "actor.test", postURI, postURI, 0) + + // Setup mock to return comments + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + if req.CommenterDID == actorDID { + return []*Comment{comment1, comment2}, nil, nil + } + return []*Comment{}, nil, nil + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + ViewerDID: &viewerDID, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, resp.Comments, 2) + assert.Equal(t, comment1.URI, resp.Comments[0].URI) + assert.Equal(t, comment2.URI, resp.Comments[1].URI) +} + +func TestCommentService_GetActorComments_EmptyActorDID(t *testing.T) { + // Setup + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with empty ActorDID + req := &GetActorCommentsRequest{ + ActorDID: "", + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "actor DID is required") +} + +func TestCommentService_GetActorComments_InvalidActorDIDFormat(t *testing.T) { + // Setup + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with invalid DID format (missing did: prefix) + req := &GetActorCommentsRequest{ + ActorDID: "plc:actor123", + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "invalid actor DID format") +} + +func TestCommentService_GetActorComments_CommunityHandleResolution(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + communityDID := "did:plc:community123" + communityHandle := "c-test.coves.social" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + // Add community to repo + community := createTestCommunity(communityDID, communityHandle) + _, _ = communityRepo.Create(context.Background(), community) + + // Track what community filter was passed to repo + var receivedCommunityDID *string + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + receivedCommunityDID = req.CommunityDID + return []*Comment{}, nil, nil + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with community handle (not DID) + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Community: communityHandle, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.NotNil(t, receivedCommunityDID) + assert.Equal(t, communityDID, *receivedCommunityDID) +} + +func TestCommentService_GetActorComments_CommunityDIDPassThrough(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + communityDID := "did:plc:community123" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + // Track what community filter was passed to repo + var receivedCommunityDID *string + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + receivedCommunityDID = req.CommunityDID + return []*Comment{}, nil, nil + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with community DID (not handle) - should pass through without resolution + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Community: communityDID, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.NotNil(t, receivedCommunityDID) + assert.Equal(t, communityDID, *receivedCommunityDID) +} + +func TestCommentService_GetActorComments_CommunityNotFound(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + nonexistentCommunity := "nonexistent.coves.social" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with nonexistent community handle + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Community: nonexistentCommunity, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify - should return empty results, not error + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, resp.Comments, 0) +} + +func TestCommentService_GetActorComments_RepositoryError(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + // Mock repository error + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + return nil, nil, errors.New("database connection failed") + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "failed to fetch comments") +} + +func TestCommentService_GetActorComments_LimitBoundsNormalization(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + + tests := []struct { + name string + inputLimit int + expectedLimit int + }{ + {"zero limit defaults to 50", 0, 50}, + {"negative limit defaults to 50", -10, 50}, + {"limit > 100 capped to 100", 200, 100}, + {"valid limit unchanged", 25, 25}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + var receivedLimit int + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + receivedLimit = req.Limit + return []*Comment{}, nil, nil + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Limit: tt.inputLimit, + } + + _, err := service.GetActorComments(context.Background(), req) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedLimit, receivedLimit) + }) + } +} + +func TestCommentService_GetActorComments_WithPagination(t *testing.T) { + // Setup + actorDID := "did:plc:actor123" + postURI := "at://did:plc:post123/app.bsky.feed.post/test" + + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + comment1 := createTestComment("at://did:plc:actor123/comment/1", actorDID, "actor.test", postURI, postURI, 0) + nextCursor := "cursor123" + + commentRepo.listByCommenterWithCursorFunc = func(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) { + return []*Comment{comment1}, &nextCursor, nil + } + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute + req := &GetActorCommentsRequest{ + ActorDID: actorDID, + Limit: 50, + } + + resp, err := service.GetActorComments(context.Background(), req) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, resp.Comments, 1) + assert.NotNil(t, resp.Cursor) + assert.Equal(t, nextCursor, *resp.Cursor) +} + +func TestCommentService_GetActorComments_NilRequest(t *testing.T) { + // Setup + commentRepo := newMockCommentRepo() + userRepo := newMockUserRepo() + postRepo := newMockPostRepo() + communityRepo := newMockCommunityRepo() + + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) + + // Execute with nil request + resp, err := service.GetActorComments(context.Background(), nil) + + // Verify + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "request cannot be nil") +} + +func TestValidateGetActorCommentsRequest_Defaults(t *testing.T) { + req := &GetActorCommentsRequest{ + ActorDID: "did:plc:actor123", + // Limit is 0 (zero value) + } + + err := validateGetActorCommentsRequest(req) + assert.NoError(t, err) + + // Check defaults applied + assert.Equal(t, 50, req.Limit) +} + +func TestValidateGetActorCommentsRequest_BoundsEnforcement(t *testing.T) { + tests := []struct { + name string + limit int + expectedLimit int + }{ + {"zero limit defaults to 50", 0, 50}, + {"negative limit defaults to 50", -10, 50}, + {"limit too high capped to 100", 200, 100}, + {"valid limit unchanged", 25, 25}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &GetActorCommentsRequest{ + ActorDID: "did:plc:actor123", + Limit: tt.limit, + } + + err := validateGetActorCommentsRequest(req) + assert.NoError(t, err) + assert.Equal(t, tt.expectedLimit, req.Limit) + }) + } +} diff --git a/internal/core/comments/interfaces.go b/internal/core/comments/interfaces.go index 4c5539b..90ea1ae 100644 --- a/internal/core/comments/interfaces.go +++ b/internal/core/comments/interfaces.go @@ -50,9 +50,14 @@ type Repository interface { CountByParent(ctx context.Context, parentURI string) (int, error) // ListByCommenter retrieves all comments by a specific user - // Future: Used for user comment history + // Deprecated: Use ListByCommenterWithCursor for cursor-based pagination ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination + // Used for user profile comment history (social.coves.actor.getComments) + // Supports optional community filtering and returns next page cursor + ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) + // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination // Supports hot, top, and new sorting with cursor-based pagination // Returns comments with author info hydrated and next page cursor diff --git a/internal/core/comments/view_models.go b/internal/core/comments/view_models.go index 4d214f5..5e91ef6 100644 --- a/internal/core/comments/view_models.go +++ b/internal/core/comments/view_models.go @@ -67,3 +67,20 @@ type GetCommentsResponse struct { Cursor *string `json:"cursor,omitempty"` Comments []*ThreadViewComment `json:"comments"` } + +// GetActorCommentsRequest defines the parameters for fetching a user's comments +// Used by social.coves.actor.getComments endpoint +type GetActorCommentsRequest struct { + ActorDID string // Required: DID of the commenter + Community string // Optional: filter to comments in a specific community (handle or DID) + Limit int // Max comments to return (1-100, default 50) + Cursor *string // Pagination cursor from previous response + ViewerDID *string // Optional: DID of the viewer for populating viewer state +} + +// GetActorCommentsResponse represents the response for fetching a user's comments +// Matches social.coves.actor.getComments lexicon output +type GetActorCommentsResponse struct { + Comments []*CommentView `json:"comments"` + Cursor *string `json:"cursor,omitempty"` +} diff --git a/internal/db/postgres/comment_repo.go b/internal/db/postgres/comment_repo.go index 1ebfbc4..0667884 100644 --- a/internal/db/postgres/comment_repo.go +++ b/internal/db/postgres/comment_repo.go @@ -410,6 +410,156 @@ func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID return result, nil } +// ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination +// Used for user profile comment history (social.coves.actor.getComments) +// Supports optional community filtering and returns next page cursor +// Uses chronological ordering (newest first) with composite key cursor for stable pagination +func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) { + // Parse cursor for pagination + cursorFilter, cursorValues, err := r.parseCommenterCursor(req.Cursor) + if err != nil { + return nil, nil, fmt.Errorf("invalid cursor: %w", err) + } + + // Build community filter if provided + // Parameter numbering: $1=commenterDID, $2=limit+1 (for pagination detection) + // Cursor values (if present) use $3 and $4, community DID comes after + var communityFilter string + var communityValue []interface{} + paramOffset := 2 + len(cursorValues) // Start after $1, $2, and any cursor params + if req.CommunityDID != nil && *req.CommunityDID != "" { + paramOffset++ + communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset) + communityValue = append(communityValue, *req.CommunityDID) + } + + // Build complete query with JOINs and filters + // LEFT JOIN prevents data loss when user record hasn't been indexed yet + query := fmt.Sprintf(` + SELECT + c.id, c.uri, c.cid, c.rkey, c.commenter_did, + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, + c.content, c.content_facets, c.embed, c.content_labels, c.langs, + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, + c.upvote_count, c.downvote_count, c.score, c.reply_count, + COALESCE(u.handle, c.commenter_did) as author_handle + FROM comments c + LEFT JOIN users u ON c.commenter_did = u.did + WHERE c.commenter_did = $1 + AND c.deleted_at IS NULL + %s + %s + ORDER BY c.created_at DESC, c.uri DESC + LIMIT $2 + `, communityFilter, cursorFilter) + + // Prepare query arguments + args := []interface{}{req.CommenterDID, req.Limit + 1} // +1 to detect next page + args = append(args, cursorValues...) + args = append(args, communityValue...) + + // Execute query + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, nil, fmt.Errorf("failed to query comments by commenter: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + log.Printf("Failed to close rows: %v", err) + } + }() + + // Scan results + var result []*comments.Comment + for rows.Next() { + var comment comments.Comment + var langs pq.StringArray + var authorHandle string + + err := rows.Scan( + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, + &authorHandle, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to scan comment: %w", err) + } + + comment.Langs = langs + comment.CommenterHandle = authorHandle + result = append(result, &comment) + } + + if err = rows.Err(); err != nil { + return nil, nil, fmt.Errorf("error iterating comments: %w", err) + } + + // Handle pagination cursor + var nextCursor *string + if len(result) > req.Limit && req.Limit > 0 { + result = result[:req.Limit] + lastComment := result[len(result)-1] + cursorStr := r.buildCommenterCursor(lastComment) + nextCursor = &cursorStr + } + + return result, nextCursor, nil +} + +// parseCommenterCursor decodes pagination cursor for commenter comments +// Cursor format: createdAt|uri (same as "new" sort for other comment queries) +// +// IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4). +// The caller (ListByCommenterWithCursor) must ensure parameters are ordered as: +// $1=commenterDID, $2=limit+1, $3=createdAt, $4=uri, then community DID if present. +// If you modify the parameter order in the caller, you must update the filter here. +func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) { + if cursor == nil || *cursor == "" { + return "", nil, nil + } + + // Validate cursor size to prevent DoS via massive base64 strings + const maxCursorSize = 1024 + if len(*cursor) > maxCursorSize { + return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize) + } + + // Decode base64 cursor + decoded, err := base64.URLEncoding.DecodeString(*cursor) + if err != nil { + return "", nil, fmt.Errorf("invalid cursor encoding") + } + + // Parse cursor: createdAt|uri + parts := strings.Split(string(decoded), "|") + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid cursor format") + } + + createdAt := parts[0] + uri := parts[1] + + // Validate AT-URI format + if !strings.HasPrefix(uri, "at://") { + return "", nil, fmt.Errorf("invalid cursor URI") + } + + filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))` + return filter, []interface{}{createdAt, uri}, nil +} + +// buildCommenterCursor creates pagination cursor from last comment +// Uses createdAt|uri format for stable pagination +func (r *postgresCommentRepo) buildCommenterCursor(comment *comments.Comment) string { + cursorStr := fmt.Sprintf("%s|%s", + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), + comment.URI) + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) +} + // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) // Uses cursor-based pagination with composite keys for consistent ordering @@ -964,6 +1114,7 @@ func (r *postgresCommentRepo) GetVoteStateForComments(ctx context.Context, viewe // If votes table doesn't exist yet, return empty map instead of error // This allows the API to work before votes indexing is fully implemented if strings.Contains(err.Error(), "does not exist") { + log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs)) return make(map[string]interface{}), nil } return nil, fmt.Errorf("failed to get vote state for comments: %w", err) diff --git a/tests/integration/author_posts_e2e_test.go b/tests/integration/author_posts_e2e_test.go index a16c081..7d00f5f 100644 --- a/tests/integration/author_posts_e2e_test.go +++ b/tests/integration/author_posts_e2e_test.go @@ -110,7 +110,7 @@ func TestGetAuthorPosts_E2E_Success(t *testing.T) { // Setup HTTP server with XRPC routes r := chi.NewRouter() - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) httpServer := httptest.NewServer(r) defer httpServer.Close() @@ -322,7 +322,7 @@ func TestGetAuthorPosts_FilterLogic(t *testing.T) { // Setup HTTP server e2eAuth := NewE2EOAuthMiddleware() r := chi.NewRouter() - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) httpServer := httptest.NewServer(r) defer httpServer.Close() @@ -443,7 +443,7 @@ func TestGetAuthorPosts_ServiceErrors(t *testing.T) { // Setup HTTP server e2eAuth := NewE2EOAuthMiddleware() r := chi.NewRouter() - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) httpServer := httptest.NewServer(r) defer httpServer.Close() @@ -606,7 +606,7 @@ func TestGetAuthorPosts_WithJetstreamIndexing(t *testing.T) { // Verify post is now queryable via GetAuthorPosts e2eAuth := NewE2EOAuthMiddleware() r := chi.NewRouter() - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) httpServer := httptest.NewServer(r) defer httpServer.Close() @@ -679,7 +679,7 @@ func TestGetAuthorPosts_CommunityFilter(t *testing.T) { // Setup HTTP server e2eAuth := NewE2EOAuthMiddleware() r := chi.NewRouter() - routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, nil, e2eAuth.OAuthAuthMiddleware) httpServer := httptest.NewServer(r) defer httpServer.Close() -- 2.51.2