diff --git a/cmd/server/main.go b/cmd/server/main.go --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -630,6 +630,10 @@ 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) + log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") + log.Println(" - GET /xrpc/social.coves.actor.getPosts") + 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/errors.go b/internal/api/handlers/actor/errors.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/actor/errors.go @@ -0,0 +1,94 @@ +package actor + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "Coves/internal/core/posts" +) + +// ErrorResponse represents an XRPC error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// writeError writes a JSON error response +func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(ErrorResponse{ + Error: errorType, + Message: message, + }); err != nil { + // Log encoding errors but can't send error response (headers already sent) + log.Printf("ERROR: Failed to encode error response: %v", err) + } +} + +// handleServiceError maps service errors to HTTP responses +func handleServiceError(w http.ResponseWriter, err error) { + // Check for handler-level errors first + var actorNotFound *actorNotFoundError + if errors.As(err, &actorNotFound) { + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") + return + } + + // Check for service-level errors + switch { + case errors.Is(err, posts.ErrNotFound): + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") + + case errors.Is(err, posts.ErrActorNotFound): + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") + + case errors.Is(err, posts.ErrCommunityNotFound): + writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") + + case errors.Is(err, posts.ErrInvalidCursor): + writeError(w, http.StatusBadRequest, "InvalidCursor", "Invalid pagination cursor") + + case posts.IsValidationError(err): + // Extract message from ValidationError for cleaner response + var valErr *posts.ValidationError + if errors.As(err, &valErr) { + writeError(w, http.StatusBadRequest, "InvalidRequest", valErr.Message) + } else { + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + } + + default: + // Internal server error - don't leak details + log.Printf("ERROR: Actor posts service error: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + } +} + +// actorNotFoundError represents an actor not found error +type actorNotFoundError struct { + actor string +} + +func (e *actorNotFoundError) Error() string { + return fmt.Sprintf("actor not found: %s", e.actor) +} + +// resolutionFailedError represents an infrastructure failure during resolution +// (database down, DNS failures, TLS errors, etc.) +// This is distinct from actorNotFoundError to avoid masking real problems as "not found" +type resolutionFailedError struct { + actor string + cause error +} + +func (e *resolutionFailedError) Error() string { + return fmt.Sprintf("failed to resolve actor %s: %v", e.actor, e.cause) +} + +func (e *resolutionFailedError) Unwrap() error { + return e.cause +} diff --git a/internal/api/handlers/actor/get_posts.go b/internal/api/handlers/actor/get_posts.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/actor/get_posts.go @@ -0,0 +1,185 @@ +package actor + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "strings" + + "Coves/internal/api/handlers/common" + "Coves/internal/api/middleware" + "Coves/internal/core/blueskypost" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" +) + +// GetPostsHandler handles actor post retrieval +type GetPostsHandler struct { + postService posts.Service + userService users.UserService + voteService votes.Service + blueskyService blueskypost.Service +} + +// NewGetPostsHandler creates a new actor posts handler +func NewGetPostsHandler( + postService posts.Service, + userService users.UserService, + voteService votes.Service, + blueskyService blueskypost.Service, +) *GetPostsHandler { + if blueskyService == nil { + log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") + } + return &GetPostsHandler{ + postService: postService, + userService: userService, + voteService: voteService, + blueskyService: blueskyService, + } +} + +// HandleGetPosts retrieves posts by an actor (user) +// GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=... +func (h *GetPostsHandler) HandleGetPosts(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 + } + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + return + } + + // Get viewer DID for populating viewer state (optional) + viewerDID := middleware.GetUserDID(r) + req.ViewerDID = viewerDID + + // Get actor posts from service + response, err := h.postService.GetAuthorPosts(r.Context(), req) + if err != nil { + handleServiceError(w, err) + return + } + + // Populate viewer vote state if authenticated + common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) + + // Transform blob refs to URLs and resolve post embeds for all posts + for _, feedPost := range response.Feed { + if feedPost.Post != nil { + posts.TransformBlobRefsToURLs(feedPost.Post) + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) + } + } + + // 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 posts response: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") + return + } + + // Return feed + 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 posts response: %v", err) + } +} + +// parseRequest parses query parameters into GetAuthorPostsRequest +func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) { + req := posts.GetAuthorPostsRequest{} + + // Required: actor (handle or DID) + actor := r.URL.Query().Get("actor") + if actor == "" { + return req, posts.NewValidationError("actor", "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 req, posts.NewValidationError("actor", "actor parameter exceeds maximum length") + } + + // Resolve actor to DID if it's a handle + actorDID, err := h.resolveActor(r, actor) + if err != nil { + return req, err + } + req.ActorDID = actorDID + + // Optional: filter (default: posts_with_replies) + req.Filter = r.URL.Query().Get("filter") + + // 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 req, posts.NewValidationError("limit", "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 *GetPostsHandler) 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 +} diff --git a/internal/api/handlers/actor/get_posts_test.go b/internal/api/handlers/actor/get_posts_test.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/actor/get_posts_test.go @@ -0,0 +1,331 @@ +package actor + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "Coves/internal/core/blueskypost" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" + + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" +) + +// mockPostService implements posts.Service for testing +type mockPostService struct { + getAuthorPostsFunc func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) +} + +func (m *mockPostService) GetAuthorPosts(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { + if m.getAuthorPostsFunc != nil { + return m.getAuthorPostsFunc(ctx, req) + } + return &posts.GetAuthorPostsResponse{ + Feed: []*posts.FeedViewPost{}, + Cursor: nil, + }, nil +} + +func (m *mockPostService) CreatePost(ctx context.Context, req posts.CreatePostRequest) (*posts.CreatePostResponse, error) { + return nil, nil +} + +// mockUserService implements users.UserService for testing +type mockUserService struct { + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) +} + +func (m *mockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { + return nil, nil +} + +func (m *mockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { + return nil, nil +} + +func (m *mockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { + if m.resolveHandleToDIDFunc != nil { + return m.resolveHandleToDIDFunc(ctx, handle) + } + return "did:plc:testuser", nil +} + +func (m *mockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { + return nil, nil +} + +func (m *mockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { + return nil +} + +// mockVoteService implements votes.Service for testing +type mockVoteService struct{} + +func (m *mockVoteService) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { + return nil, nil +} + +func (m *mockVoteService) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { + return nil +} + +func (m *mockVoteService) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { + return nil +} + +func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { + return nil +} + +func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { + return nil +} + +// mockBlueskyService implements blueskypost.Service for testing +type mockBlueskyService struct{} + +func (m *mockBlueskyService) ResolvePost(ctx context.Context, atURI string) (*blueskypost.BlueskyPostResult, error) { + return nil, nil +} + +func (m *mockBlueskyService) ParseBlueskyURL(ctx context.Context, url string) (string, error) { + return "", nil +} + +func (m *mockBlueskyService) IsBlueskyURL(url string) bool { + return false +} + +func TestGetPostsHandler_Success(t *testing.T) { + mockPosts := &mockPostService{ + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { + return &posts.GetAuthorPostsResponse{ + Feed: []*posts.FeedViewPost{ + { + Post: &posts.PostView{ + URI: "at://did:plc:testuser/social.coves.community.post/abc123", + CID: "bafytest123", + }, + }, + }, + }, nil + }, + } + mockUsers := &mockUserService{} + mockVotes := &mockVoteService{} + mockBluesky := &mockBlueskyService{} + + handler := NewGetPostsHandler(mockPosts, mockUsers, mockVotes, mockBluesky) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:testuser", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var response posts.GetAuthorPostsResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(response.Feed) != 1 { + t.Errorf("Expected 1 post in feed, got %d", len(response.Feed)) + } +} + +func TestGetPostsHandler_MissingActorParameter(t *testing.T) { + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(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 TestGetPostsHandler_InvalidLimitParameter(t *testing.T) { + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&limit=abc", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(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 TestGetPostsHandler_ActorNotFound(t *testing.T) { + mockUsers := &mockUserService{ + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { + return "", posts.ErrActorNotFound + }, + } + + handler := NewGetPostsHandler(&mockPostService{}, mockUsers, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=nonexistent.user", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", rec.Code) + } +} + +func TestGetPostsHandler_ActorLengthExceedsMax(t *testing.T) { + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + // 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.getPosts?actor="+longActor, nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } +} + +func TestGetPostsHandler_InvalidCursor(t *testing.T) { + mockPosts := &mockPostService{ + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { + return nil, posts.ErrInvalidCursor + }, + } + + handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:test&cursor=invalid", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(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 != "InvalidCursor" { + t.Errorf("Expected error 'InvalidCursor', got '%s'", response.Error) + } +} + +func TestGetPostsHandler_MethodNotAllowed(t *testing.T) { + handler := NewGetPostsHandler(&mockPostService{}, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getPosts", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected status 405, got %d", rec.Code) + } +} + +func TestGetPostsHandler_HandleResolution(t *testing.T) { + resolvedDID := "" + mockPosts := &mockPostService{ + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { + resolvedDID = req.ActorDID + return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil + }, + } + mockUsers := &mockUserService{ + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { + if handle == "test.user" { + return "did:plc:resolveduser123", nil + } + return "", posts.ErrActorNotFound + }, + } + + handler := NewGetPostsHandler(mockPosts, mockUsers, &mockVoteService{}, &mockBlueskyService{}) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=test.user", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(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 TestGetPostsHandler_DirectDIDPassthrough(t *testing.T) { + receivedDID := "" + mockPosts := &mockPostService{ + getAuthorPostsFunc: func(ctx context.Context, req posts.GetAuthorPostsRequest) (*posts.GetAuthorPostsResponse, error) { + receivedDID = req.ActorDID + return &posts.GetAuthorPostsResponse{Feed: []*posts.FeedViewPost{}}, nil + }, + } + + handler := NewGetPostsHandler(mockPosts, &mockUserService{}, &mockVoteService{}, &mockBlueskyService{}) + + // When actor is already a DID, it should pass through without resolution + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getPosts?actor=did:plc:directuser", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPosts(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) + } +} diff --git a/internal/api/routes/actor.go b/internal/api/routes/actor.go new file mode 100644 --- /dev/null +++ b/internal/api/routes/actor.go @@ -0,0 +1,29 @@ +package routes + +import ( + "Coves/internal/api/handlers/actor" + "Coves/internal/api/middleware" + "Coves/internal/core/blueskypost" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" + + "github.com/go-chi/chi/v5" +) + +// RegisterActorRoutes registers actor-related XRPC endpoints +func RegisterActorRoutes( + r chi.Router, + postService posts.Service, + userService users.UserService, + voteService votes.Service, + blueskyService blueskypost.Service, + authMiddleware *middleware.OAuthAuthMiddleware, +) { + // Create handlers + getPostsHandler := actor.NewGetPostsHandler(postService, userService, voteService, blueskyService) + + // 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) +} diff --git a/internal/atproto/lexicon/social/coves/actor/getPosts.json b/internal/atproto/lexicon/social/coves/actor/getPosts.json new file mode 100644 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/actor/getPosts.json @@ -0,0 +1,66 @@ +{ + "lexicon": 1, + "id": "social.coves.actor.getPosts", + "defs": { + "main": { + "type": "query", + "description": "Get a user's posts for their profile page.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the user" + }, + "filter": { + "type": "string", + "knownValues": ["posts_with_replies", "posts_no_replies", "posts_with_media"], + "default": "posts_with_replies", + "description": "Filter for post types" + }, + "community": { + "type": "string", + "format": "at-identifier", + "description": "Filter to posts in a specific community" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["feed"], + "properties": { + "feed": { + "type": "array", + "items": { + "type": "ref", + "ref": "social.coves.feed.defs#feedViewPost" + } + }, + "cursor": { + "type": "string" + } + } + } + }, + "errors": [ + { + "name": "NotFound", + "description": "Actor not found" + } + ] + } + } +} diff --git a/internal/core/comments/comment_service_test.go b/internal/core/comments/comment_service_test.go --- a/internal/core/comments/comment_service_test.go +++ b/internal/core/comments/comment_service_test.go @@ -212,6 +212,11 @@ } return nil, posts.NewNotFoundError("post", uri) } +func (m *mockPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { + // Mock implementation - returns empty for tests + return nil, nil, nil +} + // mockCommunityRepo is a mock implementation of the communities.Repository interface type mockCommunityRepo struct { communities map[string]*communities.Community diff --git a/internal/core/posts/errors.go b/internal/core/posts/errors.go --- a/internal/core/posts/errors.go +++ b/internal/core/posts/errors.go @@ -25,6 +25,12 @@ ErrNotFound = errors.New("post not found") // ErrRateLimitExceeded is returned when an aggregator exceeds rate limits ErrRateLimitExceeded = errors.New("rate limit exceeded") + + // ErrInvalidCursor is returned when a pagination cursor is malformed + ErrInvalidCursor = errors.New("invalid pagination cursor") + + // ErrActorNotFound is returned when the requested actor does not exist + ErrActorNotFound = errors.New("actor not found") ) // ValidationError represents a validation error with field context diff --git a/internal/core/posts/interfaces.go b/internal/core/posts/interfaces.go --- a/internal/core/posts/interfaces.go +++ b/internal/core/posts/interfaces.go @@ -16,6 +16,11 @@ // Flow: Validate -> Fetch community -> Ensure fresh token -> Write to PDS -> Return URI/CID // AppView indexing happens asynchronously via Jetstream consumer CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) + // GetAuthorPosts retrieves posts authored by a specific user for their profile page + // Supports filtering by post type (with/without replies, media only) and community + // Returns paginated feed with cursor + GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) + // Future methods (Beta): // GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error) // UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error) @@ -33,6 +38,11 @@ // GetByURI retrieves a post by its AT-URI // Used for E2E test verification and future GET endpoint GetByURI(ctx context.Context, uri string) (*Post, error) + + // GetByAuthor retrieves posts authored by a specific user + // Supports filtering by post type and community + // Returns posts, cursor for pagination, and error + GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) // Future methods (Beta): // Update(ctx context.Context, post *Post) error diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -143,3 +143,74 @@ SavedURI *string `json:"savedUri,omitempty"` Tags []string `json:"tags,omitempty"` Saved bool `json:"saved"` } + +// Filter constants for GetAuthorPosts +const ( + FilterPostsWithReplies = "posts_with_replies" + FilterPostsNoReplies = "posts_no_replies" + FilterPostsWithMedia = "posts_with_media" +) + +// GetAuthorPostsRequest represents input for fetching author's posts +// Matches social.coves.actor.getPosts lexicon input +type GetAuthorPostsRequest struct { + ActorDID string // Resolved DID from actor param (handle or DID) + Filter string // FilterPostsWithReplies, FilterPostsNoReplies, FilterPostsWithMedia + Community string // Optional community DID filter + Limit int // Number of posts to return (1-100, default 50) + Cursor *string // Pagination cursor + ViewerDID string // Viewer's DID for enriching viewer state +} + +// GetAuthorPostsResponse represents author posts response +// Matches social.coves.actor.getPosts lexicon output +type GetAuthorPostsResponse struct { + Feed []*FeedViewPost `json:"feed"` + Cursor *string `json:"cursor,omitempty"` +} + +// FeedViewPost matches social.coves.feed.defs#feedViewPost +// Wraps a post with optional context about why it appears in a feed +type FeedViewPost struct { + Post *PostView `json:"post"` + Reason *FeedReason `json:"reason,omitempty"` // Context for why post appears in feed + Reply *ReplyRef `json:"reply,omitempty"` // Reply context if post is a reply +} + +// GetPost returns the underlying PostView for viewer state enrichment +func (f *FeedViewPost) GetPost() *PostView { + return f.Post +} + +// FeedReason represents the reason a post appears in a feed +// Matches social.coves.feed.defs union type for feed context +type FeedReason struct { + Type string `json:"$type"` + Repost *ReasonRepost `json:"repost,omitempty"` + Pin *ReasonPin `json:"pin,omitempty"` +} + +// ReasonRepost indicates the post was reposted by another user +type ReasonRepost struct { + By *AuthorView `json:"by"` + IndexedAt string `json:"indexedAt"` +} + +// ReasonPin indicates the post is pinned by the community +type ReasonPin struct { + Community *CommunityRef `json:"community"` +} + +// ReplyRef contains context about post replies +// Matches social.coves.feed.defs#replyRef +type ReplyRef struct { + Root *PostRef `json:"root"` + Parent *PostRef `json:"parent"` +} + +// PostRef is a minimal reference to a post (URI + CID) +// Matches social.coves.feed.defs#postRef +type PostRef struct { + URI string `json:"uri"` + CID string `json:"cid"` +} diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -558,3 +558,125 @@ log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID) return true } + +// GetAuthorPosts retrieves posts by a specific author with optional filtering +// Supports filtering by: posts_with_replies, posts_no_replies, posts_with_media +// Optionally filter to a specific community +func (s *postService) GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) { + // 1. Validate request + if err := s.validateGetAuthorPostsRequest(&req); err != nil { + return nil, err + } + + // 2. If community is provided, resolve it to DID + if req.Community != "" { + communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) + if err != nil { + if communities.IsNotFound(err) { + return nil, ErrCommunityNotFound + } + if communities.IsValidationError(err) { + return nil, NewValidationError("community", err.Error()) + } + return nil, fmt.Errorf("failed to resolve community identifier: %w", err) + } + req.Community = communityDID + } + + // 3. Fetch posts from repository + postViews, cursor, err := s.repo.GetByAuthor(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get author posts: %w", err) + } + + // 4. Wrap PostViews in FeedViewPost + feed := make([]*FeedViewPost, len(postViews)) + for i, postView := range postViews { + feed[i] = &FeedViewPost{ + Post: postView, + } + } + + // 5. Return response + return &GetAuthorPostsResponse{ + Feed: feed, + Cursor: cursor, + }, nil +} + +// validateGetAuthorPostsRequest validates the GetAuthorPosts request +func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) error { + // Validate actor DID is set + if req.ActorDID == "" { + return NewValidationError("actor", "actor is required") + } + + // Validate DID format - AT Protocol supports did:plc and did:web + if err := validateDIDFormat(req.ActorDID); err != nil { + return NewValidationError("actor", err.Error()) + } + + // Validate and set defaults for filter + validFilters := map[string]bool{ + FilterPostsWithReplies: true, + FilterPostsNoReplies: true, + FilterPostsWithMedia: true, + } + if req.Filter == "" { + req.Filter = FilterPostsWithReplies // Default + } + if !validFilters[req.Filter] { + return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media") + } + + // Validate and set defaults for limit + if req.Limit <= 0 { + req.Limit = 50 // Default + } + if req.Limit > 100 { + req.Limit = 100 // Max + } + + return nil +} + +// validateDIDFormat validates that a string is a properly formatted DID +// Supports did:plc: (24 char base32 identifier) and did:web: (domain-based) +func validateDIDFormat(did string) error { + const maxDIDLength = 2048 + + if len(did) > maxDIDLength { + return fmt.Errorf("DID exceeds maximum length") + } + + switch { + case strings.HasPrefix(did, "did:plc:"): + // did:plc: format - identifier is 24 lowercase alphanumeric chars + identifier := strings.TrimPrefix(did, "did:plc:") + if len(identifier) == 0 { + return fmt.Errorf("invalid did:plc format: missing identifier") + } + // Base32 uses lowercase a-z and 2-7 + for _, c := range identifier { + if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) { + return fmt.Errorf("invalid did:plc format: identifier contains invalid characters") + } + } + return nil + + case strings.HasPrefix(did, "did:web:"): + // did:web: format - domain-based identifier + domain := strings.TrimPrefix(did, "did:web:") + if len(domain) == 0 { + return fmt.Errorf("invalid did:web format: missing domain") + } + // Basic domain validation - must contain at least one dot or be localhost + if !strings.Contains(domain, ".") && domain != "localhost" { + return fmt.Errorf("invalid did:web format: invalid domain") + } + return nil + + default: + return fmt.Errorf("unsupported DID method: must be did:plc or did:web") + } +} diff --git a/internal/core/posts/service_author_posts_test.go b/internal/core/posts/service_author_posts_test.go new file mode 100644 --- /dev/null +++ b/internal/core/posts/service_author_posts_test.go @@ -0,0 +1,277 @@ +package posts + +import ( + "context" + "testing" +) + +// mockRepository implements Repository for testing +type mockRepository struct { + getByAuthorFunc func(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) +} + +func (m *mockRepository) Create(ctx context.Context, post *Post) error { + return nil +} + +func (m *mockRepository) GetByURI(ctx context.Context, uri string) (*Post, error) { + return nil, nil +} + +func (m *mockRepository) GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) { + if m.getByAuthorFunc != nil { + return m.getByAuthorFunc(ctx, req) + } + return []*PostView{}, nil, nil +} + +func (m *mockRepository) SoftDelete(ctx context.Context, uri string) error { + return nil +} + +func (m *mockRepository) Update(ctx context.Context, post *Post) error { + return nil +} + +func (m *mockRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { + return nil +} + +func TestValidateDIDFormat(t *testing.T) { + tests := []struct { + name string + did string + wantErr bool + errMsg string + }{ + { + name: "valid did:plc", + did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + wantErr: false, + }, + { + name: "valid did:web", + did: "did:web:example.com", + wantErr: false, + }, + { + name: "valid did:web with subdomain", + did: "did:web:bsky.social", + wantErr: false, + }, + { + name: "valid did:web localhost", + did: "did:web:localhost", + wantErr: false, + }, + { + name: "invalid - missing method", + did: "did:", + wantErr: true, + errMsg: "unsupported DID method", + }, + { + name: "invalid - unsupported method", + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + wantErr: true, + errMsg: "unsupported DID method", + }, + { + name: "invalid did:plc - empty identifier", + did: "did:plc:", + wantErr: true, + errMsg: "missing identifier", + }, + { + name: "invalid did:plc - uppercase chars", + did: "did:plc:UPPERCASE", + wantErr: true, + errMsg: "invalid characters", + }, + { + name: "invalid did:plc - numbers outside base32", + did: "did:plc:abc0189", + wantErr: true, + errMsg: "invalid characters", + }, + { + name: "invalid did:web - empty domain", + did: "did:web:", + wantErr: true, + errMsg: "missing domain", + }, + { + name: "invalid did:web - no dot in domain", + did: "did:web:nodot", + wantErr: true, + errMsg: "invalid domain", + }, + { + name: "invalid - not a DID", + did: "notadid", + wantErr: true, + errMsg: "unsupported DID method", + }, + { + name: "invalid - too long", + did: "did:plc:" + string(make([]byte, 2100)), + wantErr: true, + errMsg: "exceeds maximum length", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDIDFormat(tt.did) + if tt.wantErr { + if err == nil { + t.Errorf("validateDIDFormat(%q) = nil, want error containing %q", tt.did, tt.errMsg) + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { + t.Errorf("validateDIDFormat(%q) = %v, want error containing %q", tt.did, err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validateDIDFormat(%q) = %v, want nil", tt.did, err) + } + } + }) + } +} + +// helper function for contains check (named testContains to avoid conflict with package function) +func testContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestValidateGetAuthorPostsRequest(t *testing.T) { + // Create a minimal service for testing validation + // We only need to test the validation logic, not the full service + + tests := []struct { + name string + req GetAuthorPostsRequest + wantErr bool + errMsg string + }{ + { + name: "valid request - minimal", + req: GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + }, + wantErr: false, + }, + { + name: "valid request - with filter", + req: GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Filter: FilterPostsWithMedia, + }, + wantErr: false, + }, + { + name: "valid request - with limit", + req: GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Limit: 25, + }, + wantErr: false, + }, + { + name: "invalid - empty actor", + req: GetAuthorPostsRequest{ + ActorDID: "", + }, + wantErr: true, + errMsg: "actor is required", + }, + { + name: "invalid - bad DID format", + req: GetAuthorPostsRequest{ + ActorDID: "notadid", + }, + wantErr: true, + errMsg: "unsupported DID method", + }, + { + name: "invalid - unknown filter", + req: GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Filter: "unknown_filter", + }, + wantErr: true, + errMsg: "filter must be one of", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create service with nil dependencies - we only test validation + s := &postService{} + err := s.validateGetAuthorPostsRequest(&tt.req) + + if tt.wantErr { + if err == nil { + t.Errorf("validateGetAuthorPostsRequest() = nil, want error containing %q", tt.errMsg) + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { + t.Errorf("validateGetAuthorPostsRequest() = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validateGetAuthorPostsRequest() = %v, want nil", err) + } + } + }) + } +} + +func TestValidateGetAuthorPostsRequest_DefaultsSet(t *testing.T) { + s := &postService{} + + // Test that defaults are set + t.Run("filter defaults to posts_with_replies", func(t *testing.T) { + req := GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Filter: "", // empty + } + err := s.validateGetAuthorPostsRequest(&req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Filter != FilterPostsWithReplies { + t.Errorf("Filter = %q, want %q", req.Filter, FilterPostsWithReplies) + } + }) + + t.Run("limit defaults to 50 when 0", func(t *testing.T) { + req := GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Limit: 0, + } + err := s.validateGetAuthorPostsRequest(&req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Limit != 50 { + t.Errorf("Limit = %d, want 50", req.Limit) + } + }) + + t.Run("limit capped at 100", func(t *testing.T) { + req := GetAuthorPostsRequest{ + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + Limit: 200, + } + err := s.validateGetAuthorPostsRequest(&req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Limit != 100 { + t.Errorf("Limit = %d, want 100", req.Limit) + } + }) +} diff --git a/internal/core/users/service.go b/internal/core/users/service.go --- a/internal/core/users/service.go +++ b/internal/core/users/service.go @@ -133,14 +133,23 @@ } // ResolveHandleToDID resolves a handle to a DID // This is critical for login: users enter their handle, we resolve to DID -// Uses DNS TXT record lookup and HTTPS .well-known/atproto-did resolution +// First checks local database for indexed users (fast path), then falls back +// to external DNS TXT record lookup and HTTPS .well-known/atproto-did resolution func (s *userService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { handle = strings.TrimSpace(strings.ToLower(handle)) if handle == "" { return "", fmt.Errorf("handle is required") } - // Use identity resolver to resolve handle to DID + // Fast path: check local database first for users we've already indexed + // This avoids external network calls for known users + user, err := s.userRepo.GetByHandle(ctx, handle) + if err == nil && user != nil { + return user.DID, nil + } + // If not found locally, fall through to external resolution + + // Slow path: use identity resolver for external DNS/HTTPS resolution did, _, err := s.identityResolver.ResolveHandle(ctx, handle) if err != nil { return "", fmt.Errorf("failed to resolve handle %s: %w", handle, err) diff --git a/internal/db/migrations/026_add_author_posts_index.sql b/internal/db/migrations/026_add_author_posts_index.sql new file mode 100644 --- /dev/null +++ b/internal/db/migrations/026_add_author_posts_index.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- Add optimized index for author posts queries with soft delete filter +-- This supports the social.coves.actor.getPosts endpoint which retrieves posts by author +-- The existing idx_posts_author doesn't filter deleted posts, causing full index scans +CREATE INDEX CONCURRENTLY idx_posts_author_created +ON posts(author_did, created_at DESC) +WHERE deleted_at IS NULL; + +-- +goose Down +-- +goose NO TRANSACTION +DROP INDEX CONCURRENTLY IF EXISTS idx_posts_author_created; diff --git a/internal/db/postgres/post_repo.go b/internal/db/postgres/post_repo.go --- a/internal/db/postgres/post_repo.go +++ b/internal/db/postgres/post_repo.go @@ -4,8 +4,12 @@ import ( "Coves/internal/core/posts" "context" "database/sql" + "encoding/base64" + "encoding/json" "fmt" + "log" "strings" + "time" ) type postgresPostRepo struct { @@ -128,3 +132,284 @@ } return &post, nil } + +// GetByAuthor retrieves posts by author with filtering and pagination +// Supports filter options: posts_with_replies (default), posts_no_replies, posts_with_media +// Uses cursor-based pagination with created_at + uri for stable ordering +// Returns []*PostView, next cursor, and error +func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { + // Build WHERE clauses based on filters + whereConditions := []string{ + "p.author_did = $1", + "p.deleted_at IS NULL", + } + args := []interface{}{req.ActorDID} + paramIndex := 2 + + // Optional community filter + if req.Community != "" { + whereConditions = append(whereConditions, fmt.Sprintf("p.community_did = $%d", paramIndex)) + args = append(args, req.Community) + paramIndex++ + } + + // Filter by post type + // Design note: Coves architecture separates posts from comments (unlike Bluesky where + // posts can be replies to other posts). The posts_no_replies filter exists for API + // compatibility with Bluesky's getAuthorFeed, but is intentionally a no-op in Coves + // since all Coves posts are top-level (comments are stored in a separate table). + switch req.Filter { + case posts.FilterPostsWithMedia: + whereConditions = append(whereConditions, "p.embed IS NOT NULL") + case posts.FilterPostsNoReplies: + // No-op: All Coves posts are top-level; comments are in the comments table. + // This filter exists for Bluesky API compatibility. + case posts.FilterPostsWithReplies, "": + // Default: return all posts (no additional filter needed) + } + + // Build cursor filter for pagination + cursorFilter, cursorArgs, cursorErr := r.parseAuthorPostsCursor(req.Cursor, paramIndex) + if cursorErr != nil { + return nil, nil, cursorErr + } + if cursorFilter != "" { + whereConditions = append(whereConditions, cursorFilter) + args = append(args, cursorArgs...) + paramIndex += len(cursorArgs) + } + + // Add limit to args + limit := req.Limit + if limit <= 0 { + limit = 50 // default + } + if limit > 100 { + limit = 100 // max + } + args = append(args, limit+1) // +1 to check for next page + + whereClause := strings.Join(whereConditions, " AND ") + + query := fmt.Sprintf(` + SELECT + p.uri, p.cid, p.rkey, + p.author_did, u.handle as author_handle, + p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url, + p.title, p.content, p.content_facets, p.embed, p.content_labels, + p.created_at, p.edited_at, p.indexed_at, + p.upvote_count, p.downvote_count, p.score, p.comment_count + FROM posts p + INNER JOIN users u ON p.author_did = u.did + INNER JOIN communities c ON p.community_did = c.did + WHERE %s + ORDER BY p.created_at DESC, p.uri DESC + LIMIT $%d + `, whereClause, paramIndex) + + // Execute query + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, nil, fmt.Errorf("failed to query author posts: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + log.Printf("WARN: failed to close rows: %v", err) + } + }() + + // Scan results + var postViews []*posts.PostView + for rows.Next() { + postView, err := r.scanAuthorPost(rows) + if err != nil { + return nil, nil, fmt.Errorf("failed to scan author post: %w", err) + } + postViews = append(postViews, postView) + } + + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("error iterating author posts results: %w", err) + } + + // Handle pagination cursor + var cursor *string + if len(postViews) > limit && limit > 0 { + postViews = postViews[:limit] + lastPost := postViews[len(postViews)-1] + cursorStr := r.buildAuthorPostsCursor(lastPost) + cursor = &cursorStr + } + + return postViews, cursor, nil +} + +// parseAuthorPostsCursor decodes pagination cursor for author posts +// Cursor format: base64(created_at|uri) +// Uses simple | delimiter since this is an internal cursor (not signed like feed cursors) +// Returns filter clause, arguments, and error. Error is returned for malformed cursors +// to provide clear feedback rather than silently returning the first page. +func (r *postgresPostRepo) parseAuthorPostsCursor(cursor *string, paramOffset int) (string, []interface{}, error) { + if cursor == nil || *cursor == "" { + return "", nil, nil + } + + // Validate cursor size to prevent DoS via massive base64 strings + const maxCursorSize = 512 + if len(*cursor) > maxCursorSize { + return "", nil, fmt.Errorf("%w: cursor exceeds maximum length", posts.ErrInvalidCursor) + } + + // Decode base64 cursor + decoded, err := base64.URLEncoding.DecodeString(*cursor) + if err != nil { + return "", nil, fmt.Errorf("%w: invalid base64 encoding", posts.ErrInvalidCursor) + } + + // Parse cursor: created_at|uri + parts := strings.Split(string(decoded), "|") + if len(parts) != 2 { + return "", nil, fmt.Errorf("%w: malformed cursor format", posts.ErrInvalidCursor) + } + + createdAt := parts[0] + uri := parts[1] + + // Validate timestamp format + if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { + return "", nil, fmt.Errorf("%w: invalid timestamp in cursor", posts.ErrInvalidCursor) + } + + // Validate URI format (must be AT-URI) + if !strings.HasPrefix(uri, "at://") { + return "", nil, fmt.Errorf("%w: invalid URI format in cursor", posts.ErrInvalidCursor) + } + + // Use composite key comparison for stable cursor pagination + // (created_at, uri) < (cursor_created_at, cursor_uri) + filter := fmt.Sprintf("(p.created_at < $%d OR (p.created_at = $%d AND p.uri < $%d))", + paramOffset, paramOffset, paramOffset+1) + return filter, []interface{}{createdAt, uri}, nil +} + +// buildAuthorPostsCursor creates pagination cursor from last post +// Cursor format: base64(created_at|uri) +func (r *postgresPostRepo) buildAuthorPostsCursor(post *posts.PostView) string { + cursorStr := fmt.Sprintf("%s|%s", post.CreatedAt.Format(time.RFC3339Nano), post.URI) + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) +} + +// scanAuthorPost scans a database row into a PostView for author posts query +func (r *postgresPostRepo) scanAuthorPost(rows *sql.Rows) (*posts.PostView, error) { + var ( + postView posts.PostView + authorView posts.AuthorView + communityRef posts.CommunityRef + title, content sql.NullString + facets, embed sql.NullString + labelsJSON sql.NullString + editedAt sql.NullTime + communityHandle sql.NullString + communityAvatar sql.NullString + communityPDSURL sql.NullString + ) + + err := rows.Scan( + &postView.URI, &postView.CID, &postView.RKey, + &authorView.DID, &authorView.Handle, + &communityRef.DID, &communityHandle, &communityRef.Name, &communityAvatar, &communityPDSURL, + &title, &content, &facets, &embed, &labelsJSON, + &postView.CreatedAt, &editedAt, &postView.IndexedAt, + &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, + ) + if err != nil { + return nil, err + } + + // Build author view + postView.Author = &authorView + + // Build community ref + if communityHandle.Valid { + communityRef.Handle = communityHandle.String + } + if communityAvatar.Valid { + communityRef.Avatar = &communityAvatar.String + } + if communityPDSURL.Valid { + communityRef.PDSURL = communityPDSURL.String + } + postView.Community = &communityRef + + // Set optional fields + if title.Valid { + postView.Title = &title.String + } + if content.Valid { + postView.Text = &content.String + } + if editedAt.Valid { + postView.EditedAt = &editedAt.Time + } + + // Parse facets JSON + if facets.Valid { + var facetArray []interface{} + if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { + return nil, fmt.Errorf("failed to parse facets JSON for post %s: %w", postView.URI, err) + } + postView.TextFacets = facetArray + } + + // Parse embed JSON + if embed.Valid { + var embedData interface{} + if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { + return nil, fmt.Errorf("failed to parse embed JSON for post %s: %w", postView.URI, err) + } + postView.Embed = embedData + } + + // Build stats + postView.Stats = &posts.PostStats{ + Upvotes: postView.UpvoteCount, + Downvotes: postView.DownvoteCount, + Score: postView.Score, + CommentCount: postView.CommentCount, + } + + // Build the record (required by lexicon) + record := map[string]interface{}{ + "$type": "social.coves.community.post", + "community": communityRef.DID, + "author": authorView.DID, + "createdAt": postView.CreatedAt.Format(time.RFC3339), + } + + // Add optional fields to record if present + if title.Valid { + record["title"] = title.String + } + if content.Valid { + record["content"] = content.String + } + // Reuse already-parsed facets and embed from PostView to avoid double parsing + if facets.Valid { + record["facets"] = postView.TextFacets + } + if embed.Valid { + record["embed"] = postView.Embed + } + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + var selfLabels posts.SelfLabels + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { + return nil, fmt.Errorf("failed to parse labels JSON for post %s: %w", postView.URI, err) + } + record["labels"] = selfLabels + } + + postView.Record = record + + return &postView, nil +} diff --git a/internal/db/postgres/post_repo_cursor_test.go b/internal/db/postgres/post_repo_cursor_test.go new file mode 100644 --- /dev/null +++ b/internal/db/postgres/post_repo_cursor_test.go @@ -0,0 +1,244 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/base64" + "testing" + "time" + + "Coves/internal/core/posts" +) + +func TestParseAuthorPostsCursor(t *testing.T) { + repo := &postgresPostRepo{db: nil} // db not needed for cursor parsing + + // Helper to create a valid cursor + makeCursor := func(timestamp, uri string) string { + return base64.URLEncoding.EncodeToString([]byte(timestamp + "|" + uri)) + } + + validTimestamp := time.Now().Format(time.RFC3339Nano) + validURI := "at://did:plc:test123/social.coves.community.post/abc123" + + tests := []struct { + name string + cursor *string + wantFilter bool + wantErr bool + errMsg string + }{ + { + name: "nil cursor returns empty filter", + cursor: nil, + wantFilter: false, + wantErr: false, + }, + { + name: "empty cursor returns empty filter", + cursor: strPtr(""), + wantFilter: false, + wantErr: false, + }, + { + name: "valid cursor", + cursor: strPtr(makeCursor(validTimestamp, validURI)), + wantFilter: true, + wantErr: false, + }, + { + name: "cursor too long", + cursor: strPtr(makeCursor(validTimestamp, string(make([]byte, 600)))), + wantFilter: false, + wantErr: true, + errMsg: "exceeds maximum length", + }, + { + name: "invalid base64", + cursor: strPtr("not-valid-base64!!!"), + wantFilter: false, + wantErr: true, + errMsg: "invalid base64", + }, + { + name: "missing pipe delimiter", + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("no-pipe-here"))), + wantFilter: false, + wantErr: true, + errMsg: "malformed cursor format", + }, + { + name: "invalid timestamp", + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("not-a-timestamp|" + validURI))), + wantFilter: false, + wantErr: true, + errMsg: "invalid timestamp", + }, + { + name: "invalid URI format", + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte(validTimestamp + "|not-an-at-uri"))), + wantFilter: false, + wantErr: true, + errMsg: "invalid URI format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter, args, err := repo.parseAuthorPostsCursor(tt.cursor, 1) + + if tt.wantErr { + if err == nil { + t.Errorf("parseAuthorPostsCursor() = nil error, want error containing %q", tt.errMsg) + } else if !posts.IsValidationError(err) && err != posts.ErrInvalidCursor { + // Check if error wraps ErrInvalidCursor + if tt.errMsg != "" && !containsStr(err.Error(), tt.errMsg) { + t.Errorf("parseAuthorPostsCursor() error = %v, want error containing %q", err, tt.errMsg) + } + } + } else { + if err != nil { + t.Errorf("parseAuthorPostsCursor() = %v, want nil error", err) + } + } + + if tt.wantFilter { + if filter == "" { + t.Error("parseAuthorPostsCursor() filter = empty, want non-empty filter") + } + if len(args) == 0 { + t.Error("parseAuthorPostsCursor() args = empty, want non-empty args") + } + } else if !tt.wantErr { + if filter != "" { + t.Errorf("parseAuthorPostsCursor() filter = %q, want empty", filter) + } + } + }) + } +} + +func TestBuildAuthorPostsCursor(t *testing.T) { + repo := &postgresPostRepo{db: nil} + + now := time.Now() + post := &posts.PostView{ + URI: "at://did:plc:test123/social.coves.community.post/abc123", + CreatedAt: now, + } + + cursor := repo.buildAuthorPostsCursor(post) + + // Decode and verify cursor + decoded, err := base64.URLEncoding.DecodeString(cursor) + if err != nil { + t.Fatalf("Failed to decode cursor: %v", err) + } + + // Should contain timestamp|uri + decodedStr := string(decoded) + if !containsStr(decodedStr, "|") { + t.Errorf("Cursor should contain '|' delimiter, got %q", decodedStr) + } + if !containsStr(decodedStr, post.URI) { + t.Errorf("Cursor should contain URI, got %q", decodedStr) + } + if !containsStr(decodedStr, now.Format(time.RFC3339Nano)) { + t.Errorf("Cursor should contain timestamp, got %q", decodedStr) + } +} + +func TestBuildAndParseCursorRoundTrip(t *testing.T) { + repo := &postgresPostRepo{db: nil} + + now := time.Now() + post := &posts.PostView{ + URI: "at://did:plc:test123/social.coves.community.post/abc123", + CreatedAt: now, + } + + // Build cursor + cursor := repo.buildAuthorPostsCursor(post) + + // Parse it back + filter, args, err := repo.parseAuthorPostsCursor(&cursor, 1) + + if err != nil { + t.Fatalf("Failed to parse cursor: %v", err) + } + + if filter == "" { + t.Error("Expected non-empty filter") + } + + if len(args) != 2 { + t.Errorf("Expected 2 args, got %d", len(args)) + } + + // First arg should be timestamp string + if ts, ok := args[0].(string); ok { + parsedTime, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + t.Errorf("First arg is not a valid timestamp: %v", err) + } + if !parsedTime.Equal(now) { + t.Errorf("Timestamp mismatch: got %v, want %v", parsedTime, now) + } + } else { + t.Errorf("First arg should be string, got %T", args[0]) + } + + // Second arg should be URI + if uri, ok := args[1].(string); ok { + if uri != post.URI { + t.Errorf("URI mismatch: got %q, want %q", uri, post.URI) + } + } else { + t.Errorf("Second arg should be string, got %T", args[1]) + } +} + +// Helper functions +func strPtr(s string) *string { + return &s +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// Ensure the mock repository satisfies the interface +var _ posts.Repository = (*mockPostRepository)(nil) + +type mockPostRepository struct { + db *sql.DB +} + +func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error { + return nil +} + +func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) { + return nil, nil +} + +func (m *mockPostRepository) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { + return nil, nil, nil +} + +func (m *mockPostRepository) SoftDelete(ctx context.Context, uri string) error { + return nil +} + +func (m *mockPostRepository) Update(ctx context.Context, post *posts.Post) error { + return nil +} + +func (m *mockPostRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { + return nil +} diff --git a/tests/integration/author_posts_e2e_test.go b/tests/integration/author_posts_e2e_test.go new file mode 100644 --- /dev/null +++ b/tests/integration/author_posts_e2e_test.go @@ -0,0 +1,739 @@ +package integration + +import ( + "Coves/internal/api/routes" + "Coves/internal/atproto/identity" + "Coves/internal/atproto/jetstream" + "Coves/internal/core/communities" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" + "Coves/internal/db/postgres" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/go-chi/chi/v5" + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" +) + +// TestGetAuthorPosts_E2E_Success tests the full author posts flow with real PDS +// Flow: Create user on PDS → Create posts → Query via XRPC → Verify response +func TestGetAuthorPosts_E2E_Success(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Setup test database + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" + } + + db, err := sql.Open("postgres", dbURL) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + defer func() { _ = db.Close() }() + + // Run migrations + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { + t.Fatalf("Failed to set goose dialect: %v", dialectErr) + } + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { + t.Fatalf("Failed to run migrations: %v", migrateErr) + } + + // Check if PDS is running + pdsURL := getTestPDSURL() + healthResp, err := http.Get(pdsURL + "/xrpc/_health") + if err != nil { + t.Skipf("PDS not running at %s: %v", pdsURL, err) + } + _ = healthResp.Body.Close() + + ctx := context.Background() + + // Setup repositories + postRepo := postgres.NewPostRepository(db) + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + // Setup services + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, pdsURL) + communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil) + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) + + // Create test user on PDS + testUserHandle := fmt.Sprintf("apt%d.local.coves.dev", time.Now().UnixNano()%1000000) + testUserEmail := fmt.Sprintf("author-posts-%d@test.local", time.Now().Unix()) + testUserPassword := "test-password-123" + + t.Logf("Creating test user on PDS: %s", testUserHandle) + _, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) + if err != nil { + t.Fatalf("Failed to create test user on PDS: %v", err) + } + t.Logf("Test user created: DID=%s", userDID) + + // Index user in AppView + _ = createTestUser(t, db, testUserHandle, userDID) + + // Create test community + testCommunityDID, err := createFeedTestCommunity(db, ctx, "author-posts-test", "owner.test") + if err != nil { + t.Fatalf("Failed to create test community: %v", err) + } + + // Create multiple test posts for the user + now := time.Now() + postURIs := make([]string, 5) + for i := 0; i < 5; i++ { + postURIs[i] = createTestPost(t, db, testCommunityDID, userDID, fmt.Sprintf("Test Post %d", i+1), i*10, now.Add(-time.Duration(i)*time.Hour)) + } + t.Logf("Created %d test posts", len(postURIs)) + + // Setup OAuth middleware + e2eAuth := NewE2EOAuthMiddleware() + token := e2eAuth.AddUser(userDID) + + // Setup HTTP server with XRPC routes + r := chi.NewRouter() + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + httpServer := httptest.NewServer(r) + defer httpServer.Close() + + // Test 1: Get posts by DID + t.Run("Get posts by DID", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=10", httpServer.URL, userDID), nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET author posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) + } + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + if len(response.Feed) != 5 { + t.Errorf("Expected 5 posts, got %d", len(response.Feed)) + } + + // Verify posts are returned in correct order (newest first) + for i, feedPost := range response.Feed { + if feedPost.Post == nil { + t.Errorf("Post %d is nil", i) + continue + } + t.Logf("Post %d: %s", i, feedPost.Post.URI) + } + + t.Logf("SUCCESS: Retrieved %d posts for author %s", len(response.Feed), userDID) + }) + + // Test 2: Get posts by handle + t.Run("Get posts by handle", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=5", httpServer.URL, testUserHandle), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET author posts by handle: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) + } + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + if len(response.Feed) != 5 { + t.Errorf("Expected 5 posts, got %d", len(response.Feed)) + } + + t.Logf("SUCCESS: Handle resolution worked - %s → %s", testUserHandle, userDID) + }) + + // Test 3: Pagination with cursor + t.Run("Pagination with cursor", func(t *testing.T) { + // First page + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=3", httpServer.URL, userDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET first page: %v", err) + } + + var firstPage posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&firstPage); decodeErr != nil { + t.Fatalf("Failed to decode first page: %v", decodeErr) + } + _ = resp.Body.Close() + + if len(firstPage.Feed) != 3 { + t.Errorf("Expected 3 posts on first page, got %d", len(firstPage.Feed)) + } + if firstPage.Cursor == nil { + t.Fatal("Expected cursor for pagination") + } + + // Second page using cursor + req2, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&limit=3&cursor=%s", + httpServer.URL, userDID, *firstPage.Cursor), nil) + + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("Failed to GET second page: %v", err) + } + defer func() { _ = resp2.Body.Close() }() + + var secondPage posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp2.Body).Decode(&secondPage); decodeErr != nil { + t.Fatalf("Failed to decode second page: %v", decodeErr) + } + + if len(secondPage.Feed) != 2 { + t.Errorf("Expected 2 posts on second page, got %d", len(secondPage.Feed)) + } + + // Verify no overlap between pages + firstPageURIs := make(map[string]bool) + for _, fp := range firstPage.Feed { + firstPageURIs[fp.Post.URI] = true + } + for _, fp := range secondPage.Feed { + if firstPageURIs[fp.Post.URI] { + t.Errorf("Duplicate post in second page: %s", fp.Post.URI) + } + } + + t.Logf("SUCCESS: Pagination working - page 1: %d posts, page 2: %d posts", + len(firstPage.Feed), len(secondPage.Feed)) + }) + + // Test 4: Actor not found + t.Run("Actor not found", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, "did:plc:nonexistent123"), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // The actor exists as a valid DID format but has no posts - should return empty feed + // If you want 404, you'd need a user existence check in the service + // For now, we expect 200 with empty feed (Bluesky-compatible behavior) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Logf("Response: %s", string(body)) + } + + t.Logf("SUCCESS: Non-existent actor handled correctly") + }) + + t.Logf("\nE2E AUTHOR POSTS FLOW COMPLETE:") + t.Logf(" Created user on PDS") + t.Logf(" Indexed 5 posts in AppView") + t.Logf(" Queried by DID") + t.Logf(" Queried by handle (with resolution)") + t.Logf(" Tested pagination") + t.Logf(" Tested error handling") +} + +// TestGetAuthorPosts_FilterLogic tests the different filter options +func TestGetAuthorPosts_FilterLogic(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + db := setupTestDB(t) + defer func() { _ = db.Close() }() + + ctx := context.Background() + + // Setup repositories and services + postRepo := postgres.NewPostRepository(db) + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) + communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) + + // Create test user (did:plc uses base32: a-z, 2-7) + testUserDID := "did:plc:filtertestabcd" + _ = createTestUser(t, db, "filtertest.test", testUserDID) + + // Create test community + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "filter-test", "owner.test") + + // Create posts with and without embeds + now := time.Now() + + // Create post without embed + createTestPost(t, db, testCommunityDID, testUserDID, "Post without embed", 10, now) + + // Create post with embed (need to insert directly with embed field) + embedJSON := `{"$type":"social.coves.embed.external","external":{"uri":"https://example.com"}}` + _, err := db.ExecContext(ctx, ` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, embed, created_at, score) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 20) + `, + fmt.Sprintf("at://%s/social.coves.community.post/embed-post", testCommunityDID), + "bafyembed", "embed-post", testUserDID, testCommunityDID, + "Post with embed", embedJSON, now.Add(-1*time.Hour)) + if err != nil { + t.Fatalf("Failed to create post with embed: %v", err) + } + + // Setup HTTP server + e2eAuth := NewE2EOAuthMiddleware() + r := chi.NewRouter() + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + httpServer := httptest.NewServer(r) + defer httpServer.Close() + + // Test: posts_with_media filter + t.Run("Filter posts_with_media", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=posts_with_media", + httpServer.URL, testUserDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET filtered posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) + } + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + // Should only return the post with embed + if len(response.Feed) != 1 { + t.Errorf("Expected 1 post with media, got %d", len(response.Feed)) + } + + // Verify it's the post with embed + if len(response.Feed) > 0 && response.Feed[0].Post != nil { + if response.Feed[0].Post.Embed == nil { + t.Error("Expected post with embed, but embed is nil") + } + } + + t.Logf("SUCCESS: posts_with_media filter returned %d posts", len(response.Feed)) + }) + + // Test: posts_with_replies (default - returns all) + t.Run("Filter posts_with_replies (default)", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=posts_with_replies", + httpServer.URL, testUserDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET filtered posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + // Should return all posts + if len(response.Feed) != 2 { + t.Errorf("Expected 2 posts, got %d", len(response.Feed)) + } + + t.Logf("SUCCESS: posts_with_replies filter returned %d posts", len(response.Feed)) + }) + + // Test: Invalid filter returns error + t.Run("Invalid filter returns error", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&filter=invalid_filter", + httpServer.URL, testUserDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected 400 for invalid filter, got %d: %s", resp.StatusCode, string(body)) + } + + t.Logf("SUCCESS: Invalid filter correctly rejected") + }) +} + +// TestGetAuthorPosts_ServiceErrors tests error handling in the service layer +func TestGetAuthorPosts_ServiceErrors(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + db := setupTestDB(t) + defer func() { _ = db.Close() }() + + ctx := context.Background() + + // Setup services + postRepo := postgres.NewPostRepository(db) + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) + communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) + + // Create test user and community + testUserDID := "did:plc:serviceerrorabc" + _ = createTestUser(t, db, "serviceerror.test", testUserDID) + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "serviceerror-test", "owner.test") + + // Create a test post + createTestPost(t, db, testCommunityDID, testUserDID, "Test Post", 10, time.Now()) + + // Setup HTTP server + e2eAuth := NewE2EOAuthMiddleware() + r := chi.NewRouter() + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + httpServer := httptest.NewServer(r) + defer httpServer.Close() + + // Test: Missing actor parameter + t.Run("Missing actor parameter", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts", httpServer.URL), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected 400 for missing actor, got %d: %s", resp.StatusCode, string(body)) + } + + t.Logf("SUCCESS: Missing actor parameter correctly rejected") + }) + + // Test: Invalid DID format + t.Run("Invalid DID format", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, "not-a-did"), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // Invalid DIDs that don't resolve should return 404 (actor not found) + if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected 404 or 400 for invalid DID, got %d: %s", resp.StatusCode, string(body)) + } + + t.Logf("SUCCESS: Invalid DID format handled") + }) + + // Test: Invalid cursor + t.Run("Invalid cursor", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&cursor=%s", + httpServer.URL, testUserDID, "invalid-cursor-format"), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected 400 for invalid cursor, got %d: %s", resp.StatusCode, string(body)) + } + + t.Logf("SUCCESS: Invalid cursor correctly rejected") + }) + + // Test: Community filter with non-existent community + t.Run("Non-existent community filter", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&community=%s", + httpServer.URL, testUserDID, "did:plc:nonexistentcommunity"), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Expected 404 for non-existent community, got %d: %s", resp.StatusCode, string(body)) + } + + t.Logf("SUCCESS: Non-existent community correctly rejected") + }) +} + +// TestGetAuthorPosts_WithJetstreamIndexing tests the full flow including Jetstream indexing +func TestGetAuthorPosts_WithJetstreamIndexing(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + db := setupTestDB(t) + defer func() { _ = db.Close() }() + + ctx := context.Background() + pdsURL := getTestPDSURL() + + // Setup repositories + postRepo := postgres.NewPostRepository(db) + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + // Setup services + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, pdsURL) + communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil) + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) + + // Create test user on PDS + testUserHandle := fmt.Sprintf("jet%d.local.coves.dev", time.Now().UnixNano()%1000000) + testUserEmail := fmt.Sprintf("jetstream-author-%d@test.local", time.Now().Unix()) + testUserPassword := "test-password-123" + + _, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) + if err != nil { + t.Skipf("PDS not available: %v", err) + } + + // Index user in AppView + _ = createTestUser(t, db, testUserHandle, userDID) + + // Create test community + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "jetstream-author-test", "owner.test") + + // Setup Jetstream consumer + postConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db) + + // Simulate a post being indexed via Jetstream + t.Run("Index post via Jetstream consumer", func(t *testing.T) { + rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) + postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunityDID, rkey) + + postEvent := jetstream.JetstreamEvent{ + Did: testCommunityDID, + TimeUS: time.Now().UnixMicro(), + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Rev: "test-post-rev", + Operation: "create", + Collection: "social.coves.community.post", + RKey: rkey, + CID: "bafyjetstream", + Record: map[string]interface{}{ + "$type": "social.coves.community.post", + "community": testCommunityDID, + "author": userDID, + "title": "Jetstream Indexed Post", + "content": "This post was indexed via Jetstream", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + if handleErr := postConsumer.HandleEvent(ctx, &postEvent); handleErr != nil { + t.Fatalf("Failed to handle post event: %v", handleErr) + } + + t.Logf("Post indexed via Jetstream: %s", postURI) + + // Verify post is now queryable via GetAuthorPosts + e2eAuth := NewE2EOAuthMiddleware() + r := chi.NewRouter() + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + httpServer := httptest.NewServer(r) + defer httpServer.Close() + + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, userDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET author posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + if len(response.Feed) != 1 { + t.Errorf("Expected 1 post, got %d", len(response.Feed)) + } + + if len(response.Feed) > 0 && response.Feed[0].Post != nil { + title := response.Feed[0].Post.Title + if title == nil || *title != "Jetstream Indexed Post" { + t.Errorf("Expected title 'Jetstream Indexed Post', got %v", title) + } + } + + t.Logf("SUCCESS: Post indexed via Jetstream is queryable via GetAuthorPosts") + }) +} + +// TestGetAuthorPosts_CommunityFilter tests filtering posts by community +func TestGetAuthorPosts_CommunityFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + db := setupTestDB(t) + defer func() { _ = db.Close() }() + + ctx := context.Background() + + // Setup services + postRepo := postgres.NewPostRepository(db) + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) + communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) + postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) + voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) + + // Create test user + testUserDID := "did:plc:communityfilter" + _ = createTestUser(t, db, "communityfilter.test", testUserDID) + + // Create two communities + community1DID, _ := createFeedTestCommunity(db, ctx, "filter-community-1", "owner1.test") + community2DID, _ := createFeedTestCommunity(db, ctx, "filter-community-2", "owner2.test") + + // Create posts in each community + now := time.Now() + createTestPost(t, db, community1DID, testUserDID, "Post in Community 1 - A", 10, now) + createTestPost(t, db, community1DID, testUserDID, "Post in Community 1 - B", 20, now.Add(-1*time.Hour)) + createTestPost(t, db, community2DID, testUserDID, "Post in Community 2", 30, now.Add(-2*time.Hour)) + + // Setup HTTP server + e2eAuth := NewE2EOAuthMiddleware() + r := chi.NewRouter() + routes.RegisterActorRoutes(r, postService, userService, voteService, nil, e2eAuth.OAuthAuthMiddleware) + httpServer := httptest.NewServer(r) + defer httpServer.Close() + + // Test: Filter by community 1 + t.Run("Filter by community 1", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s&community=%s", + httpServer.URL, testUserDID, community1DID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + if len(response.Feed) != 2 { + t.Errorf("Expected 2 posts in community 1, got %d", len(response.Feed)) + } + + // Verify all posts are from community 1 + for _, fp := range response.Feed { + if fp.Post.Community.DID != community1DID { + t.Errorf("Expected community DID %s, got %s", community1DID, fp.Post.Community.DID) + } + } + + t.Logf("SUCCESS: Community filter returned %d posts from community 1", len(response.Feed)) + }) + + // Test: No filter returns all posts + t.Run("No filter returns all posts", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/xrpc/social.coves.actor.getPosts?actor=%s", httpServer.URL, testUserDID), nil) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to GET posts: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var response posts.GetAuthorPostsResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&response); decodeErr != nil { + t.Fatalf("Failed to decode response: %v", decodeErr) + } + + if len(response.Feed) != 3 { + t.Errorf("Expected 3 total posts, got %d", len(response.Feed)) + } + + t.Logf("SUCCESS: No filter returned %d total posts", len(response.Feed)) + }) +} diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -49,6 +49,10 @@ if err != nil { t.Fatalf("Failed to connect to test database: %v", err) } + // Limit connection pool to prevent "too many clients" error in parallel tests + db.SetMaxOpenConns(5) + db.SetMaxIdleConns(2) + if pingErr := db.Ping(); pingErr != nil { t.Fatalf("Failed to ping test database: %v", pingErr) }