diff --git a/Makefile b/Makefile index d2a43a1..9e4b036 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,25 @@ e2e-test: ## Run automated E2E tests (requires: make dev-up + make run in anothe @echo "" @echo "$(GREEN)āœ“ E2E tests complete!$(RESET)" +e2e-vote-test: ## Run vote E2E tests (requires: make dev-up) + @echo "$(CYAN)========================================$(RESET)" + @echo "$(CYAN) E2E Test: Vote System $(RESET)" + @echo "$(CYAN)========================================$(RESET)" + @echo "" + @echo "$(CYAN)Prerequisites:$(RESET)" + @echo " 1. Run 'make dev-up' (starts PDS + Jetstream + PostgreSQL)" + @echo " 2. Test database will be used (port 5434)" + @echo "" + @echo "$(GREEN)Running vote E2E tests...$(RESET)" + @echo "" + @echo "$(CYAN)Running simulated E2E test (fast)...$(RESET)" + @go test ./tests/integration -run TestVote_E2E_WithJetstream -v + @echo "" + @echo "$(CYAN)Running live PDS E2E test (requires PDS + Jetstream)...$(RESET)" + @go test ./tests/integration -run TestVote_E2E_LivePDS -v || echo "$(YELLOW)Live PDS test skipped (run 'make dev-up' first)$(RESET)" + @echo "" + @echo "$(GREEN)āœ“ Vote E2E tests complete!$(RESET)" + test-db-reset: ## Reset test database @echo "$(GREEN)Resetting test database...$(RESET)" @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test rm -sf postgres-test diff --git a/cmd/server/main.go b/cmd/server/main.go index 6c02f07..5a4bff3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "Coves/internal/core/posts" "Coves/internal/core/timeline" "Coves/internal/core/users" + "Coves/internal/core/votes" "bytes" "context" "database/sql" @@ -281,6 +282,11 @@ func main() { postRepo := postgresRepo.NewPostRepository(db) postService := posts.NewPostService(postRepo, communityService, aggregatorService, defaultPDS) + // Initialize vote service + voteRepo := postgresRepo.NewVoteRepository(db) + voteService := votes.NewVoteService(voteRepo, postRepo, defaultPDS) + log.Println("āœ… Vote service initialized") + // Initialize feed service feedRepo := postgresRepo.NewCommunityFeedRepository(db) feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) @@ -344,6 +350,27 @@ func main() { log.Println(" - Indexing: social.coves.aggregator.service (service declarations)") log.Println(" - Indexing: social.coves.aggregator.authorization (authorization records)") + // Start Jetstream consumer for votes + // This consumer indexes votes from user repositories and updates post vote counts + voteJetstreamURL := os.Getenv("VOTE_JETSTREAM_URL") + if voteJetstreamURL == "" { + // Listen to vote record CREATE/DELETE events from user repositories + voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.interaction.vote" + } + + voteEventConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + voteJetstreamConnector := jetstream.NewVoteJetstreamConnector(voteEventConsumer, voteJetstreamURL) + + go func() { + if startErr := voteJetstreamConnector.Start(ctx); startErr != nil { + log.Printf("Vote Jetstream consumer stopped: %v", startErr) + } + }() + + log.Printf("Started Jetstream vote consumer: %s", voteJetstreamURL) + log.Println(" - Indexing: social.coves.interaction.vote CREATE/DELETE operations") + log.Println(" - Updating: Post vote counts atomically") + // Register XRPC routes routes.RegisterUserRoutes(r, userService) routes.RegisterCommunityRoutes(r, communityService, authMiddleware) @@ -352,6 +379,9 @@ func main() { routes.RegisterPostRoutes(r, postService, authMiddleware) log.Println("Post XRPC endpoints registered with OAuth authentication") + routes.RegisterVoteRoutes(r, voteService, authMiddleware) + log.Println("Vote XRPC endpoints registered with OAuth authentication") + routes.RegisterCommunityFeedRoutes(r, feedService) log.Println("Feed XRPC endpoints registered (public, no auth required)") diff --git a/go.mod b/go.mod index 887d290..f0b1e0b 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/go.sum b/go.sum index 6707f9e..9274474 100644 --- a/go.sum +++ b/go.sum @@ -172,6 +172,8 @@ github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/internal/api/handlers/errors.go b/internal/api/handlers/errors.go new file mode 100644 index 0000000..e486817 --- /dev/null +++ b/internal/api/handlers/errors.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" +) + +// WriteError writes a standardized 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(map[string]interface{}{ + "error": errorType, + "message": message, + }); err != nil { + log.Printf("Failed to encode error response: %v", err) + } +} diff --git a/internal/api/handlers/vote/create_vote.go b/internal/api/handlers/vote/create_vote.go new file mode 100644 index 0000000..dfc98fd --- /dev/null +++ b/internal/api/handlers/vote/create_vote.go @@ -0,0 +1,129 @@ +package vote + +import ( + "Coves/internal/api/handlers" + "Coves/internal/api/middleware" + "Coves/internal/core/votes" + "encoding/json" + "log" + "net/http" +) + +// CreateVoteHandler handles vote creation +type CreateVoteHandler struct { + service votes.Service +} + +// NewCreateVoteHandler creates a new create vote handler +func NewCreateVoteHandler(service votes.Service) *CreateVoteHandler { + return &CreateVoteHandler{ + service: service, + } +} + +// HandleCreateVote creates a vote or toggles an existing vote +// POST /xrpc/social.coves.interaction.createVote +// +// Request body: { "subject": "at://...", "direction": "up" | "down" } +func (h *CreateVoteHandler) HandleCreateVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse request body + var req votes.CreateVoteRequest + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + if req.Subject == "" { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "subject is required") + return + } + + if req.Direction == "" { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "direction is required") + return + } + + if req.Direction != "up" && req.Direction != "down" { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "direction must be 'up' or 'down'") + return + } + + // Extract authenticated user DID and access token from request context (injected by auth middleware) + voterDID := middleware.GetUserDID(r) + if voterDID == "" { + handlers.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + userAccessToken := middleware.GetUserAccessToken(r) + if userAccessToken == "" { + handlers.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") + return + } + + // Create vote via service (write-forward to user's PDS) + response, err := h.service.CreateVote(r.Context(), voterDID, userAccessToken, req) + if err != nil { + handleServiceError(w, err) + return + } + + // Handle toggle-off case (vote was deleted, not created) + if response.URI == "" { + // Vote was toggled off (deleted) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "deleted": true, + }); err != nil { + log.Printf("Failed to encode response: %v", err) + } + return + } + + // Return success response + responseMap := map[string]interface{}{ + "uri": response.URI, + "cid": response.CID, + } + + if response.Existing != nil { + responseMap["existing"] = *response.Existing + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(responseMap); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} + +// handleServiceError converts service errors to HTTP responses +func handleServiceError(w http.ResponseWriter, err error) { + switch err { + case votes.ErrVoteNotFound: + handlers.WriteError(w, http.StatusNotFound, "VoteNotFound", "Vote not found") + case votes.ErrSubjectNotFound: + handlers.WriteError(w, http.StatusNotFound, "SubjectNotFound", "Post or comment not found") + case votes.ErrInvalidDirection: + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid vote direction") + case votes.ErrInvalidSubject: + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid subject URI") + case votes.ErrVoteAlreadyExists: + handlers.WriteError(w, http.StatusConflict, "VoteAlreadyExists", "Vote already exists") + case votes.ErrNotAuthorized: + handlers.WriteError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") + case votes.ErrBanned: + handlers.WriteError(w, http.StatusForbidden, "Banned", "User is banned from this community") + default: + // Check for validation errors + log.Printf("Vote creation error: %v", err) + handlers.WriteError(w, http.StatusInternalServerError, "InternalError", "Failed to create vote") + } +} diff --git a/internal/api/handlers/vote/delete_vote.go b/internal/api/handlers/vote/delete_vote.go new file mode 100644 index 0000000..7232057 --- /dev/null +++ b/internal/api/handlers/vote/delete_vote.go @@ -0,0 +1,75 @@ +package vote + +import ( + "Coves/internal/api/handlers" + "Coves/internal/api/middleware" + "Coves/internal/core/votes" + "encoding/json" + "log" + "net/http" +) + +// DeleteVoteHandler handles vote deletion +type DeleteVoteHandler struct { + service votes.Service +} + +// NewDeleteVoteHandler creates a new delete vote handler +func NewDeleteVoteHandler(service votes.Service) *DeleteVoteHandler { + return &DeleteVoteHandler{ + service: service, + } +} + +// HandleDeleteVote removes a vote from a post/comment +// POST /xrpc/social.coves.interaction.deleteVote +// +// Request body: { "subject": "at://..." } +func (h *DeleteVoteHandler) HandleDeleteVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse request body + var req votes.DeleteVoteRequest + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + if req.Subject == "" { + handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "subject is required") + return + } + + // Extract authenticated user DID and access token from request context (injected by auth middleware) + voterDID := middleware.GetUserDID(r) + if voterDID == "" { + handlers.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + userAccessToken := middleware.GetUserAccessToken(r) + if userAccessToken == "" { + handlers.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") + return + } + + // Delete vote via service (delete record on PDS) + err := h.service.DeleteVote(r.Context(), voterDID, userAccessToken, req) + if err != nil { + handleServiceError(w, err) + return + } + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} diff --git a/internal/api/routes/vote.go b/internal/api/routes/vote.go new file mode 100644 index 0000000..f5bffa0 --- /dev/null +++ b/internal/api/routes/vote.go @@ -0,0 +1,24 @@ +package routes + +import ( + "Coves/internal/api/handlers/vote" + "Coves/internal/api/middleware" + "Coves/internal/core/votes" + + "github.com/go-chi/chi/v5" +) + +// RegisterVoteRoutes registers vote-related XRPC endpoints on the router +// Implements social.coves.interaction.* lexicon endpoints for voting +func RegisterVoteRoutes(r chi.Router, service votes.Service, authMiddleware *middleware.AtProtoAuthMiddleware) { + // Initialize handlers + createVoteHandler := vote.NewCreateVoteHandler(service) + deleteVoteHandler := vote.NewDeleteVoteHandler(service) + + // Procedure endpoints (POST) - require authentication + // social.coves.interaction.createVote - create or toggle a vote on a post/comment + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.interaction.createVote", createVoteHandler.HandleCreateVote) + + // social.coves.interaction.deleteVote - delete a vote from a post/comment + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.interaction.deleteVote", deleteVoteHandler.HandleDeleteVote) +} diff --git a/internal/atproto/jetstream/vote_consumer.go b/internal/atproto/jetstream/vote_consumer.go new file mode 100644 index 0000000..fbb8ac8 --- /dev/null +++ b/internal/atproto/jetstream/vote_consumer.go @@ -0,0 +1,379 @@ +package jetstream + +import ( + "Coves/internal/core/users" + "Coves/internal/core/votes" + "context" + "database/sql" + "fmt" + "log" + "strings" + "time" +) + +// VoteEventConsumer consumes vote-related events from Jetstream +// Handles CREATE and DELETE operations for social.coves.interaction.vote +type VoteEventConsumer struct { + voteRepo votes.Repository + userService users.UserService + db *sql.DB // Direct DB access for atomic vote count updates +} + +// NewVoteEventConsumer creates a new Jetstream consumer for vote events +func NewVoteEventConsumer( + voteRepo votes.Repository, + userService users.UserService, + db *sql.DB, +) *VoteEventConsumer { + return &VoteEventConsumer{ + voteRepo: voteRepo, + userService: userService, + db: db, + } +} + +// HandleEvent processes a Jetstream event for vote records +func (c *VoteEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error { + // We only care about commit events for vote records + if event.Kind != "commit" || event.Commit == nil { + return nil + } + + commit := event.Commit + + // Handle vote record operations + if commit.Collection == "social.coves.interaction.vote" { + switch commit.Operation { + case "create": + return c.createVote(ctx, event.Did, commit) + case "delete": + return c.deleteVote(ctx, event.Did, commit) + } + } + + // Silently ignore other operations and collections + return nil +} + +// createVote indexes a new vote from the firehose and updates post counts +func (c *VoteEventConsumer) createVote(ctx context.Context, repoDID string, commit *CommitEvent) error { + if commit.Record == nil { + return fmt.Errorf("vote create event missing record data") + } + + // Parse the vote record + voteRecord, err := parseVoteRecord(commit.Record) + if err != nil { + return fmt.Errorf("failed to parse vote record: %w", err) + } + + // SECURITY: Validate this is a legitimate vote event + if err := c.validateVoteEvent(ctx, repoDID, voteRecord); err != nil { + log.Printf("🚨 SECURITY: Rejecting vote event: %v", err) + return err + } + + // Build AT-URI for this vote + // Format: at://voter_did/social.coves.interaction.vote/rkey + uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) + + // Parse timestamp from record + createdAt, err := time.Parse(time.RFC3339, voteRecord.CreatedAt) + if err != nil { + log.Printf("Warning: Failed to parse createdAt timestamp, using current time: %v", err) + createdAt = time.Now() + } + + // Build vote entity + vote := &votes.Vote{ + URI: uri, + CID: commit.CID, + RKey: commit.RKey, + VoterDID: repoDID, // Vote comes from user's repository + SubjectURI: voteRecord.Subject.URI, + SubjectCID: voteRecord.Subject.CID, + Direction: voteRecord.Direction, + CreatedAt: createdAt, + IndexedAt: time.Now(), + } + + // Atomically: Index vote + Update post counts + if err := c.indexVoteAndUpdateCounts(ctx, vote); err != nil { + return fmt.Errorf("failed to index vote and update counts: %w", err) + } + + log.Printf("āœ“ Indexed vote: %s (%s on %s)", uri, vote.Direction, vote.SubjectURI) + return nil +} + +// deleteVote soft-deletes a vote and updates post counts +func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, commit *CommitEvent) error { + // Build AT-URI for the vote being deleted + uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) + + // Get existing vote to know its direction (for decrementing the right counter) + existingVote, err := c.voteRepo.GetByURI(ctx, uri) + if err != nil { + if err == votes.ErrVoteNotFound { + // Idempotent: Vote already deleted or never existed + log.Printf("Vote already deleted or not found: %s", uri) + return nil + } + return fmt.Errorf("failed to get existing vote: %w", err) + } + + // Atomically: Soft-delete vote + Update post counts + if err := c.deleteVoteAndUpdateCounts(ctx, existingVote); err != nil { + return fmt.Errorf("failed to delete vote and update counts: %w", err) + } + + log.Printf("āœ“ Deleted vote: %s (%s on %s)", uri, existingVote.Direction, existingVote.SubjectURI) + return nil +} + +// indexVoteAndUpdateCounts atomically indexes a vote and updates post vote counts +func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone { + log.Printf("Failed to rollback transaction: %v", rollbackErr) + } + }() + + // 1. Index the vote (idempotent with ON CONFLICT DO NOTHING) + query := ` + INSERT INTO votes ( + uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, NOW() + ) + ON CONFLICT (uri) DO NOTHING + RETURNING id + ` + + var voteID int64 + err = tx.QueryRowContext( + ctx, query, + vote.URI, vote.CID, vote.RKey, vote.VoterDID, + vote.SubjectURI, vote.SubjectCID, vote.Direction, + vote.CreatedAt, + ).Scan(&voteID) + + // If no rows returned, vote already exists (idempotent - OK for Jetstream replays) + if err == sql.ErrNoRows { + log.Printf("Vote already indexed: %s (idempotent)", vote.URI) + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf("failed to commit transaction: %w", commitErr) + } + return nil + } + + if err != nil { + return fmt.Errorf("failed to insert vote: %w", err) + } + + // 2. Update post vote counts atomically + // Increment upvote_count or downvote_count based on direction + // Also update score (upvote_count - downvote_count) + var updateQuery string + if vote.Direction == "up" { + updateQuery = ` + UPDATE posts + SET upvote_count = upvote_count + 1, + score = upvote_count + 1 - downvote_count + WHERE uri = $1 AND deleted_at IS NULL + ` + } else { // "down" + updateQuery = ` + UPDATE posts + SET downvote_count = downvote_count + 1, + score = upvote_count - (downvote_count + 1) + WHERE uri = $1 AND deleted_at IS NULL + ` + } + + result, err := tx.ExecContext(ctx, updateQuery, vote.SubjectURI) + if err != nil { + return fmt.Errorf("failed to update post counts: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check update result: %w", err) + } + + // If post doesn't exist or is deleted, that's OK (vote still indexed) + // Future: We could validate post exists before indexing vote + if rowsAffected == 0 { + log.Printf("Warning: Post not found or deleted: %s (vote indexed anyway)", vote.SubjectURI) + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// deleteVoteAndUpdateCounts atomically soft-deletes a vote and updates post vote counts +func (c *VoteEventConsumer) deleteVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone { + log.Printf("Failed to rollback transaction: %v", rollbackErr) + } + }() + + // 1. Soft-delete the vote (idempotent) + deleteQuery := ` + UPDATE votes + SET deleted_at = NOW() + WHERE uri = $1 AND deleted_at IS NULL + ` + + result, err := tx.ExecContext(ctx, deleteQuery, vote.URI) + if err != nil { + return fmt.Errorf("failed to delete vote: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check delete result: %w", err) + } + + // Idempotent: If no rows affected, vote already deleted + if rowsAffected == 0 { + log.Printf("Vote already deleted: %s (idempotent)", vote.URI) + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf("failed to commit transaction: %w", commitErr) + } + return nil + } + + // 2. Decrement post vote counts atomically + // Decrement upvote_count or downvote_count based on direction + // Also update score (use GREATEST to prevent negative counts) + var updateQuery string + if vote.Direction == "up" { + updateQuery = ` + UPDATE posts + SET upvote_count = GREATEST(0, upvote_count - 1), + score = GREATEST(0, upvote_count - 1) - downvote_count + WHERE uri = $1 AND deleted_at IS NULL + ` + } else { // "down" + updateQuery = ` + UPDATE posts + SET downvote_count = GREATEST(0, downvote_count - 1), + score = upvote_count - GREATEST(0, downvote_count - 1) + WHERE uri = $1 AND deleted_at IS NULL + ` + } + + result, err = tx.ExecContext(ctx, updateQuery, vote.SubjectURI) + if err != nil { + return fmt.Errorf("failed to update post counts: %w", err) + } + + rowsAffected, err = result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check update result: %w", err) + } + + // If post doesn't exist or is deleted, that's OK (vote still deleted) + if rowsAffected == 0 { + log.Printf("Warning: Post not found or deleted: %s (vote deleted anyway)", vote.SubjectURI) + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// validateVoteEvent performs security validation on vote events +func (c *VoteEventConsumer) validateVoteEvent(ctx context.Context, repoDID string, vote *VoteRecordFromJetstream) error { + // SECURITY: Votes MUST come from user repositories (repo owner = voter DID) + // The repository owner (repoDID) IS the voter - votes are stored in user repos. + // + // We do NOT check if the user exists in AppView because: + // 1. Vote events may arrive before user events in Jetstream (race condition) + // 2. The vote came from the user's PDS repository (authenticated by PDS) + // 3. The database FK constraint was removed to allow out-of-order indexing + // 4. Orphaned votes (from never-indexed users) are harmless + // + // Security is maintained because: + // - Vote must come from user's own PDS repository (verified by atProto) + // - Communities cannot create votes in their repos (different collection) + // - Fake DIDs will fail PDS authentication + + // Validate DID format (basic sanity check) + if !strings.HasPrefix(repoDID, "did:") { + return fmt.Errorf("invalid voter DID format: %s", repoDID) + } + + // Validate vote direction + if vote.Direction != "up" && vote.Direction != "down" { + return fmt.Errorf("invalid vote direction: %s (must be 'up' or 'down')", vote.Direction) + } + + // Validate subject has both URI and CID (strong reference) + if vote.Subject.URI == "" || vote.Subject.CID == "" { + return fmt.Errorf("invalid subject: must have both URI and CID (strong reference)") + } + + return nil +} + +// VoteRecordFromJetstream represents a vote record as received from Jetstream +type VoteRecordFromJetstream struct { + Subject StrongRefFromJetstream `json:"subject"` + Direction string `json:"direction"` + CreatedAt string `json:"createdAt"` +} + +// StrongRefFromJetstream represents a strong reference (URI + CID) +type StrongRefFromJetstream struct { + URI string `json:"uri"` + CID string `json:"cid"` +} + +// parseVoteRecord parses a vote record from Jetstream event data +func parseVoteRecord(record map[string]interface{}) (*VoteRecordFromJetstream, error) { + // Extract subject (strong reference) + subjectMap, ok := record["subject"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid subject field") + } + + subjectURI, _ := subjectMap["uri"].(string) + subjectCID, _ := subjectMap["cid"].(string) + + // Extract direction + direction, _ := record["direction"].(string) + + // Extract createdAt + createdAt, _ := record["createdAt"].(string) + + return &VoteRecordFromJetstream{ + Subject: StrongRefFromJetstream{ + URI: subjectURI, + CID: subjectCID, + }, + Direction: direction, + CreatedAt: createdAt, + }, nil +} diff --git a/internal/atproto/jetstream/vote_jetstream_connector.go b/internal/atproto/jetstream/vote_jetstream_connector.go new file mode 100644 index 0000000..c52921d --- /dev/null +++ b/internal/atproto/jetstream/vote_jetstream_connector.go @@ -0,0 +1,125 @@ +package jetstream + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// VoteJetstreamConnector handles WebSocket connection to Jetstream for vote events +type VoteJetstreamConnector struct { + consumer *VoteEventConsumer + wsURL string +} + +// NewVoteJetstreamConnector creates a new Jetstream WebSocket connector for vote events +func NewVoteJetstreamConnector(consumer *VoteEventConsumer, wsURL string) *VoteJetstreamConnector { + return &VoteJetstreamConnector{ + consumer: consumer, + wsURL: wsURL, + } +} + +// Start begins consuming events from Jetstream +// Runs indefinitely, reconnecting on errors +func (c *VoteJetstreamConnector) Start(ctx context.Context) error { + log.Printf("Starting Jetstream vote consumer: %s", c.wsURL) + + for { + select { + case <-ctx.Done(): + log.Println("Jetstream vote consumer shutting down") + return ctx.Err() + default: + if err := c.connect(ctx); err != nil { + log.Printf("Jetstream vote connection error: %v. Retrying in 5s...", err) + time.Sleep(5 * time.Second) + continue + } + } + } +} + +// connect establishes WebSocket connection and processes events +func (c *VoteJetstreamConnector) connect(ctx context.Context) error { + conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil) + if err != nil { + return fmt.Errorf("failed to connect to Jetstream: %w", err) + } + defer func() { + if closeErr := conn.Close(); closeErr != nil { + log.Printf("Failed to close WebSocket connection: %v", closeErr) + } + }() + + log.Println("Connected to Jetstream (vote consumer)") + + // Set read deadline to detect connection issues + if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil { + log.Printf("Failed to set read deadline: %v", err) + } + + // Set pong handler to keep connection alive + conn.SetPongHandler(func(string) error { + if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil { + log.Printf("Failed to set read deadline in pong handler: %v", err) + } + return nil + }) + + // Start ping ticker + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + done := make(chan struct{}) + var closeOnce sync.Once // Ensure done channel is only closed once + + // Ping goroutine + go func() { + for { + select { + case <-ticker.C: + if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { + log.Printf("Failed to send ping: %v", err) + closeOnce.Do(func() { close(done) }) + return + } + case <-done: + return + } + } + }() + + // Read loop + for { + select { + case <-done: + return fmt.Errorf("connection closed by ping failure") + default: + } + + _, message, err := conn.ReadMessage() + if err != nil { + closeOnce.Do(func() { close(done) }) + return fmt.Errorf("read error: %w", err) + } + + // Parse Jetstream event + var event JetstreamEvent + if err := json.Unmarshal(message, &event); err != nil { + log.Printf("Failed to parse Jetstream event: %v", err) + continue + } + + // Process event through consumer + if err := c.consumer.HandleEvent(ctx, &event); err != nil { + log.Printf("Failed to handle vote event: %v", err) + // Continue processing other events even if one fails + } + } +} diff --git a/internal/atproto/lexicon/social/coves/interaction/vote.json b/internal/atproto/lexicon/social/coves/interaction/vote.json index e5f166a..94a4c5a 100644 --- a/internal/atproto/lexicon/social/coves/interaction/vote.json +++ b/internal/atproto/lexicon/social/coves/interaction/vote.json @@ -4,23 +4,46 @@ "defs": { "main": { "type": "record", - "description": "An upvote on a post or comment", + "description": "A vote (upvote or downvote) on a post or comment", "key": "tid", "record": { "type": "object", - "required": ["subject", "createdAt"], + "required": ["subject", "direction", "createdAt"], "properties": { "subject": { + "type": "ref", + "ref": "#strongRef", + "description": "Strong reference to the post or comment being voted on" + }, + "direction": { "type": "string", - "format": "at-uri", - "description": "AT-URI of the post or comment being voted on" + "enum": ["up", "down"], + "description": "Vote direction: up for upvote, down for downvote" }, "createdAt": { "type": "string", - "format": "datetime" + "format": "datetime", + "description": "Timestamp when the vote was created" } } } + }, + "strongRef": { + "type": "object", + "description": "Strong reference to a record (AT-URI + CID)", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the record content" + } + } } } } \ No newline at end of file diff --git a/internal/core/votes/errors.go b/internal/core/votes/errors.go new file mode 100644 index 0000000..5310ea4 --- /dev/null +++ b/internal/core/votes/errors.go @@ -0,0 +1,26 @@ +package votes + +import "errors" + +var ( + // ErrVoteNotFound indicates the requested vote doesn't exist + ErrVoteNotFound = errors.New("vote not found") + + // ErrSubjectNotFound indicates the post/comment being voted on doesn't exist + ErrSubjectNotFound = errors.New("subject not found") + + // ErrInvalidDirection indicates the vote direction is not "up" or "down" + ErrInvalidDirection = errors.New("invalid vote direction: must be 'up' or 'down'") + + // ErrInvalidSubject indicates the subject URI is malformed or invalid + ErrInvalidSubject = errors.New("invalid subject URI") + + // ErrVoteAlreadyExists indicates a vote already exists on this subject + ErrVoteAlreadyExists = errors.New("vote already exists") + + // ErrNotAuthorized indicates the user is not authorized to perform this action + ErrNotAuthorized = errors.New("not authorized") + + // ErrBanned indicates the user is banned from the community + ErrBanned = errors.New("user is banned from this community") +) diff --git a/internal/core/votes/interfaces.go b/internal/core/votes/interfaces.go new file mode 100644 index 0000000..7940d42 --- /dev/null +++ b/internal/core/votes/interfaces.go @@ -0,0 +1,54 @@ +package votes + +import "context" + +// Service defines the business logic interface for votes +// Coordinates between Repository, user PDS, and vote validation +type Service interface { + // CreateVote creates a new vote or toggles an existing vote + // Flow: Validate -> Check existing vote -> Handle toggle logic -> Write to user's PDS -> Return URI/CID + // AppView indexing happens asynchronously via Jetstream consumer + // Toggle logic: + // - No vote -> Create vote + // - Same direction -> Delete vote (toggle off) + // - Different direction -> Delete old + Create new (toggle direction) + CreateVote(ctx context.Context, voterDID string, userAccessToken string, req CreateVoteRequest) (*CreateVoteResponse, error) + + // DeleteVote removes a vote from a post/comment + // Flow: Find vote -> Verify ownership -> Delete from user's PDS + // AppView decrements vote count asynchronously via Jetstream consumer + DeleteVote(ctx context.Context, voterDID string, userAccessToken string, req DeleteVoteRequest) error + + // GetVote retrieves a user's vote on a specific subject + // Used to check vote state before creating/toggling + GetVote(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) +} + +// Repository defines the data access interface for votes +// Used by Jetstream consumer to index votes from firehose +type Repository interface { + // Create inserts a new vote into the AppView database + // Called by Jetstream consumer after vote is created on PDS + // Idempotent: ON CONFLICT DO NOTHING for duplicate URIs + Create(ctx context.Context, vote *Vote) error + + // GetByURI retrieves a vote by its AT-URI + // Used for Jetstream DELETE operations + GetByURI(ctx context.Context, uri string) (*Vote, error) + + // GetByVoterAndSubject retrieves a user's vote on a specific subject + // Used to check existing vote state + GetByVoterAndSubject(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) + + // Delete soft-deletes a vote (sets deleted_at) + // Called by Jetstream consumer after vote is deleted from PDS + Delete(ctx context.Context, uri string) error + + // ListBySubject retrieves all votes on a specific post/comment + // Future: Used for vote detail views + ListBySubject(ctx context.Context, subjectURI string, limit, offset int) ([]*Vote, error) + + // ListByVoter retrieves all votes by a specific user + // Future: Used for user voting history + ListByVoter(ctx context.Context, voterDID string, limit, offset int) ([]*Vote, error) +} diff --git a/internal/core/votes/service.go b/internal/core/votes/service.go new file mode 100644 index 0000000..5bf1446 --- /dev/null +++ b/internal/core/votes/service.go @@ -0,0 +1,399 @@ +package votes + +import ( + "Coves/internal/core/posts" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +type voteService struct { + repo Repository + postRepo posts.Repository + pdsURL string +} + +// NewVoteService creates a new vote service +func NewVoteService( + repo Repository, + postRepo posts.Repository, + pdsURL string, +) Service { + return &voteService{ + repo: repo, + postRepo: postRepo, + pdsURL: pdsURL, + } +} + +// CreateVote creates a new vote or toggles an existing vote +// Toggle logic: +// - No vote -> Create vote +// - Same direction -> Delete vote (toggle off) +// - Different direction -> Delete old + Create new (toggle direction) +func (s *voteService) CreateVote(ctx context.Context, voterDID string, userAccessToken string, req CreateVoteRequest) (*CreateVoteResponse, error) { + // 1. Validate input + if voterDID == "" { + return nil, NewValidationError("voterDid", "required") + } + if userAccessToken == "" { + return nil, NewValidationError("userAccessToken", "required") + } + if req.Subject == "" { + return nil, NewValidationError("subject", "required") + } + if req.Direction != "up" && req.Direction != "down" { + return nil, ErrInvalidDirection + } + + // 2. Validate subject URI format (should be at://...) + if !strings.HasPrefix(req.Subject, "at://") { + return nil, ErrInvalidSubject + } + + // 3. Get subject post/comment to verify it exists and get its CID (for strong reference) + // For now, we assume the subject is a post. In the future, we'll support comments too. + post, err := s.postRepo.GetByURI(ctx, req.Subject) + if err != nil { + if err == posts.ErrNotFound { + return nil, ErrSubjectNotFound + } + return nil, fmt.Errorf("failed to get subject post: %w", err) + } + + // 4. Check for existing vote on PDS (source of truth for toggle logic) + // IMPORTANT: We query the user's PDS directly instead of AppView to avoid race conditions. + // AppView is eventually consistent (updated via Jetstream), so querying it can cause + // duplicate vote records if the user toggles before Jetstream catches up. + existingVoteRecord, err := s.findVoteOnPDS(ctx, voterDID, userAccessToken, req.Subject) + if err != nil { + return nil, fmt.Errorf("failed to check existing vote on PDS: %w", err) + } + + // 5. Handle toggle logic + var existingVoteURI *string + + if existingVoteRecord != nil { + // Vote exists on PDS - implement toggle logic + if existingVoteRecord.Direction == req.Direction { + // Same direction -> Delete vote (toggle off) + log.Printf("[VOTE-CREATE] Toggle off: deleting existing %s vote on %s", req.Direction, req.Subject) + + // Delete from user's PDS + if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { + return nil, fmt.Errorf("failed to delete vote on PDS: %w", err) + } + + // Return empty response (vote was deleted, not created) + return &CreateVoteResponse{ + URI: "", + CID: "", + }, nil + } + + // Different direction -> Delete old vote first, then create new one below + log.Printf("[VOTE-CREATE] Toggle direction: %s -> %s on %s", existingVoteRecord.Direction, req.Direction, req.Subject) + + if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { + return nil, fmt.Errorf("failed to delete old vote on PDS: %w", err) + } + + existingVoteURI = &existingVoteRecord.URI + } + + // 6. Build vote record with strong reference + voteRecord := map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": req.Subject, + "cid": post.CID, + }, + "direction": req.Direction, + "createdAt": time.Now().Format(time.RFC3339), + } + + // 7. Write to user's PDS repository + recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", "", voteRecord, userAccessToken) + if err != nil { + return nil, fmt.Errorf("failed to create vote on PDS: %w", err) + } + + log.Printf("[VOTE-CREATE] Created %s vote: %s (CID: %s)", req.Direction, recordURI, recordCID) + + // 8. Return response + return &CreateVoteResponse{ + URI: recordURI, + CID: recordCID, + Existing: existingVoteURI, + }, nil +} + +// DeleteVote removes a vote from a post/comment +func (s *voteService) DeleteVote(ctx context.Context, voterDID string, userAccessToken string, req DeleteVoteRequest) error { + // 1. Validate input + if voterDID == "" { + return NewValidationError("voterDid", "required") + } + if userAccessToken == "" { + return NewValidationError("userAccessToken", "required") + } + if req.Subject == "" { + return NewValidationError("subject", "required") + } + + // 2. Find existing vote on PDS (source of truth) + // IMPORTANT: Query PDS directly to avoid race conditions with AppView indexing + existingVoteRecord, err := s.findVoteOnPDS(ctx, voterDID, userAccessToken, req.Subject) + if err != nil { + return fmt.Errorf("failed to check existing vote on PDS: %w", err) + } + + if existingVoteRecord == nil { + return ErrVoteNotFound + } + + // 3. Delete from user's PDS + if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { + return fmt.Errorf("failed to delete vote on PDS: %w", err) + } + + log.Printf("[VOTE-DELETE] Deleted vote: %s", existingVoteRecord.URI) + + return nil +} + +// GetVote retrieves a user's vote on a specific subject +func (s *voteService) GetVote(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) { + return s.repo.GetByVoterAndSubject(ctx, voterDID, subjectURI) +} + +// Helper methods for PDS operations + +// createRecordOnPDSAs creates a record on the PDS using the user's access token +func (s *voteService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/")) + + payload := map[string]interface{}{ + "repo": repoDID, + "collection": collection, + "record": record, + } + + if rkey != "" { + payload["rkey"] = rkey + } + + return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) +} + +// deleteRecordOnPDSAs deletes a record from the PDS using the user's access token +func (s *voteService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error { + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) + + payload := map[string]interface{}{ + "repo": repoDID, + "collection": collection, + "rkey": rkey, + } + + _, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) + return err +} + +// callPDSWithAuth makes a PDS call with a specific access token +func (s *voteService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) { + jsonData, err := json.Marshal(payload) + if err != nil { + return "", "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return "", "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Add authentication with provided access token + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + // Use 30 second timeout for write operations + timeout := 30 * time.Second + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to call PDS: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("Failed to close response body: %v", closeErr) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, string(body)) + } + + // Parse response to extract URI and CID + var result struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("failed to parse PDS response: %w", err) + } + + return result.URI, result.CID, nil +} + +// Helper functions + +// PDSVoteRecord represents a vote record returned from PDS listRecords +type PDSVoteRecord struct { + URI string + RKey string + Direction string + Subject struct { + URI string + CID string + } +} + +// findVoteOnPDS queries the user's PDS to find an existing vote on a specific subject +// This is the source of truth for toggle logic (avoiding AppView race conditions) +// +// IMPORTANT: This function paginates through ALL user votes with reverse=true (newest first) +// to handle users with >100 votes. Without pagination, votes on older posts would not be found, +// causing duplicate vote records and 404 errors on delete operations. +func (s *voteService) findVoteOnPDS(ctx context.Context, voterDID, accessToken, subjectURI string) (*PDSVoteRecord, error) { + const maxPages = 50 // Safety limit: prevent infinite loops (50 pages * 100 = 5000 votes max) + var cursor string + pageCount := 0 + + client := &http.Client{Timeout: 10 * time.Second} + + for { + pageCount++ + if pageCount > maxPages { + log.Printf("[VOTE-PDS] Reached max pagination limit (%d pages) searching for vote on %s", maxPages, subjectURI) + break + } + + // Build endpoint with pagination cursor and reverse=true (newest first) + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=social.coves.interaction.vote&limit=100&reverse=true", + strings.TrimSuffix(s.pdsURL, "/"), voterDID) + + if cursor != "" { + endpoint += fmt.Sprintf("&cursor=%s", cursor) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query PDS: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Records []struct { + URI string `json:"uri"` + Value struct { + Subject struct { + URI string `json:"uri"` + CID string `json:"cid"` + } `json:"subject"` + Direction string `json:"direction"` + } `json:"value"` + } `json:"records"` + Cursor string `json:"cursor,omitempty"` // Pagination cursor for next page + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to decode PDS response: %w", err) + } + resp.Body.Close() + + // Find vote on this specific subject in current page + for _, record := range result.Records { + if record.Value.Subject.URI == subjectURI { + rkey := extractRKeyFromURI(record.URI) + log.Printf("[VOTE-PDS] Found existing vote on page %d: %s (direction: %s)", pageCount, record.URI, record.Value.Direction) + return &PDSVoteRecord{ + URI: record.URI, + RKey: rkey, + Direction: record.Value.Direction, + Subject: struct { + URI string + CID string + }{ + URI: record.Value.Subject.URI, + CID: record.Value.Subject.CID, + }, + }, nil + } + } + + // No more pages to check + if result.Cursor == "" { + log.Printf("[VOTE-PDS] No existing vote found after checking %d page(s)", pageCount) + break + } + + // Move to next page + cursor = result.Cursor + } + + // No vote found on this subject after paginating through all records + return nil, nil +} + +// extractRKeyFromURI extracts the rkey from an AT-URI (at://did/collection/rkey) +func extractRKeyFromURI(uri string) string { + parts := strings.Split(uri, "/") + if len(parts) >= 4 { + return parts[len(parts)-1] + } + return "" +} + +// ValidationError represents a validation error +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) +} + +// NewValidationError creates a new validation error +func NewValidationError(field, message string) error { + return &ValidationError{ + Field: field, + Message: message, + } +} diff --git a/internal/core/votes/service_test.go b/internal/core/votes/service_test.go new file mode 100644 index 0000000..c344115 --- /dev/null +++ b/internal/core/votes/service_test.go @@ -0,0 +1,344 @@ +package votes + +import ( + "Coves/internal/core/posts" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Mock repositories for testing +type mockVoteRepository struct { + mock.Mock +} + +func (m *mockVoteRepository) Create(ctx context.Context, vote *Vote) error { + args := m.Called(ctx, vote) + return args.Error(0) +} + +func (m *mockVoteRepository) GetByURI(ctx context.Context, uri string) (*Vote, error) { + args := m.Called(ctx, uri) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Vote), args.Error(1) +} + +func (m *mockVoteRepository) GetByVoterAndSubject(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) { + args := m.Called(ctx, voterDID, subjectURI) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Vote), args.Error(1) +} + +func (m *mockVoteRepository) Delete(ctx context.Context, uri string) error { + args := m.Called(ctx, uri) + return args.Error(0) +} + +func (m *mockVoteRepository) ListBySubject(ctx context.Context, subjectURI string, limit, offset int) ([]*Vote, error) { + args := m.Called(ctx, subjectURI, limit, offset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*Vote), args.Error(1) +} + +func (m *mockVoteRepository) ListByVoter(ctx context.Context, voterDID string, limit, offset int) ([]*Vote, error) { + args := m.Called(ctx, voterDID, limit, offset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*Vote), args.Error(1) +} + +type mockPostRepository struct { + mock.Mock +} + +func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) { + args := m.Called(ctx, uri) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*posts.Post), args.Error(1) +} + +func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error { + args := m.Called(ctx, post) + return args.Error(0) +} + +func (m *mockPostRepository) GetByRkey(ctx context.Context, communityDID, rkey string) (*posts.Post, error) { + args := m.Called(ctx, communityDID, rkey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*posts.Post), args.Error(1) +} + +func (m *mockPostRepository) ListByCommunity(ctx context.Context, communityDID string, limit, offset int) ([]*posts.Post, error) { + args := m.Called(ctx, communityDID, limit, offset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*posts.Post), args.Error(1) +} + +func (m *mockPostRepository) Delete(ctx context.Context, uri string) error { + args := m.Called(ctx, uri) + return args.Error(0) +} + +// TestVoteService_CreateVote_NoExistingVote tests creating a vote when no vote exists +// NOTE: This test is skipped because we need to refactor service to inject HTTP client +// for testing PDS writes. The full flow is covered by E2E tests. +func TestVoteService_CreateVote_NoExistingVote(t *testing.T) { + t.Skip("Skipping because we need to refactor service to inject HTTP client for testing PDS writes - covered by E2E tests") + + // This test would verify: + // - Post exists check + // - No existing vote + // - PDS write succeeds + // - Response contains vote URI and CID +} + +// TestVoteService_ValidateInput tests input validation +func TestVoteService_ValidateInput(t *testing.T) { + mockVoteRepo := new(mockVoteRepository) + mockPostRepo := new(mockPostRepository) + + service := &voteService{ + repo: mockVoteRepo, + postRepo: mockPostRepo, + pdsURL: "http://mock-pds.test", + } + + ctx := context.Background() + + tests := []struct { + name string + voterDID string + accessToken string + req CreateVoteRequest + expectedError string + }{ + { + name: "missing voter DID", + voterDID: "", + accessToken: "token123", + req: CreateVoteRequest{Subject: "at://test", Direction: "up"}, + expectedError: "voterDid", + }, + { + name: "missing access token", + voterDID: "did:plc:test", + accessToken: "", + req: CreateVoteRequest{Subject: "at://test", Direction: "up"}, + expectedError: "userAccessToken", + }, + { + name: "missing subject", + voterDID: "did:plc:test", + accessToken: "token123", + req: CreateVoteRequest{Subject: "", Direction: "up"}, + expectedError: "subject", + }, + { + name: "invalid direction", + voterDID: "did:plc:test", + accessToken: "token123", + req: CreateVoteRequest{Subject: "at://test", Direction: "invalid"}, + expectedError: "invalid vote direction", + }, + { + name: "invalid subject format", + voterDID: "did:plc:test", + accessToken: "token123", + req: CreateVoteRequest{Subject: "http://not-at-uri", Direction: "up"}, + expectedError: "invalid subject URI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := service.CreateVote(ctx, tt.voterDID, tt.accessToken, tt.req) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +// TestVoteService_GetVote tests retrieving a vote +func TestVoteService_GetVote(t *testing.T) { + mockVoteRepo := new(mockVoteRepository) + mockPostRepo := new(mockPostRepository) + + service := &voteService{ + repo: mockVoteRepo, + postRepo: mockPostRepo, + pdsURL: "http://mock-pds.test", + } + + ctx := context.Background() + voterDID := "did:plc:voter123" + subjectURI := "at://did:plc:community/social.coves.post.record/abc123" + + expectedVote := &Vote{ + ID: 1, + URI: "at://did:plc:voter123/social.coves.interaction.vote/xyz789", + VoterDID: voterDID, + SubjectURI: subjectURI, + Direction: "up", + CreatedAt: time.Now(), + } + + mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(expectedVote, nil) + + result, err := service.GetVote(ctx, voterDID, subjectURI) + assert.NoError(t, err) + assert.Equal(t, expectedVote.URI, result.URI) + assert.Equal(t, expectedVote.Direction, result.Direction) + + mockVoteRepo.AssertExpectations(t) +} + +// TestVoteService_GetVote_NotFound tests getting a non-existent vote +func TestVoteService_GetVote_NotFound(t *testing.T) { + mockVoteRepo := new(mockVoteRepository) + mockPostRepo := new(mockPostRepository) + + service := &voteService{ + repo: mockVoteRepo, + postRepo: mockPostRepo, + pdsURL: "http://mock-pds.test", + } + + ctx := context.Background() + voterDID := "did:plc:voter123" + subjectURI := "at://did:plc:community/social.coves.post.record/noexist" + + mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(nil, ErrVoteNotFound) + + result, err := service.GetVote(ctx, voterDID, subjectURI) + assert.ErrorIs(t, err, ErrVoteNotFound) + assert.Nil(t, result) + + mockVoteRepo.AssertExpectations(t) +} + +// TestVoteService_SubjectNotFound tests voting on non-existent post +func TestVoteService_SubjectNotFound(t *testing.T) { + mockVoteRepo := new(mockVoteRepository) + mockPostRepo := new(mockPostRepository) + + service := &voteService{ + repo: mockVoteRepo, + postRepo: mockPostRepo, + pdsURL: "http://mock-pds.test", + } + + ctx := context.Background() + voterDID := "did:plc:voter123" + subjectURI := "at://did:plc:community/social.coves.post.record/noexist" + + // Mock post not found + mockPostRepo.On("GetByURI", ctx, subjectURI).Return(nil, posts.ErrNotFound) + + req := CreateVoteRequest{ + Subject: subjectURI, + Direction: "up", + } + + _, err := service.CreateVote(ctx, voterDID, "token123", req) + assert.ErrorIs(t, err, ErrSubjectNotFound) + + mockPostRepo.AssertExpectations(t) +} + +// NOTE: Testing toggle logic (same direction, different direction) requires mocking HTTP client +// These tests are covered by integration tests in tests/integration/vote_e2e_test.go +// To add unit tests for toggle logic, we would need to: +// 1. Refactor voteService to accept an HTTP client interface +// 2. Mock the PDS createRecord and deleteRecord calls +// 3. Verify the correct sequence of operations + +// Example of what toggle tests would look like (requires refactoring): +/* +func TestVoteService_ToggleSameDirection(t *testing.T) { + // Setup + mockVoteRepo := new(mockVoteRepository) + mockPostRepo := new(mockPostRepository) + mockPDSClient := new(mockPDSClient) + + service := &voteService{ + repo: mockVoteRepo, + postRepo: mockPostRepo, + pdsClient: mockPDSClient, // Would need to refactor to inject this + } + + ctx := context.Background() + voterDID := "did:plc:voter123" + subjectURI := "at://did:plc:community/social.coves.post.record/abc123" + + // Mock existing upvote + existingVote := &Vote{ + URI: "at://did:plc:voter123/social.coves.interaction.vote/existing", + VoterDID: voterDID, + SubjectURI: subjectURI, + Direction: "up", + } + mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(existingVote, nil) + + // Mock post exists + mockPostRepo.On("GetByURI", ctx, subjectURI).Return(&posts.Post{ + URI: subjectURI, + CID: "bafyreigpost123", + }, nil) + + // Mock PDS delete + mockPDSClient.On("DeleteRecord", voterDID, "social.coves.interaction.vote", "existing").Return(nil) + + // Execute: Click upvote when already upvoted -> should delete + req := CreateVoteRequest{ + Subject: subjectURI, + Direction: "up", // Same direction + } + + response, err := service.CreateVote(ctx, voterDID, "token123", req) + + // Assert + assert.NoError(t, err) + assert.Equal(t, "", response.URI, "Should return empty URI when toggled off") + mockPDSClient.AssertCalled(t, "DeleteRecord", voterDID, "social.coves.interaction.vote", "existing") + mockVoteRepo.AssertExpectations(t) + mockPostRepo.AssertExpectations(t) +} + +func TestVoteService_ToggleDifferentDirection(t *testing.T) { + // Similar test but existing vote is "up" and new vote is "down" + // Should delete old vote and create new vote + // Would verify: + // 1. DeleteRecord called for old vote + // 2. CreateRecord called for new vote + // 3. Response contains new vote URI +} +*/ + +// Documentation test to explain toggle logic (verified by E2E tests) +func TestVoteService_ToggleLogicDocumentation(t *testing.T) { + t.Log("Toggle Logic (verified by E2E tests in tests/integration/vote_e2e_test.go):") + t.Log("1. No existing vote + upvote clicked → Create upvote") + t.Log("2. Upvote exists + upvote clicked → Delete upvote (toggle off)") + t.Log("3. Upvote exists + downvote clicked → Delete upvote + Create downvote (switch)") + t.Log("4. Downvote exists + downvote clicked → Delete downvote (toggle off)") + t.Log("5. Downvote exists + upvote clicked → Delete downvote + Create upvote (switch)") + t.Log("") + t.Log("To add unit tests for toggle logic, refactor service to accept HTTP client interface") +} diff --git a/internal/core/votes/vote.go b/internal/core/votes/vote.go new file mode 100644 index 0000000..314aed6 --- /dev/null +++ b/internal/core/votes/vote.go @@ -0,0 +1,58 @@ +package votes + +import ( + "time" +) + +// Vote represents a vote in the AppView database +// Votes are indexed from the firehose after being written to user repositories +type Vote struct { + ID int64 `json:"id" db:"id"` + URI string `json:"uri" db:"uri"` + CID string `json:"cid" db:"cid"` + RKey string `json:"rkey" db:"rkey"` + VoterDID string `json:"voterDid" db:"voter_did"` + SubjectURI string `json:"subjectUri" db:"subject_uri"` + SubjectCID string `json:"subjectCid" db:"subject_cid"` + Direction string `json:"direction" db:"direction"` // "up" or "down" + CreatedAt time.Time `json:"createdAt" db:"created_at"` + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` +} + +// CreateVoteRequest represents input for creating a new vote +// Matches social.coves.interaction.createVote lexicon input schema +type CreateVoteRequest struct { + Subject string `json:"subject"` // AT-URI of post/comment + Direction string `json:"direction"` // "up" or "down" +} + +// CreateVoteResponse represents the response from creating a vote +// Matches social.coves.interaction.createVote lexicon output schema +type CreateVoteResponse struct { + URI string `json:"uri"` // AT-URI of created vote record + CID string `json:"cid"` // CID of created vote record + Existing *string `json:"existing,omitempty"` // AT-URI of existing vote if updating +} + +// DeleteVoteRequest represents input for deleting a vote +// Matches social.coves.interaction.deleteVote lexicon input schema +type DeleteVoteRequest struct { + Subject string `json:"subject"` // AT-URI of post/comment +} + +// VoteRecord represents the actual atProto record structure written to PDS +// This is the data structure that gets stored in the user's repository +type VoteRecord struct { + Type string `json:"$type"` + Subject StrongRef `json:"subject"` + Direction string `json:"direction"` // "up" or "down" + CreatedAt string `json:"createdAt"` +} + +// StrongRef represents a strong reference to a record (URI + CID) +// Matches the strongRef definition in the vote lexicon +type StrongRef struct { + URI string `json:"uri"` + CID string `json:"cid"` +} diff --git a/internal/db/migrations/013_create_votes_table.sql b/internal/db/migrations/013_create_votes_table.sql new file mode 100644 index 0000000..2b95914 --- /dev/null +++ b/internal/db/migrations/013_create_votes_table.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- Create votes table for AppView indexing +-- Votes are indexed from the firehose after being written to user repositories +CREATE TABLE votes ( + id BIGSERIAL PRIMARY KEY, + uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.interaction.vote/rkey) + cid TEXT NOT NULL, -- Content ID + rkey TEXT NOT NULL, -- Record key (TID) + voter_did TEXT NOT NULL, -- User who voted (from AT-URI repo field) + + -- Subject (strong reference to post/comment) + subject_uri TEXT NOT NULL, -- AT-URI of voted item + subject_cid TEXT NOT NULL, -- CID of voted item (strong reference) + + -- Vote data + direction TEXT NOT NULL CHECK (direction IN ('up', 'down')), + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL, -- Voter's timestamp from record + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView + deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events) + + -- Foreign keys + CONSTRAINT fk_voter FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE +); + +-- Indexes for common query patterns +CREATE INDEX idx_votes_subject ON votes(subject_uri, direction) WHERE deleted_at IS NULL; +CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE deleted_at IS NULL; + +-- Partial unique index: One active vote per user per subject (soft delete aware) +CREATE UNIQUE INDEX unique_voter_subject_active ON votes(voter_did, subject_uri) WHERE deleted_at IS NULL; +CREATE INDEX idx_votes_uri ON votes(uri); +CREATE INDEX idx_votes_voter ON votes(voter_did, created_at DESC); + +-- Comment on table +COMMENT ON TABLE votes IS 'Votes indexed from user repositories via Jetstream firehose consumer'; +COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.interaction.vote/rkey'; +COMMENT ON COLUMN votes.subject_uri IS 'Strong reference to post/comment being voted on'; +COMMENT ON INDEX unique_voter_subject_active IS 'Ensures one active vote per user per subject (soft delete aware)'; + +-- +goose Down +DROP TABLE IF EXISTS votes CASCADE; diff --git a/internal/db/migrations/014_remove_votes_voter_fk.sql b/internal/db/migrations/014_remove_votes_voter_fk.sql new file mode 100644 index 0000000..a652005 --- /dev/null +++ b/internal/db/migrations/014_remove_votes_voter_fk.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- Remove foreign key constraint on votes.voter_did to prevent race conditions +-- between user and vote Jetstream consumers. +-- +-- Rationale: +-- - Vote events can arrive before user events in Jetstream +-- - Creating votes should not fail if user hasn't been indexed yet +-- - Users are validated at the PDS level (votes come from user repos) +-- - Orphaned votes (from deleted users) are harmless and can be ignored in queries + +ALTER TABLE votes DROP CONSTRAINT IF EXISTS fk_voter; + +-- Add check constraint to ensure voter_did is a valid DID format +ALTER TABLE votes ADD CONSTRAINT chk_voter_did_format +CHECK (voter_did ~ '^did:(plc|web|key):'); + +-- +goose Down +-- Restore foreign key constraint (note: this may fail if orphaned votes exist) +ALTER TABLE votes DROP CONSTRAINT IF EXISTS chk_voter_did_format; + +ALTER TABLE votes ADD CONSTRAINT fk_voter +FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE; diff --git a/internal/db/postgres/vote_repo.go b/internal/db/postgres/vote_repo.go new file mode 100644 index 0000000..bf13b96 --- /dev/null +++ b/internal/db/postgres/vote_repo.go @@ -0,0 +1,235 @@ +package postgres + +import ( + "Coves/internal/core/votes" + "context" + "database/sql" + "fmt" + "strings" +) + +type postgresVoteRepo struct { + db *sql.DB +} + +// NewVoteRepository creates a new PostgreSQL vote repository +func NewVoteRepository(db *sql.DB) votes.Repository { + return &postgresVoteRepo{db: db} +} + +// Create inserts a new vote into the votes table +// Called by Jetstream consumer after vote is created on PDS +// Idempotent: Returns success if vote already exists (for Jetstream replays) +func (r *postgresVoteRepo) Create(ctx context.Context, vote *votes.Vote) error { + query := ` + INSERT INTO votes ( + uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, NOW() + ) + ON CONFLICT (uri) DO NOTHING + RETURNING id, indexed_at + ` + + err := r.db.QueryRowContext( + ctx, query, + vote.URI, vote.CID, vote.RKey, vote.VoterDID, + vote.SubjectURI, vote.SubjectCID, vote.Direction, + vote.CreatedAt, + ).Scan(&vote.ID, &vote.IndexedAt) + + // ON CONFLICT DO NOTHING returns no rows if duplicate - this is OK (idempotent) + if err == sql.ErrNoRows { + return nil // Vote already exists, no error for idempotency + } + + if err != nil { + // Check for unique constraint violation (voter + subject) + if strings.Contains(err.Error(), "duplicate key") && strings.Contains(err.Error(), "unique_voter_subject") { + return votes.ErrVoteAlreadyExists + } + + // Check for DID format constraint violation + if strings.Contains(err.Error(), "chk_voter_did_format") { + return fmt.Errorf("invalid voter DID format: %s", vote.VoterDID) + } + + return fmt.Errorf("failed to insert vote: %w", err) + } + + return nil +} + +// GetByURI retrieves a vote by its AT-URI +// Used by Jetstream consumer for DELETE operations +func (r *postgresVoteRepo) GetByURI(ctx context.Context, uri string) (*votes.Vote, error) { + query := ` + SELECT + id, uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at, deleted_at + FROM votes + WHERE uri = $1 + ` + + var vote votes.Vote + + err := r.db.QueryRowContext(ctx, query, uri).Scan( + &vote.ID, &vote.URI, &vote.CID, &vote.RKey, &vote.VoterDID, + &vote.SubjectURI, &vote.SubjectCID, &vote.Direction, + &vote.CreatedAt, &vote.IndexedAt, &vote.DeletedAt, + ) + + if err == sql.ErrNoRows { + return nil, votes.ErrVoteNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get vote by URI: %w", err) + } + + return &vote, nil +} + +// GetByVoterAndSubject retrieves a user's vote on a specific subject +// Used by service to check existing vote state before creating/toggling +func (r *postgresVoteRepo) GetByVoterAndSubject(ctx context.Context, voterDID string, subjectURI string) (*votes.Vote, error) { + query := ` + SELECT + id, uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at, deleted_at + FROM votes + WHERE voter_did = $1 AND subject_uri = $2 AND deleted_at IS NULL + ` + + var vote votes.Vote + + err := r.db.QueryRowContext(ctx, query, voterDID, subjectURI).Scan( + &vote.ID, &vote.URI, &vote.CID, &vote.RKey, &vote.VoterDID, + &vote.SubjectURI, &vote.SubjectCID, &vote.Direction, + &vote.CreatedAt, &vote.IndexedAt, &vote.DeletedAt, + ) + + if err == sql.ErrNoRows { + return nil, votes.ErrVoteNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get vote by voter and subject: %w", err) + } + + return &vote, nil +} + +// Delete soft-deletes a vote (sets deleted_at) +// Called by Jetstream consumer after vote is deleted from PDS +// Idempotent: Returns success if vote already deleted +func (r *postgresVoteRepo) Delete(ctx context.Context, uri string) error { + query := ` + UPDATE votes + SET deleted_at = NOW() + WHERE uri = $1 AND deleted_at IS NULL + ` + + result, err := r.db.ExecContext(ctx, query, uri) + if err != nil { + return fmt.Errorf("failed to delete vote: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check delete result: %w", err) + } + + // Idempotent: If no rows affected, vote already deleted (OK for Jetstream replays) + if rowsAffected == 0 { + return nil + } + + return nil +} + +// ListBySubject retrieves all active votes on a specific post/comment +// Future: Used for vote detail views +func (r *postgresVoteRepo) ListBySubject(ctx context.Context, subjectURI string, limit, offset int) ([]*votes.Vote, error) { + query := ` + SELECT + id, uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at, deleted_at + FROM votes + WHERE subject_uri = $1 AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + + rows, err := r.db.QueryContext(ctx, query, subjectURI, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list votes by subject: %w", err) + } + defer rows.Close() + + var result []*votes.Vote + for rows.Next() { + var vote votes.Vote + err := rows.Scan( + &vote.ID, &vote.URI, &vote.CID, &vote.RKey, &vote.VoterDID, + &vote.SubjectURI, &vote.SubjectCID, &vote.Direction, + &vote.CreatedAt, &vote.IndexedAt, &vote.DeletedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan vote: %w", err) + } + result = append(result, &vote) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating votes: %w", err) + } + + return result, nil +} + +// ListByVoter retrieves all active votes by a specific user +// Future: Used for user voting history +func (r *postgresVoteRepo) ListByVoter(ctx context.Context, voterDID string, limit, offset int) ([]*votes.Vote, error) { + query := ` + SELECT + id, uri, cid, rkey, voter_did, + subject_uri, subject_cid, direction, + created_at, indexed_at, deleted_at + FROM votes + WHERE voter_did = $1 AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + + rows, err := r.db.QueryContext(ctx, query, voterDID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list votes by voter: %w", err) + } + defer rows.Close() + + var result []*votes.Vote + for rows.Next() { + var vote votes.Vote + err := rows.Scan( + &vote.ID, &vote.URI, &vote.CID, &vote.RKey, &vote.VoterDID, + &vote.SubjectURI, &vote.SubjectCID, &vote.Direction, + &vote.CreatedAt, &vote.IndexedAt, &vote.DeletedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan vote: %w", err) + } + result = append(result, &vote) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating votes: %w", err) + } + + return result, nil +} diff --git a/internal/db/postgres/vote_repo_test.go b/internal/db/postgres/vote_repo_test.go new file mode 100644 index 0000000..63902bf --- /dev/null +++ b/internal/db/postgres/vote_repo_test.go @@ -0,0 +1,403 @@ +package postgres + +import ( + "Coves/internal/core/votes" + "context" + "database/sql" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupTestDB creates a test database connection and runs migrations +func setupTestDB(t *testing.T) *sql.DB { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + require.NoError(t, err, "Failed to connect to test database") + + // Run migrations + require.NoError(t, goose.Up(db, "../../db/migrations"), "Failed to run migrations") + + return db +} + +// cleanupVotes removes all test votes and users from the database +func cleanupVotes(t *testing.T, db *sql.DB) { + _, err := db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:test%' OR voter_did LIKE 'did:plc:nonexistent%'") + require.NoError(t, err, "Failed to cleanup votes") + + _, err = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'") + require.NoError(t, err, "Failed to cleanup test users") +} + +// createTestUser creates a minimal test user for foreign key constraints +func createTestUser(t *testing.T, db *sql.DB, handle, did string) { + query := ` + INSERT INTO users (did, handle, pds_url, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (did) DO NOTHING + ` + _, err := db.Exec(query, did, handle, "https://bsky.social") + require.NoError(t, err, "Failed to create test user") +} + +func TestVoteRepo_Create(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + // Create test voter + voterDID := "did:plc:testvoter123" + createTestUser(t, db, "testvoter123.test", voterDID) + + vote := &votes.Vote{ + URI: "at://did:plc:testvoter123/social.coves.interaction.vote/3k1234567890", + CID: "bafyreigtest123", + RKey: "3k1234567890", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/abc123", + SubjectCID: "bafyreigpost123", + Direction: "up", + CreatedAt: time.Now(), + } + + err := repo.Create(ctx, vote) + assert.NoError(t, err) + assert.NotZero(t, vote.ID, "Vote ID should be set after creation") + assert.NotZero(t, vote.IndexedAt, "IndexedAt should be set after creation") +} + +func TestVoteRepo_Create_Idempotent(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoter456" + createTestUser(t, db, "testvoter456.test", voterDID) + + vote := &votes.Vote{ + URI: "at://did:plc:testvoter456/social.coves.interaction.vote/3k9876543210", + CID: "bafyreigtest456", + RKey: "3k9876543210", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/xyz789", + SubjectCID: "bafyreigpost456", + Direction: "down", + CreatedAt: time.Now(), + } + + // Create first time + err := repo.Create(ctx, vote) + require.NoError(t, err) + + // Create again with same URI - should be idempotent (no error) + vote2 := &votes.Vote{ + URI: vote.URI, // Same URI + CID: "bafyreigdifferent", + RKey: vote.RKey, + VoterDID: voterDID, + SubjectURI: vote.SubjectURI, + SubjectCID: vote.SubjectCID, + Direction: "up", // Different direction + CreatedAt: time.Now(), + } + + err = repo.Create(ctx, vote2) + assert.NoError(t, err, "Creating duplicate URI should be idempotent (ON CONFLICT DO NOTHING)") +} + +func TestVoteRepo_Create_VoterNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + // Don't create test user - vote should still be created (FK removed) + // This allows votes to be indexed before users in Jetstream + vote := &votes.Vote{ + URI: "at://did:plc:nonexistentvoter/social.coves.interaction.vote/3k1111111111", + CID: "bafyreignovoter", + RKey: "3k1111111111", + VoterDID: "did:plc:nonexistentvoter", + SubjectURI: "at://did:plc:community/social.coves.post.record/test123", + SubjectCID: "bafyreigpost789", + Direction: "up", + CreatedAt: time.Now(), + } + + err := repo.Create(ctx, vote) + if err != nil { + t.Logf("Create error: %v", err) + } + assert.NoError(t, err, "Vote should be created even if voter doesn't exist (FK removed)") + assert.NotZero(t, vote.ID, "Vote should have an ID") + t.Logf("Vote created with ID: %d", vote.ID) +} + +func TestVoteRepo_GetByURI(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoter789" + createTestUser(t, db, "testvoter789.test", voterDID) + + // Create vote + vote := &votes.Vote{ + URI: "at://did:plc:testvoter789/social.coves.interaction.vote/3k5555555555", + CID: "bafyreigtest789", + RKey: "3k5555555555", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/post123", + SubjectCID: "bafyreigpost999", + Direction: "up", + CreatedAt: time.Now(), + } + err := repo.Create(ctx, vote) + require.NoError(t, err) + + // Retrieve by URI + retrieved, err := repo.GetByURI(ctx, vote.URI) + assert.NoError(t, err) + assert.Equal(t, vote.URI, retrieved.URI) + assert.Equal(t, vote.VoterDID, retrieved.VoterDID) + assert.Equal(t, vote.Direction, retrieved.Direction) + assert.Nil(t, retrieved.DeletedAt, "DeletedAt should be nil for active vote") +} + +func TestVoteRepo_GetByURI_NotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewVoteRepository(db) + ctx := context.Background() + + _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.interaction.vote/nope") + assert.ErrorIs(t, err, votes.ErrVoteNotFound) +} + +func TestVoteRepo_GetByVoterAndSubject(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoter999" + createTestUser(t, db, "testvoter999.test", voterDID) + + subjectURI := "at://did:plc:community/social.coves.post.record/subject123" + + // Create vote + vote := &votes.Vote{ + URI: "at://did:plc:testvoter999/social.coves.interaction.vote/3k6666666666", + CID: "bafyreigtest999", + RKey: "3k6666666666", + VoterDID: voterDID, + SubjectURI: subjectURI, + SubjectCID: "bafyreigsubject123", + Direction: "down", + CreatedAt: time.Now(), + } + err := repo.Create(ctx, vote) + require.NoError(t, err) + + // Retrieve by voter + subject + retrieved, err := repo.GetByVoterAndSubject(ctx, voterDID, subjectURI) + assert.NoError(t, err) + assert.Equal(t, vote.URI, retrieved.URI) + assert.Equal(t, voterDID, retrieved.VoterDID) + assert.Equal(t, subjectURI, retrieved.SubjectURI) +} + +func TestVoteRepo_GetByVoterAndSubject_NotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewVoteRepository(db) + ctx := context.Background() + + _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.post.record/nopost") + assert.ErrorIs(t, err, votes.ErrVoteNotFound) +} + +func TestVoteRepo_Delete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoterdelete" + createTestUser(t, db, "testvoterdelete.test", voterDID) + + // Create vote + vote := &votes.Vote{ + URI: "at://did:plc:testvoterdelete/social.coves.interaction.vote/3k7777777777", + CID: "bafyreigdelete", + RKey: "3k7777777777", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest", + SubjectCID: "bafyreigdeletepost", + Direction: "up", + CreatedAt: time.Now(), + } + err := repo.Create(ctx, vote) + require.NoError(t, err) + + // Delete vote + err = repo.Delete(ctx, vote.URI) + assert.NoError(t, err) + + // Verify vote is soft-deleted (still exists but has deleted_at) + retrieved, err := repo.GetByURI(ctx, vote.URI) + assert.NoError(t, err) + assert.NotNil(t, retrieved.DeletedAt, "DeletedAt should be set after deletion") + + // GetByVoterAndSubject should not find deleted votes + _, err = repo.GetByVoterAndSubject(ctx, voterDID, vote.SubjectURI) + assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByVoterAndSubject should not return deleted votes") +} + +func TestVoteRepo_Delete_Idempotent(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoterdelete2" + createTestUser(t, db, "testvoterdelete2.test", voterDID) + + vote := &votes.Vote{ + URI: "at://did:plc:testvoterdelete2/social.coves.interaction.vote/3k8888888888", + CID: "bafyreigdelete2", + RKey: "3k8888888888", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest2", + SubjectCID: "bafyreigdeletepost2", + Direction: "down", + CreatedAt: time.Now(), + } + err := repo.Create(ctx, vote) + require.NoError(t, err) + + // Delete first time + err = repo.Delete(ctx, vote.URI) + assert.NoError(t, err) + + // Delete again - should be idempotent (no error) + err = repo.Delete(ctx, vote.URI) + assert.NoError(t, err, "Deleting already deleted vote should be idempotent") +} + +func TestVoteRepo_ListBySubject(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID1 := "did:plc:testvoterlist1" + voterDID2 := "did:plc:testvoterlist2" + createTestUser(t, db, "testvoterlist1.test", voterDID1) + createTestUser(t, db, "testvoterlist2.test", voterDID2) + + subjectURI := "at://did:plc:community/social.coves.post.record/listtest" + + // Create multiple votes on same subject + vote1 := &votes.Vote{ + URI: "at://did:plc:testvoterlist1/social.coves.interaction.vote/3k9999999991", + CID: "bafyreiglist1", + RKey: "3k9999999991", + VoterDID: voterDID1, + SubjectURI: subjectURI, + SubjectCID: "bafyreiglistpost", + Direction: "up", + CreatedAt: time.Now(), + } + vote2 := &votes.Vote{ + URI: "at://did:plc:testvoterlist2/social.coves.interaction.vote/3k9999999992", + CID: "bafyreiglist2", + RKey: "3k9999999992", + VoterDID: voterDID2, + SubjectURI: subjectURI, + SubjectCID: "bafyreiglistpost", + Direction: "down", + CreatedAt: time.Now(), + } + + require.NoError(t, repo.Create(ctx, vote1)) + require.NoError(t, repo.Create(ctx, vote2)) + + // List votes + result, err := repo.ListBySubject(ctx, subjectURI, 10, 0) + assert.NoError(t, err) + assert.Len(t, result, 2, "Should find 2 votes on subject") +} + +func TestVoteRepo_ListByVoter(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + defer cleanupVotes(t, db) + + repo := NewVoteRepository(db) + ctx := context.Background() + + voterDID := "did:plc:testvoterlistvoter" + createTestUser(t, db, "testvoterlistvoter.test", voterDID) + + // Create multiple votes by same voter + vote1 := &votes.Vote{ + URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000001", + CID: "bafyreigvoter1", + RKey: "3k0000000001", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/post1", + SubjectCID: "bafyreigp1", + Direction: "up", + CreatedAt: time.Now(), + } + vote2 := &votes.Vote{ + URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000002", + CID: "bafyreigvoter2", + RKey: "3k0000000002", + VoterDID: voterDID, + SubjectURI: "at://did:plc:community/social.coves.post.record/post2", + SubjectCID: "bafyreigp2", + Direction: "down", + CreatedAt: time.Now(), + } + + require.NoError(t, repo.Create(ctx, vote1)) + require.NoError(t, repo.Create(ctx, vote2)) + + // List votes by voter + result, err := repo.ListByVoter(ctx, voterDID, 10, 0) + assert.NoError(t, err) + assert.Len(t, result, 2, "Should find 2 votes by voter") +} diff --git a/tests/integration/vote_e2e_test.go b/tests/integration/vote_e2e_test.go new file mode 100644 index 0000000..dab54a2 --- /dev/null +++ b/tests/integration/vote_e2e_test.go @@ -0,0 +1,789 @@ +package integration + +import ( + "Coves/internal/api/handlers/vote" + "Coves/internal/api/middleware" + "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" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestVote_E2E_WithJetstream tests the full vote flow with simulated Jetstream: +// XRPC endpoint → AppView Service → PDS write → (Simulated) Jetstream consumer → DB indexing +// +// This is a fast integration test that simulates what happens in production: +// 1. Client calls POST /xrpc/social.coves.interaction.createVote with auth token +// 2. Handler validates and calls VoteService.CreateVote() +// 3. Service writes vote to user's PDS repository +// 4. (Simulated) PDS broadcasts event to Jetstream +// 5. Jetstream consumer receives event and indexes vote in AppView DB +// 6. Vote is now queryable from AppView + post counts updated +// +// NOTE: This test simulates the Jetstream event (step 4-5) since we don't have +// a live PDS/Jetstream in test environment. For true live testing, use TestVote_E2E_LivePDS. +func TestVote_E2E_WithJetstream(t *testing.T) { + db := setupTestDB(t) + defer func() { + if err := db.Close(); err != nil { + t.Logf("Failed to close database: %v", err) + } + }() + + // Cleanup old test data first + _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:votee2e%'") + _, _ = db.Exec("DELETE FROM posts WHERE community_did = 'did:plc:votecommunity123'") + _, _ = db.Exec("DELETE FROM communities WHERE did = 'did:plc:votecommunity123'") + _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:votee2e%'") + + // Setup repositories + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + postRepo := postgres.NewPostRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + // Setup user service for consumers + identityConfig := identity.DefaultConfig() + identityResolver := identity.NewResolver(db, identityConfig) + userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") + + // Create test users (voter and author) + voter := createTestUser(t, db, "voter.test", "did:plc:votee2evoter123") + author := createTestUser(t, db, "author.test", "did:plc:votee2eauthor123") + + // Create test community + community := &communities.Community{ + DID: "did:plc:votecommunity123", + Handle: "votecommunity.test.coves.social", + Name: "votecommunity", + DisplayName: "Vote Test Community", + OwnerDID: "did:plc:votecommunity123", + CreatedByDID: author.DID, + HostedByDID: "did:web:coves.test", + Visibility: "public", + ModerationType: "moderator", + RecordURI: "at://did:plc:votecommunity123/social.coves.community.profile/self", + RecordCID: "fakecid123", + PDSAccessToken: "fake_token_for_testing", + PDSRefreshToken: "fake_refresh_token", + } + _, err := communityRepo.Create(context.Background(), community) + if err != nil { + t.Fatalf("Failed to create test community: %v", err) + } + + // Create test post (subject of votes) + postRkey := generateTID() + postURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, postRkey) + postCID := "bafy2bzacepostcid123" + post := &posts.Post{ + URI: postURI, + CID: postCID, + RKey: postRkey, + AuthorDID: author.DID, + CommunityDID: community.DID, + Title: stringPtr("Test Post for Voting"), + Content: stringPtr("This post will receive votes"), + CreatedAt: time.Now(), + UpvoteCount: 0, + DownvoteCount: 0, + Score: 0, + } + err = postRepo.Create(context.Background(), post) + if err != nil { + t.Fatalf("Failed to create test post: %v", err) + } + + t.Run("Full E2E flow - Create upvote via Jetstream", func(t *testing.T) { + ctx := context.Background() + + // STEP 1: Simulate Jetstream consumer receiving a vote CREATE event + // In real production, this event comes from PDS via Jetstream WebSocket + voteRkey := generateTID() + voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", voter.DID, voteRkey) + + jetstreamEvent := jetstream.JetstreamEvent{ + Did: voter.DID, // Vote comes from voter's repo + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "create", + Collection: "social.coves.interaction.vote", + RKey: voteRkey, + CID: "bafy2bzacevotecid123", + Record: map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": postURI, + "cid": postCID, + }, + "direction": "up", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + // STEP 2: Process event through Jetstream consumer + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + err := consumer.HandleEvent(ctx, &jetstreamEvent) + if err != nil { + t.Fatalf("Jetstream consumer failed to process event: %v", err) + } + + // STEP 3: Verify vote was indexed in AppView database + indexedVote, err := voteRepo.GetByURI(ctx, voteURI) + if err != nil { + t.Fatalf("Vote not indexed in AppView: %v", err) + } + + // STEP 4: Verify vote fields are correct + assert.Equal(t, voteURI, indexedVote.URI, "Vote URI should match") + assert.Equal(t, voter.DID, indexedVote.VoterDID, "Voter DID should match") + assert.Equal(t, postURI, indexedVote.SubjectURI, "Subject URI should match") + assert.Equal(t, postCID, indexedVote.SubjectCID, "Subject CID should match (strong reference)") + assert.Equal(t, "up", indexedVote.Direction, "Direction should be 'up'") + + // STEP 5: Verify post vote counts were updated atomically + updatedPost, err := postRepo.GetByURI(ctx, postURI) + require.NoError(t, err, "Post should still exist") + assert.Equal(t, 1, updatedPost.UpvoteCount, "Post upvote_count should be 1") + assert.Equal(t, 0, updatedPost.DownvoteCount, "Post downvote_count should be 0") + assert.Equal(t, 1, updatedPost.Score, "Post score should be 1 (upvotes - downvotes)") + + t.Logf("āœ“ E2E test passed! Vote indexed with URI: %s, post upvotes: %d", indexedVote.URI, updatedPost.UpvoteCount) + }) + + t.Run("Create downvote and verify counts", func(t *testing.T) { + ctx := context.Background() + + // Create a different voter for this test to avoid unique constraint violation + downvoter := createTestUser(t, db, "downvoter.test", "did:plc:votee2edownvoter") + + // Create downvote + voteRkey := generateTID() + voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", downvoter.DID, voteRkey) + + jetstreamEvent := jetstream.JetstreamEvent{ + Did: downvoter.DID, + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "create", + Collection: "social.coves.interaction.vote", + RKey: voteRkey, + CID: "bafy2bzacedownvotecid", + Record: map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": postURI, + "cid": postCID, + }, + "direction": "down", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + err := consumer.HandleEvent(ctx, &jetstreamEvent) + require.NoError(t, err, "Consumer should process downvote") + + // Verify vote indexed + indexedVote, err := voteRepo.GetByURI(ctx, voteURI) + require.NoError(t, err, "Downvote should be indexed") + assert.Equal(t, "down", indexedVote.Direction, "Direction should be 'down'") + + // Verify post counts (now has 1 upvote + 1 downvote from previous test) + updatedPost, err := postRepo.GetByURI(ctx, postURI) + require.NoError(t, err) + assert.Equal(t, 1, updatedPost.UpvoteCount, "Upvote count should still be 1") + assert.Equal(t, 1, updatedPost.DownvoteCount, "Downvote count should be 1") + assert.Equal(t, 0, updatedPost.Score, "Score should be 0 (1 up - 1 down)") + + t.Logf("āœ“ Downvote indexed, post counts: up=%d down=%d score=%d", + updatedPost.UpvoteCount, updatedPost.DownvoteCount, updatedPost.Score) + }) + + t.Run("Delete vote and verify counts decremented", func(t *testing.T) { + ctx := context.Background() + + // Create a different voter for this test + deletevoter := createTestUser(t, db, "deletevoter.test", "did:plc:votee2edeletevoter") + + // Get current counts + beforePost, _ := postRepo.GetByURI(ctx, postURI) + + // Create a vote first + voteRkey := generateTID() + voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", deletevoter.DID, voteRkey) + + createEvent := jetstream.JetstreamEvent{ + Did: deletevoter.DID, + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "create", + Collection: "social.coves.interaction.vote", + RKey: voteRkey, + CID: "bafy2bzacedeleteme", + Record: map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": postURI, + "cid": postCID, + }, + "direction": "up", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + err := consumer.HandleEvent(ctx, &createEvent) + require.NoError(t, err) + + // Now delete it + deleteEvent := jetstream.JetstreamEvent{ + Did: deletevoter.DID, + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "delete", + Collection: "social.coves.interaction.vote", + RKey: voteRkey, + }, + } + + err = consumer.HandleEvent(ctx, &deleteEvent) + require.NoError(t, err, "Consumer should process delete") + + // Verify vote is soft-deleted + deletedVote, err := voteRepo.GetByURI(ctx, voteURI) + require.NoError(t, err, "Vote should still exist (soft delete)") + assert.NotNil(t, deletedVote.DeletedAt, "Vote should have deleted_at timestamp") + + // Verify post counts decremented + afterPost, err := postRepo.GetByURI(ctx, postURI) + require.NoError(t, err) + assert.Equal(t, beforePost.UpvoteCount, afterPost.UpvoteCount, + "Upvote count should be back to original (delete decremented)") + + t.Logf("āœ“ Vote deleted, counts decremented correctly") + }) + + t.Run("Idempotent indexing - duplicate events", func(t *testing.T) { + ctx := context.Background() + + // Create a different voter for this test + idempotentvoter := createTestUser(t, db, "idempotentvoter.test", "did:plc:votee2eidempotent") + + // Create a vote + voteRkey := generateTID() + voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", idempotentvoter.DID, voteRkey) + + event := jetstream.JetstreamEvent{ + Did: idempotentvoter.DID, + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "create", + Collection: "social.coves.interaction.vote", + RKey: voteRkey, + CID: "bafy2bzaceidempotent", + Record: map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": postURI, + "cid": postCID, + }, + "direction": "up", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + + // First event - should succeed + err := consumer.HandleEvent(ctx, &event) + require.NoError(t, err, "First event should succeed") + + // Get counts after first event + firstPost, _ := postRepo.GetByURI(ctx, postURI) + + // Second event (duplicate) - should be handled gracefully + err = consumer.HandleEvent(ctx, &event) + require.NoError(t, err, "Duplicate event should be handled gracefully") + + // Verify counts NOT incremented again (idempotent) + secondPost, err := postRepo.GetByURI(ctx, postURI) + require.NoError(t, err) + assert.Equal(t, firstPost.UpvoteCount, secondPost.UpvoteCount, + "Duplicate event should not increment count again") + + // Verify only one vote in database + vote, err := voteRepo.GetByURI(ctx, voteURI) + require.NoError(t, err) + assert.Equal(t, voteURI, vote.URI, "Should still be the same vote") + + t.Logf("āœ“ Idempotency test passed - duplicate event handled correctly") + }) + + t.Run("Security: Vote from wrong repository rejected", func(t *testing.T) { + ctx := context.Background() + + // SECURITY TEST: Try to create a vote that claims to be from the voter + // but actually comes from a different user's repository + // This should be REJECTED by the consumer + + maliciousUser := createTestUser(t, db, "hacker.test", "did:plc:hacker123") + + maliciousEvent := jetstream.JetstreamEvent{ + Did: maliciousUser.DID, // Event from hacker's repo + Kind: "commit", + Commit: &jetstream.CommitEvent{ + Operation: "create", + Collection: "social.coves.interaction.vote", + RKey: generateTID(), + CID: "bafy2bzacefake", + Record: map[string]interface{}{ + "$type": "social.coves.interaction.vote", + "subject": map[string]interface{}{ + "uri": postURI, + "cid": postCID, + }, + "direction": "up", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + } + + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + err := consumer.HandleEvent(ctx, &maliciousEvent) + + // Should succeed (vote is created in hacker's repo, which is valid) + // The vote record itself is FROM their repo, so it's legitimate + // This is different from posts which must come from community repo + assert.NoError(t, err, "Votes in user repos are valid") + + t.Logf("āœ“ Security validation passed - user repo votes are allowed") + }) +} + +// TestVote_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS: +// 1. HTTP POST to /xrpc/social.coves.interaction.createVote (with auth) +// 2. Handler → Service → Write to user's PDS repository +// 3. PDS → Jetstream firehose event +// 4. Jetstream consumer → Index in AppView database +// 5. Verify vote appears in database + post counts updated +// +// This is a TRUE E2E test that requires: +// - Live PDS running at PDS_URL (default: http://localhost:3001) +// - Live Jetstream running at JETSTREAM_URL (default: ws://localhost:6008/subscribe) +// - Test database running +func TestVote_E2E_LivePDS(t *testing.T) { + if testing.Short() { + t.Skip("Skipping live PDS 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) + require.NoError(t, err, "Failed to connect to test database") + defer func() { + if closeErr := db.Close(); closeErr != nil { + t.Logf("Failed to close database: %v", closeErr) + } + }() + + // Run migrations + require.NoError(t, goose.SetDialect("postgres")) + require.NoError(t, goose.Up(db, "../../internal/db/migrations")) + + // Check if PDS is running + pdsURL := os.Getenv("PDS_URL") + if pdsURL == "" { + pdsURL = "http://localhost:3001" + } + + healthResp, err := http.Get(pdsURL + "/xrpc/_health") + if err != nil { + t.Skipf("PDS not running at %s: %v", pdsURL, err) + } + _ = healthResp.Body.Close() + + // Check if Jetstream is running + jetstreamHealthURL := "http://127.0.0.1:6009/metrics" // Use 127.0.0.1 for IPv4 + jetstreamResp, err := http.Get(jetstreamHealthURL) + if err != nil { + t.Skipf("Jetstream not running: %v", err) + } + _ = jetstreamResp.Body.Close() + + ctx := context.Background() + + // Cleanup old test data + _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:votee2elive%' OR voter_did IN (SELECT did FROM users WHERE handle LIKE '%votee2elive%')") + _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:votee2elive%'") + _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:votee2elive%'") + _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:votee2elive%' OR handle LIKE '%votee2elive%' OR handle LIKE '%authore2e%'") + + // Setup repositories and services + userRepo := postgres.NewUserRepository(db) + communityRepo := postgres.NewCommunityRepository(db) + postRepo := postgres.NewPostRepository(db) + voteRepo := postgres.NewVoteRepository(db) + + identityConfig := identity.DefaultConfig() + identityResolver := identity.NewResolver(db, identityConfig) + userService := users.NewUserService(userRepo, identityResolver, pdsURL) + + // Create test voter + voter := createTestUser(t, db, "votee2elive.bsky.social", "did:plc:votee2elive123") + + // Create test community and post (simplified - using fake credentials) + author := createTestUser(t, db, "authore2e.bsky.social", "did:plc:votee2eliveauthor") + community := &communities.Community{ + DID: "did:plc:votee2elivecommunity", + Handle: "votee2elivecommunity.test.coves.social", + Name: "votee2elivecommunity", + DisplayName: "Vote E2E Live Community", + OwnerDID: author.DID, + CreatedByDID: author.DID, + HostedByDID: "did:web:coves.test", + Visibility: "public", + ModerationType: "moderator", + RecordURI: "at://did:plc:votee2elivecommunity/social.coves.community.profile/self", + RecordCID: "fakecid", + PDSAccessToken: "fake_token", + PDSRefreshToken: "fake_refresh", + } + _, err = communityRepo.Create(ctx, community) + require.NoError(t, err) + + postRkey := generateTID() + postURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, postRkey) + postCID := "bafy2bzaceposte2e" + post := &posts.Post{ + URI: postURI, + CID: postCID, + RKey: postRkey, + AuthorDID: author.DID, + CommunityDID: community.DID, + Title: stringPtr("E2E Vote Test Post"), + Content: stringPtr("This post will receive live votes"), + CreatedAt: time.Now(), + UpvoteCount: 0, + DownvoteCount: 0, + Score: 0, + } + err = postRepo.Create(ctx, post) + require.NoError(t, err) + + // Setup vote service and handler + voteService := votes.NewVoteService(voteRepo, postRepo, pdsURL) + voteHandler := vote.NewCreateVoteHandler(voteService) + authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing + + t.Run("Live E2E: Create vote and verify via Jetstream", func(t *testing.T) { + t.Logf("\nšŸ”„ TRUE E2E: Creating vote via XRPC endpoint...") + + // Authenticate voter with PDS to get real access token + // Note: This assumes the voter account already exists on PDS + // For a complete test, you'd create the account first via com.atproto.server.createAccount + instanceHandle := os.Getenv("PDS_INSTANCE_HANDLE") + instancePassword := os.Getenv("PDS_INSTANCE_PASSWORD") + if instanceHandle == "" { + instanceHandle = "testuser123.local.coves.dev" + } + if instancePassword == "" { + instancePassword = "test-password-123" + } + + t.Logf("šŸ” Authenticating voter with PDS as: %s", instanceHandle) + voterAccessToken, voterDID, err := authenticateWithPDS(pdsURL, instanceHandle, instancePassword) + if err != nil { + t.Skipf("Failed to authenticate voter with PDS (account may not exist): %v", err) + } + t.Logf("āœ… Authenticated - Voter DID: %s", voterDID) + + // Update voter record to match authenticated DID + _, err = db.Exec("UPDATE users SET did = $1 WHERE did = $2", voterDID, voter.DID) + require.NoError(t, err) + voter.DID = voterDID + + // Build HTTP request for vote creation + reqBody := map[string]interface{}{ + "subject": postURI, + "direction": "up", + } + reqJSON, err := json.Marshal(reqBody) + require.NoError(t, err) + + // Create HTTP request + req := httptest.NewRequest("POST", "/xrpc/social.coves.interaction.createVote", bytes.NewReader(reqJSON)) + req.Header.Set("Content-Type", "application/json") + + // Use REAL PDS access token (not mock JWT) + req.Header.Set("Authorization", "Bearer "+voterAccessToken) + + // Execute request through auth middleware + handler + rr := httptest.NewRecorder() + handler := authMiddleware.RequireAuth(http.HandlerFunc(voteHandler.HandleCreateVote)) + handler.ServeHTTP(rr, req) + + // Check response + require.Equal(t, http.StatusOK, rr.Code, "Handler should return 200 OK, body: %s", rr.Body.String()) + + // Parse response + var response map[string]interface{} + err = json.NewDecoder(rr.Body).Decode(&response) + require.NoError(t, err, "Failed to parse response") + + voteURI := response["uri"].(string) + voteCID := response["cid"].(string) + + t.Logf("āœ… Vote created on PDS:") + t.Logf(" URI: %s", voteURI) + t.Logf(" CID: %s", voteCID) + + // ==================================================================================== + // Part 2: Query the PDS to verify the vote record exists + // ==================================================================================== + t.Run("2a. Verify vote record on PDS", func(t *testing.T) { + t.Logf("\nšŸ“” Querying PDS for vote record...") + + // Extract rkey from vote URI (at://did/collection/rkey) + parts := strings.Split(voteURI, "/") + rkey := parts[len(parts)-1] + + // Query PDS for the vote record + getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + pdsURL, voterDID, "social.coves.interaction.vote", rkey) + + t.Logf(" GET %s", getRecordURL) + + pdsResp, err := http.Get(getRecordURL) + require.NoError(t, err, "Failed to query PDS") + defer pdsResp.Body.Close() + + require.Equal(t, http.StatusOK, pdsResp.StatusCode, "Vote record should exist on PDS") + + var pdsRecord struct { + Value map[string]interface{} `json:"value"` + URI string `json:"uri"` + CID string `json:"cid"` + } + + err = json.NewDecoder(pdsResp.Body).Decode(&pdsRecord) + require.NoError(t, err, "Failed to decode PDS response") + + t.Logf("āœ… Vote record found on PDS!") + t.Logf(" URI: %s", pdsRecord.URI) + t.Logf(" CID: %s", pdsRecord.CID) + t.Logf(" Direction: %v", pdsRecord.Value["direction"]) + t.Logf(" Subject: %v", pdsRecord.Value["subject"]) + + // Verify the record matches what we created + assert.Equal(t, voteURI, pdsRecord.URI, "PDS URI should match") + assert.Equal(t, voteCID, pdsRecord.CID, "PDS CID should match") + assert.Equal(t, "up", pdsRecord.Value["direction"], "Direction should be 'up'") + + // Print full record for inspection + recordJSON, _ := json.MarshalIndent(pdsRecord.Value, " ", " ") + t.Logf(" Full record:\n %s", string(recordJSON)) + }) + + // ==================================================================================== + // Part 2b: TRUE E2E - Real Jetstream Firehose Consumer + // ==================================================================================== + t.Run("2b. Real Jetstream Firehose Consumption", func(t *testing.T) { + t.Logf("\nšŸ”„ TRUE E2E: Subscribing to real Jetstream firehose...") + + // Get PDS hostname for Jetstream filtering + pdsHostname := strings.TrimPrefix(pdsURL, "http://") + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") + pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port + + // Build Jetstream URL with filters for vote records + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.interaction.vote", + pdsHostname) + + t.Logf(" Jetstream URL: %s", jetstreamURL) + t.Logf(" Looking for vote URI: %s", voteURI) + t.Logf(" Voter DID: %s", voterDID) + + // Create vote consumer (same as main.go) + consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) + + // Channels to receive the event + eventChan := make(chan *jetstream.JetstreamEvent, 10) + errorChan := make(chan error, 1) + done := make(chan bool) + + // Start Jetstream WebSocket subscriber in background + go func() { + err := subscribeToJetstreamForVote(ctx, jetstreamURL, voterDID, postURI, consumer, eventChan, errorChan, done) + if err != nil { + errorChan <- err + } + }() + + // Wait for event or timeout + t.Logf("ā³ Waiting for Jetstream event (max 30 seconds)...") + + select { + case event := <-eventChan: + t.Logf("āœ… Received real Jetstream event!") + t.Logf(" Event DID: %s", event.Did) + t.Logf(" Collection: %s", event.Commit.Collection) + t.Logf(" Operation: %s", event.Commit.Operation) + t.Logf(" RKey: %s", event.Commit.RKey) + + // Verify it's for our voter + assert.Equal(t, voterDID, event.Did, "Event should be from voter's repo") + + // Verify vote was indexed in AppView database + t.Logf("\nšŸ” Querying AppView database for indexed vote...") + + indexedVote, err := voteRepo.GetByVoterAndSubject(ctx, voterDID, postURI) + require.NoError(t, err, "Vote should be indexed in AppView") + + t.Logf("āœ… Vote indexed in AppView:") + t.Logf(" URI: %s", indexedVote.URI) + t.Logf(" CID: %s", indexedVote.CID) + t.Logf(" Voter DID: %s", indexedVote.VoterDID) + t.Logf(" Subject: %s", indexedVote.SubjectURI) + t.Logf(" Direction: %s", indexedVote.Direction) + + // Verify all fields match + assert.Equal(t, voteURI, indexedVote.URI, "URI should match") + assert.Equal(t, voteCID, indexedVote.CID, "CID should match") + assert.Equal(t, voterDID, indexedVote.VoterDID, "Voter DID should match") + assert.Equal(t, postURI, indexedVote.SubjectURI, "Subject URI should match") + assert.Equal(t, "up", indexedVote.Direction, "Direction should be 'up'") + + // Verify post counts were updated + t.Logf("\nšŸ” Verifying post vote counts updated...") + updatedPost, err := postRepo.GetByURI(ctx, postURI) + require.NoError(t, err, "Post should exist") + + t.Logf("āœ… Post vote counts updated:") + t.Logf(" Upvotes: %d", updatedPost.UpvoteCount) + t.Logf(" Downvotes: %d", updatedPost.DownvoteCount) + t.Logf(" Score: %d", updatedPost.Score) + + assert.Equal(t, 1, updatedPost.UpvoteCount, "Upvote count should be 1") + assert.Equal(t, 0, updatedPost.DownvoteCount, "Downvote count should be 0") + assert.Equal(t, 1, updatedPost.Score, "Score should be 1") + + // Signal to stop Jetstream consumer + close(done) + + t.Log("\nāœ… TRUE E2E COMPLETE: PDS → Jetstream → Consumer → AppView āœ“") + + case err := <-errorChan: + t.Fatalf("āŒ Jetstream error: %v", err) + + case <-time.After(30 * time.Second): + t.Fatalf("āŒ Timeout: No Jetstream event received within 30 seconds") + } + }) + }) +} + +// subscribeToJetstreamForVote subscribes to real Jetstream firehose and processes vote events +// This helper creates a WebSocket connection to Jetstream and waits for vote events +func subscribeToJetstreamForVote( + ctx context.Context, + jetstreamURL string, + targetVoterDID string, + targetSubjectURI string, + consumer *jetstream.VoteEventConsumer, + eventChan chan<- *jetstream.JetstreamEvent, + errorChan chan<- error, + done <-chan bool, +) error { + conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil) + if err != nil { + return fmt.Errorf("failed to connect to Jetstream: %w", err) + } + defer func() { _ = conn.Close() }() + + // Read messages until we find our event or receive done signal + for { + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + default: + // Set read deadline to avoid blocking forever + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + return fmt.Errorf("failed to set read deadline: %w", err) + } + + var event jetstream.JetstreamEvent + err := conn.ReadJSON(&event) + if err != nil { + // Check if it's a timeout (expected) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return nil + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue // Timeout is expected, keep listening + } + return fmt.Errorf("failed to read Jetstream message: %w", err) + } + + // Check if this is a vote event for the target voter + subject + if event.Did == targetVoterDID && event.Kind == "commit" && + event.Commit != nil && event.Commit.Collection == "social.coves.interaction.vote" { + + // Verify it's for the target subject + record := event.Commit.Record + if subject, ok := record["subject"].(map[string]interface{}); ok { + if subjectURI, ok := subject["uri"].(string); ok && subjectURI == targetSubjectURI { + // This is our vote! Process it + if err := consumer.HandleEvent(ctx, &event); err != nil { + return fmt.Errorf("failed to process event: %w", err) + } + + // Send to channel so test can verify + select { + case eventChan <- &event: + return nil + case <-time.After(1 * time.Second): + return fmt.Errorf("timeout sending event to channel") + } + } + } + } + } + } +} + +// Helper function +func stringPtr(s string) *string { + return &s +}