A community based topic aggregation platform built on atproto

feat(api): add vote XRPC handlers with lexicon-compliant errors

Add HTTP handlers for vote XRPC endpoints:
- HandleCreateVote: POST /xrpc/social.coves.feed.vote.create
- HandleDeleteVote: POST /xrpc/social.coves.feed.vote.delete

Error handling matches lexicon exactly:
- VoteNotFound, SubjectNotFound, InvalidSubject, NotAuthorized
- Delete returns empty object {} per lexicon spec

Includes comprehensive unit tests for all handlers.

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

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

+115
internal/api/handlers/vote/create_vote.go
···
+
package vote
+
+
import (
+
"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,
+
}
+
}
+
+
// CreateVoteInput represents the request body for creating a vote
+
type CreateVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
Direction string `json:"direction"`
+
}
+
+
// CreateVoteOutput represents the response body for creating a vote
+
type CreateVoteOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreateVote creates a vote on a post or comment
+
// POST /xrpc/social.coves.vote.create
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." }, "direction": "up" }
+
// Response: { "uri": "at://...", "cid": "..." }
+
//
+
// Behavior:
+
// - If no vote exists: creates new vote with given direction
+
// - If vote exists with same direction: deletes vote (toggle off)
+
// - If vote exists with different direction: updates to new direction
+
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 input CreateVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
if input.Direction == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction is required")
+
return
+
}
+
+
// Validate direction
+
if input.Direction != "up" && input.Direction != "down" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction must be 'up' or 'down'")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create vote request
+
req := votes.CreateVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
Direction: input.Direction,
+
}
+
+
// Call service to create vote
+
response, err := h.service.CreateVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
output := CreateVoteOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+490
internal/api/handlers/vote/create_vote_test.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"bytes"
+
"context"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// mockVoteService implements votes.Service for testing
+
type mockVoteService struct {
+
createFunc func(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error)
+
deleteFunc func(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error
+
}
+
+
func (m *mockVoteService) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {
+
if m.createFunc != nil {
+
return m.createFunc(ctx, session, req)
+
}
+
return &votes.CreateVoteResponse{
+
URI: "at://did:plc:test123/social.coves.vote/abc123",
+
CID: "bafyvote123",
+
}, nil
+
}
+
+
func (m *mockVoteService) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error {
+
if m.deleteFunc != nil {
+
return m.deleteFunc(ctx, session, req)
+
}
+
return nil
+
}
+
+
func TestCreateVoteHandler_Success(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
Direction: "up",
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session into context (simulates auth middleware)
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check response
+
var response CreateVoteOutput
+
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.URI != "at://did:plc:test123/social.coves.vote/abc123" {
+
t.Errorf("Expected URI at://did:plc:test123/social.coves.vote/abc123, got %s", response.URI)
+
}
+
if response.CID != "bafyvote123" {
+
t.Errorf("Expected CID bafyvote123, got %s", response.CID)
+
}
+
}
+
+
func TestCreateVoteHandler_RequiresAuth(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
Direction: "up",
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request without auth context
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
// No OAuth session in context
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
+
func TestCreateVoteHandler_InvalidDirection(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
tests := []struct {
+
name string
+
direction string
+
}{
+
{"empty direction", ""},
+
{"invalid direction", "sideways"},
+
{"wrong case", "UP"},
+
{"typo", "upp"},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
Direction: tc.direction,
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "InvalidRequest" {
+
t.Errorf("Expected error InvalidRequest, got %s", errResp.Error)
+
}
+
})
+
}
+
}
+
+
func TestCreateVoteHandler_MissingFields(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
tests := []struct {
+
name string
+
subjectURI string
+
subjectCID string
+
direction string
+
expectedError string
+
}{
+
{
+
name: "missing subject URI",
+
subjectURI: "",
+
subjectCID: "bafypost123",
+
direction: "up",
+
expectedError: "subject.uri is required",
+
},
+
{
+
name: "missing subject CID",
+
subjectURI: "at://did:plc:author123/social.coves.post/xyz789",
+
subjectCID: "",
+
direction: "up",
+
expectedError: "subject.cid is required",
+
},
+
{
+
name: "missing direction",
+
subjectURI: "at://did:plc:author123/social.coves.post/xyz789",
+
subjectCID: "bafypost123",
+
direction: "",
+
expectedError: "direction is required",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: tc.subjectURI,
+
CID: tc.subjectCID,
+
},
+
Direction: tc.direction,
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Message != tc.expectedError {
+
t.Errorf("Expected message '%s', got '%s'", tc.expectedError, errResp.Message)
+
}
+
})
+
}
+
}
+
+
func TestCreateVoteHandler_InvalidJSON(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
// Create invalid JSON
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBufferString("{invalid json"))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "InvalidRequest" {
+
t.Errorf("Expected error InvalidRequest, got %s", errResp.Error)
+
}
+
}
+
+
func TestCreateVoteHandler_MethodNotAllowed(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
// Create GET request (should only accept POST)
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.vote.create", nil)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
}
+
+
func TestCreateVoteHandler_ServiceError(t *testing.T) {
+
tests := []struct {
+
name string
+
serviceError error
+
expectedStatus int
+
expectedError string
+
}{
+
{
+
name: "subject not found",
+
serviceError: votes.ErrSubjectNotFound,
+
expectedStatus: http.StatusNotFound,
+
expectedError: "SubjectNotFound", // Per lexicon: social.coves.feed.vote.create#SubjectNotFound
+
},
+
{
+
name: "invalid direction",
+
serviceError: votes.ErrInvalidDirection,
+
expectedStatus: http.StatusBadRequest,
+
expectedError: "InvalidRequest",
+
},
+
{
+
name: "invalid subject",
+
serviceError: votes.ErrInvalidSubject,
+
expectedStatus: http.StatusBadRequest,
+
expectedError: "InvalidSubject", // Per lexicon: social.coves.feed.vote.create#InvalidSubject
+
},
+
{
+
name: "not authorized",
+
serviceError: votes.ErrNotAuthorized,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "NotAuthorized", // Per lexicon: social.coves.feed.vote.create#NotAuthorized
+
},
+
{
+
name: "banned",
+
serviceError: votes.ErrBanned,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "NotAuthorized", // Banned maps to NotAuthorized per lexicon
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
mockService := &mockVoteService{
+
createFunc: func(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {
+
return nil, tc.serviceError
+
},
+
}
+
handler := NewCreateVoteHandler(mockService)
+
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
Direction: "up",
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != tc.expectedStatus {
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != tc.expectedError {
+
t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error)
+
}
+
})
+
}
+
}
+
+
func TestCreateVoteHandler_ValidDirections(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewCreateVoteHandler(mockService)
+
+
directions := []string{"up", "down"}
+
+
for _, direction := range directions {
+
t.Run("direction_"+direction, func(t *testing.T) {
+
// Create request body
+
reqBody := CreateVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
Direction: direction,
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreateVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200 for direction '%s', got %d. Body: %s", direction, w.Code, w.Body.String())
+
}
+
})
+
}
+
}
+93
internal/api/handlers/vote/delete_vote.go
···
+
package vote
+
+
import (
+
"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,
+
}
+
}
+
+
// DeleteVoteInput represents the request body for deleting a vote
+
type DeleteVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
}
+
+
// DeleteVoteOutput represents the response body for deleting a vote
+
// Per lexicon: output is an empty object
+
type DeleteVoteOutput struct{}
+
+
// HandleDeleteVote removes a vote from a post or comment
+
// POST /xrpc/social.coves.vote.delete
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." } }
+
// Response: { "success": true }
+
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 input DeleteVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create delete vote request
+
req := votes.DeleteVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
}
+
+
// Call service to delete vote
+
err := h.service.DeleteVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response (empty object per lexicon)
+
output := DeleteVoteOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+400
internal/api/handlers/vote/delete_vote_test.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"bytes"
+
"context"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
func TestDeleteVoteHandler_Success(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
// Create request body
+
reqBody := DeleteVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session into context (simulates auth middleware)
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check response is empty object per lexicon
+
var response map[string]interface{}
+
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if len(response) != 0 {
+
t.Errorf("Expected empty object per lexicon, got %v", response)
+
}
+
}
+
+
func TestDeleteVoteHandler_RequiresAuth(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
// Create request body
+
reqBody := DeleteVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request without auth context
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
// No OAuth session in context
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
+
func TestDeleteVoteHandler_MissingFields(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
tests := []struct {
+
name string
+
subjectURI string
+
subjectCID string
+
expectedError string
+
}{
+
{
+
name: "missing subject URI",
+
subjectURI: "",
+
subjectCID: "bafypost123",
+
expectedError: "subject.uri is required",
+
},
+
{
+
name: "missing subject CID",
+
subjectURI: "at://did:plc:author123/social.coves.post/xyz789",
+
subjectCID: "",
+
expectedError: "subject.cid is required",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
// Create request body
+
reqBody := DeleteVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: tc.subjectURI,
+
CID: tc.subjectCID,
+
},
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Message != tc.expectedError {
+
t.Errorf("Expected message '%s', got '%s'", tc.expectedError, errResp.Message)
+
}
+
})
+
}
+
}
+
+
func TestDeleteVoteHandler_InvalidJSON(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
// Create invalid JSON
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBufferString("{invalid json"))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "InvalidRequest" {
+
t.Errorf("Expected error InvalidRequest, got %s", errResp.Error)
+
}
+
}
+
+
func TestDeleteVoteHandler_MethodNotAllowed(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
// Create GET request (should only accept POST)
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.vote.delete", nil)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
}
+
+
func TestDeleteVoteHandler_ServiceError(t *testing.T) {
+
tests := []struct {
+
name string
+
serviceError error
+
expectedStatus int
+
expectedError string
+
}{
+
{
+
name: "vote not found",
+
serviceError: votes.ErrVoteNotFound,
+
expectedStatus: http.StatusNotFound,
+
expectedError: "VoteNotFound", // Per lexicon: social.coves.feed.vote.delete#VoteNotFound
+
},
+
{
+
name: "subject not found",
+
serviceError: votes.ErrSubjectNotFound,
+
expectedStatus: http.StatusNotFound,
+
expectedError: "SubjectNotFound", // Per lexicon: social.coves.feed.vote.create#SubjectNotFound
+
},
+
{
+
name: "invalid subject",
+
serviceError: votes.ErrInvalidSubject,
+
expectedStatus: http.StatusBadRequest,
+
expectedError: "InvalidSubject", // Per lexicon: social.coves.feed.vote.create#InvalidSubject
+
},
+
{
+
name: "not authorized",
+
serviceError: votes.ErrNotAuthorized,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "NotAuthorized", // Per lexicon: social.coves.feed.vote.delete#NotAuthorized
+
},
+
{
+
name: "banned",
+
serviceError: votes.ErrBanned,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "NotAuthorized", // Banned maps to NotAuthorized per lexicon
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
mockService := &mockVoteService{
+
deleteFunc: func(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error {
+
return tc.serviceError
+
},
+
}
+
handler := NewDeleteVoteHandler(mockService)
+
+
// Create request body
+
reqBody := DeleteVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: "at://did:plc:author123/social.coves.post/xyz789",
+
CID: "bafypost123",
+
},
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != tc.expectedStatus {
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String())
+
}
+
+
// Check error response
+
var errResp XRPCError
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != tc.expectedError {
+
t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error)
+
}
+
})
+
}
+
}
+
+
func TestDeleteVoteHandler_MultipleSubjects(t *testing.T) {
+
mockService := &mockVoteService{}
+
handler := NewDeleteVoteHandler(mockService)
+
+
subjects := []struct {
+
uri string
+
cid string
+
}{
+
{"at://did:plc:author1/social.coves.post/post1", "bafypost1"},
+
{"at://did:plc:author2/social.coves.post/post2", "bafypost2"},
+
{"at://did:plc:author3/social.coves.comment/comment1", "bafycomment1"},
+
}
+
+
for _, subject := range subjects {
+
t.Run("subject_"+subject.uri, func(t *testing.T) {
+
// Create request body
+
reqBody := DeleteVoteInput{
+
Subject: struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}{
+
URI: subject.uri,
+
CID: subject.cid,
+
},
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.vote.delete", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session
+
did, _ := syntax.ParseDID("did:plc:test123")
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleDeleteVote(w, req)
+
+
// Check status code
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200 for subject '%s', got %d. Body: %s", subject.uri, w.Code, w.Body.String())
+
}
+
+
// Check response is empty object per lexicon
+
var response map[string]interface{}
+
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if len(response) != 0 {
+
t.Errorf("Expected empty object per lexicon, got %v", response)
+
}
+
})
+
}
+
}
+55
internal/api/handlers/vote/errors.go
···
+
package vote
+
+
import (
+
"Coves/internal/core/votes"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// XRPCError represents an XRPC error response
+
type XRPCError struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
// writeError writes an XRPC error response
+
func writeError(w http.ResponseWriter, status int, error, message string) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
if err := json.NewEncoder(w).Encode(XRPCError{
+
Error: error,
+
Message: message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
+
}
+
}
+
+
// handleServiceError converts service errors to appropriate HTTP responses
+
// Error names MUST match lexicon definitions exactly (UpperCamelCase)
+
func handleServiceError(w http.ResponseWriter, err error) {
+
switch err {
+
case votes.ErrVoteNotFound:
+
// Matches: social.coves.feed.vote.delete#VoteNotFound
+
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
+
case votes.ErrSubjectNotFound:
+
// Matches: social.coves.feed.vote.create#SubjectNotFound
+
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
+
case votes.ErrInvalidDirection:
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
+
case votes.ErrInvalidSubject:
+
// Matches: social.coves.feed.vote.create#InvalidSubject
+
writeError(w, http.StatusBadRequest, "InvalidSubject", "The subject reference is invalid or malformed")
+
case votes.ErrVoteAlreadyExists:
+
writeError(w, http.StatusConflict, "AlreadyExists", "Vote already exists")
+
case votes.ErrNotAuthorized:
+
// Matches: social.coves.feed.vote.create#NotAuthorized, social.coves.feed.vote.delete#NotAuthorized
+
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
+
case votes.ErrBanned:
+
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
+
default:
+
// Internal server error - log the actual error for debugging
+
log.Printf("XRPC handler error: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
+
}
+
}