diff --git a/.beads/beads.left.jsonl b/.beads/beads.left.jsonl new file mode 100644 index 0000000..01d7bfe --- /dev/null +++ b/.beads/beads.left.jsonl @@ -0,0 +1,3 @@ +{"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]} +{"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."} +{"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."} diff --git a/.beads/beads.left.meta.json b/.beads/beads.left.meta.json new file mode 100644 index 0000000..8580708 --- /dev/null +++ b/.beads/beads.left.meta.json @@ -0,0 +1 @@ +{"version":"0.23.1","timestamp":"2025-12-02T18:25:24.009187871-08:00","commit":"00d7d8d"} \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index c3a3a8b..9afc23d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -393,35 +393,12 @@ func main() { commentRepo := postgresRepo.NewCommentRepository(db) log.Println("✅ Comment repository initialized (Jetstream indexing only)") - // Initialize subject validator for votes (checks posts and comments exist) - subjectValidator := votes.NewCompositeSubjectValidator( - // Post existence checker - func(ctx context.Context, uri string) (bool, error) { - _, err := postRepo.GetByURI(ctx, uri) - if err != nil { - if err == posts.ErrNotFound { - return false, nil - } - return false, err - } - return true, nil - }, - // Comment existence checker - func(ctx context.Context, uri string) (bool, error) { - _, err := commentRepo.GetByURI(ctx, uri) - if err != nil { - if err == comments.ErrCommentNotFound { - return false, nil - } - return false, err - } - return true, nil - }, - ) - // Initialize vote service (for XRPC API endpoints) - voteService := votes.NewService(voteRepo, subjectValidator, oauthClient, oauthStore, nil) - log.Println("✅ Vote service initialized (with OAuth authentication and subject validation)") + // Note: We don't validate subject existence - the vote goes to the user's PDS regardless. + // The Jetstream consumer handles orphaned votes correctly by only updating counts for + // non-deleted subjects. This avoids race conditions and eventual consistency issues. + voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil) + log.Println("✅ Vote service initialized (with OAuth authentication)") // Initialize comment service (for query API) // Requires user and community repos for proper author/community hydration per lexicon diff --git a/internal/api/handlers/vote/create_vote_test.go b/internal/api/handlers/vote/create_vote_test.go index 822be90..ea488a3 100644 --- a/internal/api/handlers/vote/create_vote_test.go +++ b/internal/api/handlers/vote/create_vote_test.go @@ -344,17 +344,11 @@ func TestCreateVoteHandler_MethodNotAllowed(t *testing.T) { func TestCreateVoteHandler_ServiceError(t *testing.T) { tests := []struct { - name string serviceError error - expectedStatus int + name string expectedError string + expectedStatus int }{ - { - 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, diff --git a/internal/api/handlers/vote/delete_vote_test.go b/internal/api/handlers/vote/delete_vote_test.go index 0bcdc23..5104f26 100644 --- a/internal/api/handlers/vote/delete_vote_test.go +++ b/internal/api/handlers/vote/delete_vote_test.go @@ -239,10 +239,10 @@ func TestDeleteVoteHandler_MethodNotAllowed(t *testing.T) { func TestDeleteVoteHandler_ServiceError(t *testing.T) { tests := []struct { - name string serviceError error - expectedStatus int + name string expectedError string + expectedStatus int }{ { name: "vote not found", @@ -250,12 +250,6 @@ func TestDeleteVoteHandler_ServiceError(t *testing.T) { 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, diff --git a/internal/api/handlers/vote/errors.go b/internal/api/handlers/vote/errors.go index eee0aa8..f34ae6c 100644 --- a/internal/api/handlers/vote/errors.go +++ b/internal/api/handlers/vote/errors.go @@ -34,9 +34,6 @@ func handleServiceError(w http.ResponseWriter, err error) { case errors.Is(err, votes.ErrVoteNotFound): // Matches: social.coves.feed.vote.delete#VoteNotFound writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject") - case errors.Is(err, votes.ErrSubjectNotFound): - // Matches: social.coves.feed.vote.create#SubjectNotFound - writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found") case errors.Is(err, votes.ErrInvalidDirection): writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'") case errors.Is(err, votes.ErrInvalidSubject): diff --git a/internal/atproto/lexicon/social/coves/feed/vote/create.json b/internal/atproto/lexicon/social/coves/feed/vote/create.json index 2455e3a..59b71a9 100644 --- a/internal/atproto/lexicon/social/coves/feed/vote/create.json +++ b/internal/atproto/lexicon/social/coves/feed/vote/create.json @@ -44,10 +44,6 @@ } }, "errors": [ - { - "name": "SubjectNotFound", - "description": "The subject post or comment was not found" - }, { "name": "NotAuthorized", "description": "User is not authorized to vote on this content" diff --git a/internal/atproto/oauth/handlers_security.go b/internal/atproto/oauth/handlers_security.go index 032e1cf..79dcd02 100644 --- a/internal/atproto/oauth/handlers_security.go +++ b/internal/atproto/oauth/handlers_security.go @@ -25,10 +25,10 @@ import ( // - Android: Verified via /.well-known/assetlinks.json var allowedMobileRedirectURIs = map[string]bool{ // Custom scheme per atproto spec (reverse-domain of coves.social) - "social.coves:/callback": true, - "social.coves://callback": true, // Some platforms add double slash - "social.coves:/oauth/callback": true, // Alternative path - "social.coves://oauth/callback": true, + "social.coves:/callback": true, + "social.coves://callback": true, // Some platforms add double slash + "social.coves:/oauth/callback": true, // Alternative path + "social.coves://oauth/callback": true, // Universal Links - cryptographically bound to app (preferred for security) "https://coves.social/app/oauth/callback": true, } diff --git a/internal/core/votes/errors.go b/internal/core/votes/errors.go index 5310ea4..89580d1 100644 --- a/internal/core/votes/errors.go +++ b/internal/core/votes/errors.go @@ -6,9 +6,6 @@ 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'") diff --git a/internal/core/votes/service.go b/internal/core/votes/service.go index ff1cf79..3a0b702 100644 --- a/internal/core/votes/service.go +++ b/internal/core/votes/service.go @@ -10,11 +10,11 @@ import ( // Implements write-forward pattern: validates requests, then forwards to user's PDS // // Architecture: -// - Service validates input and checks authorization -// - Queries user's PDS directly via com.atproto.repo.listRecords to check existing votes -// (avoids eventual consistency issues with AppView database) -// - Creates/deletes vote records via com.atproto.repo.createRecord/deleteRecord -// - AppView indexes resulting records from Jetstream firehose for aggregate counts +// - Service validates input and checks authorization +// - Queries user's PDS directly via com.atproto.repo.listRecords to check existing votes +// (avoids eventual consistency issues with AppView database) +// - Creates/deletes vote records via com.atproto.repo.createRecord/deleteRecord +// - AppView indexes resulting records from Jetstream firehose for aggregate counts type Service interface { // CreateVote creates a new vote or toggles off an existing vote // Returns URI and CID of created vote, or empty strings if toggled off @@ -22,7 +22,11 @@ type Service interface { // Validation: // - Direction must be "up" or "down" (returns ErrInvalidDirection) // - Subject URI must be valid AT-URI (returns ErrInvalidSubject) - // - Subject must exist (returns ErrSubjectNotFound) + // - Subject CID must be provided (returns ErrInvalidSubject) + // + // Note: Subject existence is NOT validated. Votes on non-existent or deleted + // subjects are allowed - the Jetstream consumer handles orphaned votes correctly + // by only updating counts for non-deleted subjects. // // Behavior: // - If no vote exists: creates new vote with given direction diff --git a/internal/core/votes/service_impl.go b/internal/core/votes/service_impl.go index 58d855c..7d65b9f 100644 --- a/internal/core/votes/service_impl.go +++ b/internal/core/votes/service_impl.go @@ -19,25 +19,22 @@ import ( // voteService implements the Service interface for vote operations type voteService struct { - repo Repository - subjectValidator SubjectValidator - oauthClient *oauthclient.OAuthClient - oauthStore oauth.ClientAuthStore - logger *slog.Logger + repo Repository + oauthClient *oauthclient.OAuthClient + oauthStore oauth.ClientAuthStore + logger *slog.Logger } // NewService creates a new vote service instance -// subjectValidator can be nil to skip subject existence checks (not recommended for production) -func NewService(repo Repository, subjectValidator SubjectValidator, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service { +func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service { if logger == nil { logger = slog.Default() } return &voteService{ - repo: repo, - subjectValidator: subjectValidator, - oauthClient: oauthClient, - oauthStore: oauthStore, - logger: logger, + repo: repo, + oauthClient: oauthClient, + oauthStore: oauthStore, + logger: logger, } } @@ -65,20 +62,10 @@ func (s *voteService) CreateVote(ctx context.Context, session *oauth.ClientSessi return nil, ErrInvalidSubject } - // Validate subject exists in AppView (post or comment) - // This prevents creating votes on non-existent content - if s.subjectValidator != nil { - exists, err := s.subjectValidator.SubjectExists(ctx, req.Subject.URI) - if err != nil { - s.logger.Error("failed to validate subject existence", - "error", err, - "subject", req.Subject.URI) - return nil, fmt.Errorf("failed to validate subject: %w", err) - } - if !exists { - return nil, ErrSubjectNotFound - } - } + // Note: We intentionally don't validate subject existence here. + // The vote record goes to the user's PDS regardless. The Jetstream consumer + // handles orphaned votes correctly by only updating counts for non-deleted subjects. + // This avoids race conditions and eventual consistency issues. // Check for existing vote by querying PDS directly (source of truth) // This avoids eventual consistency issues with the AppView database @@ -284,6 +271,7 @@ func (s *voteService) getVoteFromPDS(ctx context.Context, session *oauth.ClientS // Parse the listRecords response var result struct { + Cursor string `json:"cursor"` Records []struct { URI string `json:"uri"` CID string `json:"cid"` @@ -297,7 +285,6 @@ func (s *voteService) getVoteFromPDS(ctx context.Context, session *oauth.ClientS CreatedAt string `json:"createdAt"` } `json:"value"` } `json:"records"` - Cursor string `json:"cursor"` } if err := json.Unmarshal(body, &result); err != nil { diff --git a/internal/core/votes/subject_validator.go b/internal/core/votes/subject_validator.go deleted file mode 100644 index 6757671..0000000 --- a/internal/core/votes/subject_validator.go +++ /dev/null @@ -1,50 +0,0 @@ -package votes - -import ( - "context" - "strings" -) - -// SubjectExistsFunc is a function type that checks if a subject exists -type SubjectExistsFunc func(ctx context.Context, uri string) (bool, error) - -// CompositeSubjectValidator validates subjects by checking both posts and comments -type CompositeSubjectValidator struct { - postExists SubjectExistsFunc - commentExists SubjectExistsFunc -} - -// NewCompositeSubjectValidator creates a validator that checks both posts and comments -// Pass nil for either function to skip validation for that type -func NewCompositeSubjectValidator(postExists, commentExists SubjectExistsFunc) *CompositeSubjectValidator { - return &CompositeSubjectValidator{ - postExists: postExists, - commentExists: commentExists, - } -} - -// SubjectExists checks if a post or comment exists at the given URI -// Determines type from the collection in the URI (e.g., social.coves.feed.post vs social.coves.feed.comment) -func (v *CompositeSubjectValidator) SubjectExists(ctx context.Context, uri string) (bool, error) { - // Parse collection from AT-URI: at://did/collection/rkey - // Example: at://did:plc:xxx/social.coves.feed.post/abc123 - if strings.Contains(uri, "/social.coves.feed.post/") { - if v.postExists != nil { - return v.postExists(ctx, uri) - } - // If no post checker, assume exists (for testing) - return true, nil - } - - if strings.Contains(uri, "/social.coves.feed.comment/") { - if v.commentExists != nil { - return v.commentExists(ctx, uri) - } - // If no comment checker, assume exists (for testing) - return true, nil - } - - // Unknown collection type - could be from another app - // For now, allow voting on unknown types (future-proofing) - return true, nil -} diff --git a/internal/core/votes/vote.go b/internal/core/votes/vote.go index 70461fe..c830ca9 100644 --- a/internal/core/votes/vote.go +++ b/internal/core/votes/vote.go @@ -1,18 +1,9 @@ package votes import ( - "context" "time" ) -// SubjectValidator validates that vote subjects (posts/comments) exist -// This prevents creating votes on non-existent content -type SubjectValidator interface { - // SubjectExists checks if a post or comment exists at the given URI - // Returns true if found, false if not found - SubjectExists(ctx context.Context, uri string) (bool, error) -} - // Vote represents a vote in the AppView database // Votes are indexed from the firehose after being written to user repositories type Vote struct { diff --git a/tests/integration/vote_e2e_test.go b/tests/integration/vote_e2e_test.go index a7e6dcb..a073ced 100644 --- a/tests/integration/vote_e2e_test.go +++ b/tests/integration/vote_e2e_test.go @@ -85,7 +85,7 @@ func TestVoteE2E_CreateUpvote(t *testing.T) { oauthClient := SetupOAuthTestClient(t, oauthStore) // Setup services - voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil) + voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil) // Create test user on PDS testUserHandle := fmt.Sprintf("voter-%d.local.coves.dev", time.Now().Unix()) @@ -173,8 +173,8 @@ func TestVoteE2E_CreateUpvote(t *testing.T) { CID string `json:"cid"` } - if err := json.NewDecoder(resp.Body).Decode(&voteResp); err != nil { - t.Fatalf("Failed to decode vote response: %v", err) + if decodeErr := json.NewDecoder(resp.Body).Decode(&voteResp); decodeErr != nil { + t.Fatalf("Failed to decode vote response: %v", decodeErr) } t.Logf("✅ XRPC response received:") @@ -310,7 +310,7 @@ func TestVoteE2E_ToggleSameDirection(t *testing.T) { oauthStore := SetupOAuthTestStore(t, db) oauthClient := SetupOAuthTestClient(t, oauthStore) - voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil) + voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil) // Create test user testUserHandle := fmt.Sprintf("toggle-%d.local.coves.dev", time.Now().Unix()) @@ -366,8 +366,12 @@ func TestVoteE2E_ToggleSameDirection(t *testing.T) { URI string `json:"uri"` CID string `json:"cid"` } - json.NewDecoder(resp.Body).Decode(&firstVoteResp) - resp.Body.Close() + if decodeErr := json.NewDecoder(resp.Body).Decode(&firstVoteResp); decodeErr != nil { + t.Fatalf("Failed to decode first vote response: %v", decodeErr) + } + if closeErr := resp.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } t.Logf("✅ First vote created: %s", firstVoteResp.URI) @@ -394,7 +398,9 @@ func TestVoteE2E_ToggleSameDirection(t *testing.T) { }, }, } - voteConsumer.HandleEvent(ctx, &voteEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &voteEvent); handleErr != nil { + t.Fatalf("Failed to handle first vote event: %v", handleErr) + } // Second upvote (same direction) - should toggle off (delete) t.Logf("\n📝 Creating second upvote (toggle off)...") @@ -408,7 +414,11 @@ func TestVoteE2E_ToggleSameDirection(t *testing.T) { if err != nil { t.Fatalf("Failed to toggle vote: %v", err) } - defer resp2.Body.Close() + defer func() { + if closeErr := resp2.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } + }() if resp2.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp2.Body) @@ -430,7 +440,9 @@ func TestVoteE2E_ToggleSameDirection(t *testing.T) { RKey: rkey, }, } - voteConsumer.HandleEvent(ctx, &deleteEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &deleteEvent); handleErr != nil { + t.Fatalf("Failed to handle delete event: %v", handleErr) + } // Verify vote was removed from AppView t.Logf("\n🔍 Verifying vote removed from AppView...") @@ -469,7 +481,7 @@ func TestVoteE2E_ToggleDifferentDirection(t *testing.T) { oauthStore := SetupOAuthTestStore(t, db) oauthClient := SetupOAuthTestClient(t, oauthStore) - voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil) + voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil) // Create test user testUserHandle := fmt.Sprintf("flip-%d.local.coves.dev", time.Now().Unix()) @@ -516,13 +528,20 @@ func TestVoteE2E_ToggleDifferentDirection(t *testing.T) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) - resp, _ := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to create upvote: %v", err) + } var upvoteResp struct { URI string `json:"uri"` CID string `json:"cid"` } - json.NewDecoder(resp.Body).Decode(&upvoteResp) - resp.Body.Close() + if decodeErr := json.NewDecoder(resp.Body).Decode(&upvoteResp); decodeErr != nil { + t.Fatalf("Failed to decode upvote response: %v", decodeErr) + } + if closeErr := resp.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } // Index upvote rkey := utils.ExtractRKeyFromURI(upvoteResp.URI) @@ -547,7 +566,9 @@ func TestVoteE2E_ToggleDifferentDirection(t *testing.T) { }, }, } - voteConsumer.HandleEvent(ctx, &upvoteEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &upvoteEvent); handleErr != nil { + t.Fatalf("Failed to handle upvote event: %v", handleErr) + } t.Logf("✅ Upvote created and indexed") @@ -568,13 +589,20 @@ func TestVoteE2E_ToggleDifferentDirection(t *testing.T) { req2.Header.Set("Content-Type", "application/json") req2.Header.Set("Authorization", "Bearer "+token) - resp2, _ := http.DefaultClient.Do(req2) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("Failed to create downvote: %v", err) + } var downvoteResp struct { URI string `json:"uri"` CID string `json:"cid"` } - json.NewDecoder(resp2.Body).Decode(&downvoteResp) - resp2.Body.Close() + if decodeErr := json.NewDecoder(resp2.Body).Decode(&downvoteResp); decodeErr != nil { + t.Fatalf("Failed to decode downvote response: %v", decodeErr) + } + if closeErr := resp2.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } // Simulate Jetstream UPDATE event (PDS updates the existing record) t.Logf("\n🔄 Simulating Jetstream UPDATE event...") @@ -599,7 +627,9 @@ func TestVoteE2E_ToggleDifferentDirection(t *testing.T) { }, }, } - voteConsumer.HandleEvent(ctx, &updateEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &updateEvent); handleErr != nil { + t.Fatalf("Failed to handle update event: %v", handleErr) + } // Verify vote direction changed in AppView t.Logf("\n🔍 Verifying vote direction changed in AppView...") @@ -648,7 +678,7 @@ func TestVoteE2E_DeleteVote(t *testing.T) { oauthStore := SetupOAuthTestStore(t, db) oauthClient := SetupOAuthTestClient(t, oauthStore) - voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil) + voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil) // Create test user testUserHandle := fmt.Sprintf("delete-%d.local.coves.dev", time.Now().Unix()) @@ -695,13 +725,20 @@ func TestVoteE2E_DeleteVote(t *testing.T) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) - resp, _ := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to create vote: %v", err) + } var voteResp struct { URI string `json:"uri"` CID string `json:"cid"` } - json.NewDecoder(resp.Body).Decode(&voteResp) - resp.Body.Close() + if decodeErr := json.NewDecoder(resp.Body).Decode(&voteResp); decodeErr != nil { + t.Fatalf("Failed to decode vote response: %v", decodeErr) + } + if closeErr := resp.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } // Index vote rkey := utils.ExtractRKeyFromURI(voteResp.URI) @@ -726,7 +763,9 @@ func TestVoteE2E_DeleteVote(t *testing.T) { }, }, } - voteConsumer.HandleEvent(ctx, &voteEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &voteEvent); handleErr != nil { + t.Fatalf("Failed to handle vote event: %v", handleErr) + } t.Logf("✅ Vote created and indexed") @@ -746,8 +785,15 @@ func TestVoteE2E_DeleteVote(t *testing.T) { deleteHttpReq.Header.Set("Content-Type", "application/json") deleteHttpReq.Header.Set("Authorization", "Bearer "+token) - deleteResp, _ := http.DefaultClient.Do(deleteHttpReq) - defer deleteResp.Body.Close() + deleteResp, err := http.DefaultClient.Do(deleteHttpReq) + if err != nil { + t.Fatalf("Failed to delete vote: %v", err) + } + defer func() { + if closeErr := deleteResp.Body.Close(); closeErr != nil { + t.Logf("Failed to close response body: %v", closeErr) + } + }() if deleteResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(deleteResp.Body) @@ -756,7 +802,9 @@ func TestVoteE2E_DeleteVote(t *testing.T) { // Per lexicon, delete returns empty object {} var deleteRespBody map[string]interface{} - json.NewDecoder(deleteResp.Body).Decode(&deleteRespBody) + if decodeErr := json.NewDecoder(deleteResp.Body).Decode(&deleteRespBody); decodeErr != nil { + t.Fatalf("Failed to decode delete response: %v", decodeErr) + } if len(deleteRespBody) != 0 { t.Errorf("Expected empty object per lexicon, got %v", deleteRespBody) @@ -777,7 +825,9 @@ func TestVoteE2E_DeleteVote(t *testing.T) { RKey: rkey, }, } - voteConsumer.HandleEvent(ctx, &deleteEvent) + if handleErr := voteConsumer.HandleEvent(ctx, &deleteEvent); handleErr != nil { + t.Fatalf("Failed to handle delete event: %v", handleErr) + } // Verify vote removed from AppView t.Logf("\n🔍 Verifying vote removed from AppView...")