A community based topic aggregation platform built on atproto
1package votes
2
3import (
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
15type mockVoteRepository struct {
16 mock.Mock
17}
18
19func (m *mockVoteRepository) Create(ctx context.Context, vote *Vote) error {
20 args := m.Called(ctx, vote)
21 return args.Error(0)
22}
23
24func (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
32func (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
40func (m *mockVoteRepository) Delete(ctx context.Context, uri string) error {
41 args := m.Called(ctx, uri)
42 return args.Error(0)
43}
44
45func (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
53func (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
61type mockPostRepository struct {
62 mock.Mock
63}
64
65func (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
73func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error {
74 args := m.Called(ctx, post)
75 return args.Error(0)
76}
77
78func (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
86func (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
94func (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.
102func 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
113func 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
179func 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
213func 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
237func 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/*
274func 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
324func 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)
335func 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}