A community based topic aggregation platform built on atproto

refactor(votes): remove orphaned vote service implementation

Delete write-forward service implementation (448 lines) and tests
(280 lines) that are no longer used after switching to client-direct
writes.

The service contained:
- CreateVote() - write-forward to user's PDS
- DeleteVote() - delete on user's PDS
- PDS write operations using DPoP-bound tokens (impossible)

These operations are now performed directly by clients at their PDS.
The AppView only indexes votes from Jetstream.

Deleted:
- internal/core/votes/service.go (448 lines)
- internal/core/votes/service_test.go (280 lines)

-743
-399
internal/core/votes/service.go
··· 1 - package votes 2 - 3 - import ( 4 - "Coves/internal/core/posts" 5 - "bytes" 6 - "context" 7 - "encoding/json" 8 - "fmt" 9 - "io" 10 - "log" 11 - "net/http" 12 - "strings" 13 - "time" 14 - ) 15 - 16 - type voteService struct { 17 - repo Repository 18 - postRepo posts.Repository 19 - pdsURL string 20 - } 21 - 22 - // NewVoteService creates a new vote service 23 - func NewVoteService( 24 - repo Repository, 25 - postRepo posts.Repository, 26 - pdsURL string, 27 - ) Service { 28 - return &voteService{ 29 - repo: repo, 30 - postRepo: postRepo, 31 - pdsURL: pdsURL, 32 - } 33 - } 34 - 35 - // CreateVote creates a new vote or toggles an existing vote 36 - // Toggle logic: 37 - // - No vote -> Create vote 38 - // - Same direction -> Delete vote (toggle off) 39 - // - Different direction -> Delete old + Create new (toggle direction) 40 - func (s *voteService) CreateVote(ctx context.Context, voterDID string, userAccessToken string, req CreateVoteRequest) (*CreateVoteResponse, error) { 41 - // 1. Validate input 42 - if voterDID == "" { 43 - return nil, NewValidationError("voterDid", "required") 44 - } 45 - if userAccessToken == "" { 46 - return nil, NewValidationError("userAccessToken", "required") 47 - } 48 - if req.Subject == "" { 49 - return nil, NewValidationError("subject", "required") 50 - } 51 - if req.Direction != "up" && req.Direction != "down" { 52 - return nil, ErrInvalidDirection 53 - } 54 - 55 - // 2. Validate subject URI format (should be at://...) 56 - if !strings.HasPrefix(req.Subject, "at://") { 57 - return nil, ErrInvalidSubject 58 - } 59 - 60 - // 3. Get subject post/comment to verify it exists and get its CID (for strong reference) 61 - // For now, we assume the subject is a post. In the future, we'll support comments too. 62 - post, err := s.postRepo.GetByURI(ctx, req.Subject) 63 - if err != nil { 64 - if err == posts.ErrNotFound { 65 - return nil, ErrSubjectNotFound 66 - } 67 - return nil, fmt.Errorf("failed to get subject post: %w", err) 68 - } 69 - 70 - // 4. Check for existing vote on PDS (source of truth for toggle logic) 71 - // IMPORTANT: We query the user's PDS directly instead of AppView to avoid race conditions. 72 - // AppView is eventually consistent (updated via Jetstream), so querying it can cause 73 - // duplicate vote records if the user toggles before Jetstream catches up. 74 - existingVoteRecord, err := s.findVoteOnPDS(ctx, voterDID, userAccessToken, req.Subject) 75 - if err != nil { 76 - return nil, fmt.Errorf("failed to check existing vote on PDS: %w", err) 77 - } 78 - 79 - // 5. Handle toggle logic 80 - var existingVoteURI *string 81 - 82 - if existingVoteRecord != nil { 83 - // Vote exists on PDS - implement toggle logic 84 - if existingVoteRecord.Direction == req.Direction { 85 - // Same direction -> Delete vote (toggle off) 86 - log.Printf("[VOTE-CREATE] Toggle off: deleting existing %s vote on %s", req.Direction, req.Subject) 87 - 88 - // Delete from user's PDS 89 - if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { 90 - return nil, fmt.Errorf("failed to delete vote on PDS: %w", err) 91 - } 92 - 93 - // Return empty response (vote was deleted, not created) 94 - return &CreateVoteResponse{ 95 - URI: "", 96 - CID: "", 97 - }, nil 98 - } 99 - 100 - // Different direction -> Delete old vote first, then create new one below 101 - log.Printf("[VOTE-CREATE] Toggle direction: %s -> %s on %s", existingVoteRecord.Direction, req.Direction, req.Subject) 102 - 103 - if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { 104 - return nil, fmt.Errorf("failed to delete old vote on PDS: %w", err) 105 - } 106 - 107 - existingVoteURI = &existingVoteRecord.URI 108 - } 109 - 110 - // 6. Build vote record with strong reference 111 - voteRecord := map[string]interface{}{ 112 - "$type": "social.coves.interaction.vote", 113 - "subject": map[string]interface{}{ 114 - "uri": req.Subject, 115 - "cid": post.CID, 116 - }, 117 - "direction": req.Direction, 118 - "createdAt": time.Now().Format(time.RFC3339), 119 - } 120 - 121 - // 7. Write to user's PDS repository 122 - recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", "", voteRecord, userAccessToken) 123 - if err != nil { 124 - return nil, fmt.Errorf("failed to create vote on PDS: %w", err) 125 - } 126 - 127 - log.Printf("[VOTE-CREATE] Created %s vote: %s (CID: %s)", req.Direction, recordURI, recordCID) 128 - 129 - // 8. Return response 130 - return &CreateVoteResponse{ 131 - URI: recordURI, 132 - CID: recordCID, 133 - Existing: existingVoteURI, 134 - }, nil 135 - } 136 - 137 - // DeleteVote removes a vote from a post/comment 138 - func (s *voteService) DeleteVote(ctx context.Context, voterDID string, userAccessToken string, req DeleteVoteRequest) error { 139 - // 1. Validate input 140 - if voterDID == "" { 141 - return NewValidationError("voterDid", "required") 142 - } 143 - if userAccessToken == "" { 144 - return NewValidationError("userAccessToken", "required") 145 - } 146 - if req.Subject == "" { 147 - return NewValidationError("subject", "required") 148 - } 149 - 150 - // 2. Find existing vote on PDS (source of truth) 151 - // IMPORTANT: Query PDS directly to avoid race conditions with AppView indexing 152 - existingVoteRecord, err := s.findVoteOnPDS(ctx, voterDID, userAccessToken, req.Subject) 153 - if err != nil { 154 - return fmt.Errorf("failed to check existing vote on PDS: %w", err) 155 - } 156 - 157 - if existingVoteRecord == nil { 158 - return ErrVoteNotFound 159 - } 160 - 161 - // 3. Delete from user's PDS 162 - if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil { 163 - return fmt.Errorf("failed to delete vote on PDS: %w", err) 164 - } 165 - 166 - log.Printf("[VOTE-DELETE] Deleted vote: %s", existingVoteRecord.URI) 167 - 168 - return nil 169 - } 170 - 171 - // GetVote retrieves a user's vote on a specific subject 172 - func (s *voteService) GetVote(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) { 173 - return s.repo.GetByVoterAndSubject(ctx, voterDID, subjectURI) 174 - } 175 - 176 - // Helper methods for PDS operations 177 - 178 - // createRecordOnPDSAs creates a record on the PDS using the user's access token 179 - func (s *voteService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { 180 - endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/")) 181 - 182 - payload := map[string]interface{}{ 183 - "repo": repoDID, 184 - "collection": collection, 185 - "record": record, 186 - } 187 - 188 - if rkey != "" { 189 - payload["rkey"] = rkey 190 - } 191 - 192 - return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 193 - } 194 - 195 - // deleteRecordOnPDSAs deletes a record from the PDS using the user's access token 196 - func (s *voteService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error { 197 - endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) 198 - 199 - payload := map[string]interface{}{ 200 - "repo": repoDID, 201 - "collection": collection, 202 - "rkey": rkey, 203 - } 204 - 205 - _, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 206 - return err 207 - } 208 - 209 - // callPDSWithAuth makes a PDS call with a specific access token 210 - func (s *voteService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) { 211 - jsonData, err := json.Marshal(payload) 212 - if err != nil { 213 - return "", "", fmt.Errorf("failed to marshal payload: %w", err) 214 - } 215 - 216 - req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewBuffer(jsonData)) 217 - if err != nil { 218 - return "", "", fmt.Errorf("failed to create request: %w", err) 219 - } 220 - req.Header.Set("Content-Type", "application/json") 221 - 222 - // Add authentication with provided access token 223 - if accessToken != "" { 224 - req.Header.Set("Authorization", "Bearer "+accessToken) 225 - } 226 - 227 - // Use 30 second timeout for write operations 228 - timeout := 30 * time.Second 229 - client := &http.Client{Timeout: timeout} 230 - resp, err := client.Do(req) 231 - if err != nil { 232 - return "", "", fmt.Errorf("failed to call PDS: %w", err) 233 - } 234 - defer func() { 235 - if closeErr := resp.Body.Close(); closeErr != nil { 236 - log.Printf("Failed to close response body: %v", closeErr) 237 - } 238 - }() 239 - 240 - body, err := io.ReadAll(resp.Body) 241 - if err != nil { 242 - return "", "", fmt.Errorf("failed to read response: %w", err) 243 - } 244 - 245 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 246 - return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, string(body)) 247 - } 248 - 249 - // Parse response to extract URI and CID 250 - var result struct { 251 - URI string `json:"uri"` 252 - CID string `json:"cid"` 253 - } 254 - if err := json.Unmarshal(body, &result); err != nil { 255 - return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 256 - } 257 - 258 - return result.URI, result.CID, nil 259 - } 260 - 261 - // Helper functions 262 - 263 - // PDSVoteRecord represents a vote record returned from PDS listRecords 264 - type PDSVoteRecord struct { 265 - URI string 266 - RKey string 267 - Direction string 268 - Subject struct { 269 - URI string 270 - CID string 271 - } 272 - } 273 - 274 - // findVoteOnPDS queries the user's PDS to find an existing vote on a specific subject 275 - // This is the source of truth for toggle logic (avoiding AppView race conditions) 276 - // 277 - // IMPORTANT: This function paginates through ALL user votes with reverse=true (newest first) 278 - // to handle users with >100 votes. Without pagination, votes on older posts would not be found, 279 - // causing duplicate vote records and 404 errors on delete operations. 280 - func (s *voteService) findVoteOnPDS(ctx context.Context, voterDID, accessToken, subjectURI string) (*PDSVoteRecord, error) { 281 - const maxPages = 50 // Safety limit: prevent infinite loops (50 pages * 100 = 5000 votes max) 282 - var cursor string 283 - pageCount := 0 284 - 285 - client := &http.Client{Timeout: 10 * time.Second} 286 - 287 - for { 288 - pageCount++ 289 - if pageCount > maxPages { 290 - log.Printf("[VOTE-PDS] Reached max pagination limit (%d pages) searching for vote on %s", maxPages, subjectURI) 291 - break 292 - } 293 - 294 - // Build endpoint with pagination cursor and reverse=true (newest first) 295 - endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=social.coves.interaction.vote&limit=100&reverse=true", 296 - strings.TrimSuffix(s.pdsURL, "/"), voterDID) 297 - 298 - if cursor != "" { 299 - endpoint += fmt.Sprintf("&cursor=%s", cursor) 300 - } 301 - 302 - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 303 - if err != nil { 304 - return nil, fmt.Errorf("failed to create request: %w", err) 305 - } 306 - 307 - req.Header.Set("Authorization", "Bearer "+accessToken) 308 - 309 - resp, err := client.Do(req) 310 - if err != nil { 311 - return nil, fmt.Errorf("failed to query PDS: %w", err) 312 - } 313 - 314 - if resp.StatusCode != http.StatusOK { 315 - body, _ := io.ReadAll(resp.Body) 316 - resp.Body.Close() 317 - return nil, fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, string(body)) 318 - } 319 - 320 - var result struct { 321 - Records []struct { 322 - URI string `json:"uri"` 323 - Value struct { 324 - Subject struct { 325 - URI string `json:"uri"` 326 - CID string `json:"cid"` 327 - } `json:"subject"` 328 - Direction string `json:"direction"` 329 - } `json:"value"` 330 - } `json:"records"` 331 - Cursor string `json:"cursor,omitempty"` // Pagination cursor for next page 332 - } 333 - 334 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 335 - resp.Body.Close() 336 - return nil, fmt.Errorf("failed to decode PDS response: %w", err) 337 - } 338 - resp.Body.Close() 339 - 340 - // Find vote on this specific subject in current page 341 - for _, record := range result.Records { 342 - if record.Value.Subject.URI == subjectURI { 343 - rkey := extractRKeyFromURI(record.URI) 344 - log.Printf("[VOTE-PDS] Found existing vote on page %d: %s (direction: %s)", pageCount, record.URI, record.Value.Direction) 345 - return &PDSVoteRecord{ 346 - URI: record.URI, 347 - RKey: rkey, 348 - Direction: record.Value.Direction, 349 - Subject: struct { 350 - URI string 351 - CID string 352 - }{ 353 - URI: record.Value.Subject.URI, 354 - CID: record.Value.Subject.CID, 355 - }, 356 - }, nil 357 - } 358 - } 359 - 360 - // No more pages to check 361 - if result.Cursor == "" { 362 - log.Printf("[VOTE-PDS] No existing vote found after checking %d page(s)", pageCount) 363 - break 364 - } 365 - 366 - // Move to next page 367 - cursor = result.Cursor 368 - } 369 - 370 - // No vote found on this subject after paginating through all records 371 - return nil, nil 372 - } 373 - 374 - // extractRKeyFromURI extracts the rkey from an AT-URI (at://did/collection/rkey) 375 - func extractRKeyFromURI(uri string) string { 376 - parts := strings.Split(uri, "/") 377 - if len(parts) >= 4 { 378 - return parts[len(parts)-1] 379 - } 380 - return "" 381 - } 382 - 383 - // ValidationError represents a validation error 384 - type ValidationError struct { 385 - Field string 386 - Message string 387 - } 388 - 389 - func (e *ValidationError) Error() string { 390 - return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) 391 - } 392 - 393 - // NewValidationError creates a new validation error 394 - func NewValidationError(field, message string) error { 395 - return &ValidationError{ 396 - Field: field, 397 - Message: message, 398 - } 399 - }
-344
internal/core/votes/service_test.go
··· 1 - package votes 2 - 3 - import ( 4 - "Coves/internal/core/posts" 5 - "context" 6 - "testing" 7 - "time" 8 - 9 - "github.com/stretchr/testify/assert" 10 - "github.com/stretchr/testify/mock" 11 - "github.com/stretchr/testify/require" 12 - ) 13 - 14 - // Mock repositories for testing 15 - type mockVoteRepository struct { 16 - mock.Mock 17 - } 18 - 19 - func (m *mockVoteRepository) Create(ctx context.Context, vote *Vote) error { 20 - args := m.Called(ctx, vote) 21 - return args.Error(0) 22 - } 23 - 24 - func (m *mockVoteRepository) GetByURI(ctx context.Context, uri string) (*Vote, error) { 25 - args := m.Called(ctx, uri) 26 - if args.Get(0) == nil { 27 - return nil, args.Error(1) 28 - } 29 - return args.Get(0).(*Vote), args.Error(1) 30 - } 31 - 32 - func (m *mockVoteRepository) GetByVoterAndSubject(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) { 33 - args := m.Called(ctx, voterDID, subjectURI) 34 - if args.Get(0) == nil { 35 - return nil, args.Error(1) 36 - } 37 - return args.Get(0).(*Vote), args.Error(1) 38 - } 39 - 40 - func (m *mockVoteRepository) Delete(ctx context.Context, uri string) error { 41 - args := m.Called(ctx, uri) 42 - return args.Error(0) 43 - } 44 - 45 - func (m *mockVoteRepository) ListBySubject(ctx context.Context, subjectURI string, limit, offset int) ([]*Vote, error) { 46 - args := m.Called(ctx, subjectURI, limit, offset) 47 - if args.Get(0) == nil { 48 - return nil, args.Error(1) 49 - } 50 - return args.Get(0).([]*Vote), args.Error(1) 51 - } 52 - 53 - func (m *mockVoteRepository) ListByVoter(ctx context.Context, voterDID string, limit, offset int) ([]*Vote, error) { 54 - args := m.Called(ctx, voterDID, limit, offset) 55 - if args.Get(0) == nil { 56 - return nil, args.Error(1) 57 - } 58 - return args.Get(0).([]*Vote), args.Error(1) 59 - } 60 - 61 - type mockPostRepository struct { 62 - mock.Mock 63 - } 64 - 65 - func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) { 66 - args := m.Called(ctx, uri) 67 - if args.Get(0) == nil { 68 - return nil, args.Error(1) 69 - } 70 - return args.Get(0).(*posts.Post), args.Error(1) 71 - } 72 - 73 - func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error { 74 - args := m.Called(ctx, post) 75 - return args.Error(0) 76 - } 77 - 78 - func (m *mockPostRepository) GetByRkey(ctx context.Context, communityDID, rkey string) (*posts.Post, error) { 79 - args := m.Called(ctx, communityDID, rkey) 80 - if args.Get(0) == nil { 81 - return nil, args.Error(1) 82 - } 83 - return args.Get(0).(*posts.Post), args.Error(1) 84 - } 85 - 86 - func (m *mockPostRepository) ListByCommunity(ctx context.Context, communityDID string, limit, offset int) ([]*posts.Post, error) { 87 - args := m.Called(ctx, communityDID, limit, offset) 88 - if args.Get(0) == nil { 89 - return nil, args.Error(1) 90 - } 91 - return args.Get(0).([]*posts.Post), args.Error(1) 92 - } 93 - 94 - func (m *mockPostRepository) Delete(ctx context.Context, uri string) error { 95 - args := m.Called(ctx, uri) 96 - return args.Error(0) 97 - } 98 - 99 - // TestVoteService_CreateVote_NoExistingVote tests creating a vote when no vote exists 100 - // NOTE: This test is skipped because we need to refactor service to inject HTTP client 101 - // for testing PDS writes. The full flow is covered by E2E tests. 102 - func TestVoteService_CreateVote_NoExistingVote(t *testing.T) { 103 - t.Skip("Skipping because we need to refactor service to inject HTTP client for testing PDS writes - covered by E2E tests") 104 - 105 - // This test would verify: 106 - // - Post exists check 107 - // - No existing vote 108 - // - PDS write succeeds 109 - // - Response contains vote URI and CID 110 - } 111 - 112 - // TestVoteService_ValidateInput tests input validation 113 - func TestVoteService_ValidateInput(t *testing.T) { 114 - mockVoteRepo := new(mockVoteRepository) 115 - mockPostRepo := new(mockPostRepository) 116 - 117 - service := &voteService{ 118 - repo: mockVoteRepo, 119 - postRepo: mockPostRepo, 120 - pdsURL: "http://mock-pds.test", 121 - } 122 - 123 - ctx := context.Background() 124 - 125 - tests := []struct { 126 - name string 127 - voterDID string 128 - accessToken string 129 - req CreateVoteRequest 130 - expectedError string 131 - }{ 132 - { 133 - name: "missing voter DID", 134 - voterDID: "", 135 - accessToken: "token123", 136 - req: CreateVoteRequest{Subject: "at://test", Direction: "up"}, 137 - expectedError: "voterDid", 138 - }, 139 - { 140 - name: "missing access token", 141 - voterDID: "did:plc:test", 142 - accessToken: "", 143 - req: CreateVoteRequest{Subject: "at://test", Direction: "up"}, 144 - expectedError: "userAccessToken", 145 - }, 146 - { 147 - name: "missing subject", 148 - voterDID: "did:plc:test", 149 - accessToken: "token123", 150 - req: CreateVoteRequest{Subject: "", Direction: "up"}, 151 - expectedError: "subject", 152 - }, 153 - { 154 - name: "invalid direction", 155 - voterDID: "did:plc:test", 156 - accessToken: "token123", 157 - req: CreateVoteRequest{Subject: "at://test", Direction: "invalid"}, 158 - expectedError: "invalid vote direction", 159 - }, 160 - { 161 - name: "invalid subject format", 162 - voterDID: "did:plc:test", 163 - accessToken: "token123", 164 - req: CreateVoteRequest{Subject: "http://not-at-uri", Direction: "up"}, 165 - expectedError: "invalid subject URI", 166 - }, 167 - } 168 - 169 - for _, tt := range tests { 170 - t.Run(tt.name, func(t *testing.T) { 171 - _, err := service.CreateVote(ctx, tt.voterDID, tt.accessToken, tt.req) 172 - require.Error(t, err) 173 - assert.Contains(t, err.Error(), tt.expectedError) 174 - }) 175 - } 176 - } 177 - 178 - // TestVoteService_GetVote tests retrieving a vote 179 - func TestVoteService_GetVote(t *testing.T) { 180 - mockVoteRepo := new(mockVoteRepository) 181 - mockPostRepo := new(mockPostRepository) 182 - 183 - service := &voteService{ 184 - repo: mockVoteRepo, 185 - postRepo: mockPostRepo, 186 - pdsURL: "http://mock-pds.test", 187 - } 188 - 189 - ctx := context.Background() 190 - voterDID := "did:plc:voter123" 191 - subjectURI := "at://did:plc:community/social.coves.post.record/abc123" 192 - 193 - expectedVote := &Vote{ 194 - ID: 1, 195 - URI: "at://did:plc:voter123/social.coves.interaction.vote/xyz789", 196 - VoterDID: voterDID, 197 - SubjectURI: subjectURI, 198 - Direction: "up", 199 - CreatedAt: time.Now(), 200 - } 201 - 202 - mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(expectedVote, nil) 203 - 204 - result, err := service.GetVote(ctx, voterDID, subjectURI) 205 - assert.NoError(t, err) 206 - assert.Equal(t, expectedVote.URI, result.URI) 207 - assert.Equal(t, expectedVote.Direction, result.Direction) 208 - 209 - mockVoteRepo.AssertExpectations(t) 210 - } 211 - 212 - // TestVoteService_GetVote_NotFound tests getting a non-existent vote 213 - func TestVoteService_GetVote_NotFound(t *testing.T) { 214 - mockVoteRepo := new(mockVoteRepository) 215 - mockPostRepo := new(mockPostRepository) 216 - 217 - service := &voteService{ 218 - repo: mockVoteRepo, 219 - postRepo: mockPostRepo, 220 - pdsURL: "http://mock-pds.test", 221 - } 222 - 223 - ctx := context.Background() 224 - voterDID := "did:plc:voter123" 225 - subjectURI := "at://did:plc:community/social.coves.post.record/noexist" 226 - 227 - mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(nil, ErrVoteNotFound) 228 - 229 - result, err := service.GetVote(ctx, voterDID, subjectURI) 230 - assert.ErrorIs(t, err, ErrVoteNotFound) 231 - assert.Nil(t, result) 232 - 233 - mockVoteRepo.AssertExpectations(t) 234 - } 235 - 236 - // TestVoteService_SubjectNotFound tests voting on non-existent post 237 - func TestVoteService_SubjectNotFound(t *testing.T) { 238 - mockVoteRepo := new(mockVoteRepository) 239 - mockPostRepo := new(mockPostRepository) 240 - 241 - service := &voteService{ 242 - repo: mockVoteRepo, 243 - postRepo: mockPostRepo, 244 - pdsURL: "http://mock-pds.test", 245 - } 246 - 247 - ctx := context.Background() 248 - voterDID := "did:plc:voter123" 249 - subjectURI := "at://did:plc:community/social.coves.post.record/noexist" 250 - 251 - // Mock post not found 252 - mockPostRepo.On("GetByURI", ctx, subjectURI).Return(nil, posts.ErrNotFound) 253 - 254 - req := CreateVoteRequest{ 255 - Subject: subjectURI, 256 - Direction: "up", 257 - } 258 - 259 - _, err := service.CreateVote(ctx, voterDID, "token123", req) 260 - assert.ErrorIs(t, err, ErrSubjectNotFound) 261 - 262 - mockPostRepo.AssertExpectations(t) 263 - } 264 - 265 - // NOTE: Testing toggle logic (same direction, different direction) requires mocking HTTP client 266 - // These tests are covered by integration tests in tests/integration/vote_e2e_test.go 267 - // To add unit tests for toggle logic, we would need to: 268 - // 1. Refactor voteService to accept an HTTP client interface 269 - // 2. Mock the PDS createRecord and deleteRecord calls 270 - // 3. Verify the correct sequence of operations 271 - 272 - // Example of what toggle tests would look like (requires refactoring): 273 - /* 274 - func TestVoteService_ToggleSameDirection(t *testing.T) { 275 - // Setup 276 - mockVoteRepo := new(mockVoteRepository) 277 - mockPostRepo := new(mockPostRepository) 278 - mockPDSClient := new(mockPDSClient) 279 - 280 - service := &voteService{ 281 - repo: mockVoteRepo, 282 - postRepo: mockPostRepo, 283 - pdsClient: mockPDSClient, // Would need to refactor to inject this 284 - } 285 - 286 - ctx := context.Background() 287 - voterDID := "did:plc:voter123" 288 - subjectURI := "at://did:plc:community/social.coves.post.record/abc123" 289 - 290 - // Mock existing upvote 291 - existingVote := &Vote{ 292 - URI: "at://did:plc:voter123/social.coves.interaction.vote/existing", 293 - VoterDID: voterDID, 294 - SubjectURI: subjectURI, 295 - Direction: "up", 296 - } 297 - mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(existingVote, nil) 298 - 299 - // Mock post exists 300 - mockPostRepo.On("GetByURI", ctx, subjectURI).Return(&posts.Post{ 301 - URI: subjectURI, 302 - CID: "bafyreigpost123", 303 - }, nil) 304 - 305 - // Mock PDS delete 306 - mockPDSClient.On("DeleteRecord", voterDID, "social.coves.interaction.vote", "existing").Return(nil) 307 - 308 - // Execute: Click upvote when already upvoted -> should delete 309 - req := CreateVoteRequest{ 310 - Subject: subjectURI, 311 - Direction: "up", // Same direction 312 - } 313 - 314 - response, err := service.CreateVote(ctx, voterDID, "token123", req) 315 - 316 - // Assert 317 - assert.NoError(t, err) 318 - assert.Equal(t, "", response.URI, "Should return empty URI when toggled off") 319 - mockPDSClient.AssertCalled(t, "DeleteRecord", voterDID, "social.coves.interaction.vote", "existing") 320 - mockVoteRepo.AssertExpectations(t) 321 - mockPostRepo.AssertExpectations(t) 322 - } 323 - 324 - func TestVoteService_ToggleDifferentDirection(t *testing.T) { 325 - // Similar test but existing vote is "up" and new vote is "down" 326 - // Should delete old vote and create new vote 327 - // Would verify: 328 - // 1. DeleteRecord called for old vote 329 - // 2. CreateRecord called for new vote 330 - // 3. Response contains new vote URI 331 - } 332 - */ 333 - 334 - // Documentation test to explain toggle logic (verified by E2E tests) 335 - func TestVoteService_ToggleLogicDocumentation(t *testing.T) { 336 - t.Log("Toggle Logic (verified by E2E tests in tests/integration/vote_e2e_test.go):") 337 - t.Log("1. No existing vote + upvote clicked → Create upvote") 338 - t.Log("2. Upvote exists + upvote clicked → Delete upvote (toggle off)") 339 - t.Log("3. Upvote exists + downvote clicked → Delete upvote + Create downvote (switch)") 340 - t.Log("4. Downvote exists + downvote clicked → Delete downvote (toggle off)") 341 - t.Log("5. Downvote exists + upvote clicked → Delete downvote + Create upvote (switch)") 342 - t.Log("") 343 - t.Log("To add unit tests for toggle logic, refactor service to accept HTTP client interface") 344 - }