A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/atproto/jetstream"
5 "Coves/internal/core/comments"
6 "Coves/internal/core/users"
7 "Coves/internal/db/postgres"
8 "context"
9 "fmt"
10 "testing"
11 "time"
12)
13
14// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
15func TestCommentVote_CreateAndUpdate(t *testing.T) {
16 db := setupTestDB(t)
17 defer func() {
18 if err := db.Close(); err != nil {
19 t.Logf("Failed to close database: %v", err)
20 }
21 }()
22
23 ctx := context.Background()
24 commentRepo := postgres.NewCommentRepository(db)
25 voteRepo := postgres.NewVoteRepository(db)
26 userRepo := postgres.NewUserRepository(db)
27 userService := users.NewUserService(userRepo, nil, "http://localhost:3001")
28
29 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
30 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
31
32 // Use fixed timestamp to prevent flaky tests
33 fixedTime := time.Date(2025, 11, 6, 12, 0, 0, 0, time.UTC)
34
35 // Setup test data
36 testUser := createTestUser(t, db, "voter.test", "did:plc:voter123")
37 testCommunity, err := createFeedTestCommunity(db, ctx, "testcommunity", "owner.test")
38 if err != nil {
39 t.Fatalf("Failed to create test community: %v", err)
40 }
41 testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Test Post", 0, fixedTime)
42
43 t.Run("Upvote on comment increments count", func(t *testing.T) {
44 // Create a comment
45 commentRKey := generateTID()
46 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
47 commentCID := "bafycomment123"
48
49 commentEvent := &jetstream.JetstreamEvent{
50 Did: testUser.DID,
51 Kind: "commit",
52 Commit: &jetstream.CommitEvent{
53 Rev: "test-rev",
54 Operation: "create",
55 Collection: "social.coves.community.comment",
56 RKey: commentRKey,
57 CID: commentCID,
58 Record: map[string]interface{}{
59 "$type": "social.coves.community.comment",
60 "content": "Comment to vote on",
61 "reply": map[string]interface{}{
62 "root": map[string]interface{}{
63 "uri": testPostURI,
64 "cid": "bafypost",
65 },
66 "parent": map[string]interface{}{
67 "uri": testPostURI,
68 "cid": "bafypost",
69 },
70 },
71 "createdAt": fixedTime.Format(time.RFC3339),
72 },
73 },
74 }
75
76 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
77 t.Fatalf("Failed to create comment: %v", err)
78 }
79
80 // Verify initial counts
81 comment, err := commentRepo.GetByURI(ctx, commentURI)
82 if err != nil {
83 t.Fatalf("Failed to get comment: %v", err)
84 }
85 if comment.UpvoteCount != 0 {
86 t.Errorf("Expected initial upvote_count = 0, got %d", comment.UpvoteCount)
87 }
88
89 // Create upvote on comment
90 voteRKey := generateTID()
91 voteURI := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", testUser.DID, voteRKey)
92
93 voteEvent := &jetstream.JetstreamEvent{
94 Did: testUser.DID,
95 Kind: "commit",
96 Commit: &jetstream.CommitEvent{
97 Rev: "test-rev",
98 Operation: "create",
99 Collection: "social.coves.feed.vote",
100 RKey: voteRKey,
101 CID: "bafyvote123",
102 Record: map[string]interface{}{
103 "$type": "social.coves.feed.vote",
104 "subject": map[string]interface{}{
105 "uri": commentURI,
106 "cid": commentCID,
107 },
108 "direction": "up",
109 "createdAt": fixedTime.Format(time.RFC3339),
110 },
111 },
112 }
113
114 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil {
115 t.Fatalf("Failed to create vote: %v", err)
116 }
117
118 // Verify vote was indexed
119 vote, err := voteRepo.GetByURI(ctx, voteURI)
120 if err != nil {
121 t.Fatalf("Failed to get vote: %v", err)
122 }
123 if vote.SubjectURI != commentURI {
124 t.Errorf("Expected vote subject_uri = %s, got %s", commentURI, vote.SubjectURI)
125 }
126 if vote.Direction != "up" {
127 t.Errorf("Expected vote direction = 'up', got %s", vote.Direction)
128 }
129
130 // Verify comment counts updated
131 updatedComment, err := commentRepo.GetByURI(ctx, commentURI)
132 if err != nil {
133 t.Fatalf("Failed to get updated comment: %v", err)
134 }
135 if updatedComment.UpvoteCount != 1 {
136 t.Errorf("Expected upvote_count = 1, got %d", updatedComment.UpvoteCount)
137 }
138 if updatedComment.Score != 1 {
139 t.Errorf("Expected score = 1, got %d", updatedComment.Score)
140 }
141 })
142
143 t.Run("Downvote on comment increments downvote count", func(t *testing.T) {
144 // Create a comment
145 commentRKey := generateTID()
146 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
147 commentCID := "bafycomment456"
148
149 commentEvent := &jetstream.JetstreamEvent{
150 Did: testUser.DID,
151 Kind: "commit",
152 Commit: &jetstream.CommitEvent{
153 Rev: "test-rev",
154 Operation: "create",
155 Collection: "social.coves.community.comment",
156 RKey: commentRKey,
157 CID: commentCID,
158 Record: map[string]interface{}{
159 "$type": "social.coves.community.comment",
160 "content": "Comment to downvote",
161 "reply": map[string]interface{}{
162 "root": map[string]interface{}{
163 "uri": testPostURI,
164 "cid": "bafypost",
165 },
166 "parent": map[string]interface{}{
167 "uri": testPostURI,
168 "cid": "bafypost",
169 },
170 },
171 "createdAt": fixedTime.Format(time.RFC3339),
172 },
173 },
174 }
175
176 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
177 t.Fatalf("Failed to create comment: %v", err)
178 }
179
180 // Create downvote
181 voteRKey := generateTID()
182
183 voteEvent := &jetstream.JetstreamEvent{
184 Did: testUser.DID,
185 Kind: "commit",
186 Commit: &jetstream.CommitEvent{
187 Rev: "test-rev",
188 Operation: "create",
189 Collection: "social.coves.feed.vote",
190 RKey: voteRKey,
191 CID: "bafyvote456",
192 Record: map[string]interface{}{
193 "$type": "social.coves.feed.vote",
194 "subject": map[string]interface{}{
195 "uri": commentURI,
196 "cid": commentCID,
197 },
198 "direction": "down",
199 "createdAt": fixedTime.Format(time.RFC3339),
200 },
201 },
202 }
203
204 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil {
205 t.Fatalf("Failed to create downvote: %v", err)
206 }
207
208 // Verify comment counts
209 updatedComment, err := commentRepo.GetByURI(ctx, commentURI)
210 if err != nil {
211 t.Fatalf("Failed to get updated comment: %v", err)
212 }
213 if updatedComment.DownvoteCount != 1 {
214 t.Errorf("Expected downvote_count = 1, got %d", updatedComment.DownvoteCount)
215 }
216 if updatedComment.Score != -1 {
217 t.Errorf("Expected score = -1, got %d", updatedComment.Score)
218 }
219 })
220
221 t.Run("Delete vote decrements comment counts", func(t *testing.T) {
222 // Create comment
223 commentRKey := generateTID()
224 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
225 commentCID := "bafycomment789"
226
227 commentEvent := &jetstream.JetstreamEvent{
228 Did: testUser.DID,
229 Kind: "commit",
230 Commit: &jetstream.CommitEvent{
231 Rev: "test-rev",
232 Operation: "create",
233 Collection: "social.coves.community.comment",
234 RKey: commentRKey,
235 CID: commentCID,
236 Record: map[string]interface{}{
237 "$type": "social.coves.community.comment",
238 "content": "Comment for vote deletion test",
239 "reply": map[string]interface{}{
240 "root": map[string]interface{}{
241 "uri": testPostURI,
242 "cid": "bafypost",
243 },
244 "parent": map[string]interface{}{
245 "uri": testPostURI,
246 "cid": "bafypost",
247 },
248 },
249 "createdAt": fixedTime.Format(time.RFC3339),
250 },
251 },
252 }
253
254 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
255 t.Fatalf("Failed to create comment: %v", err)
256 }
257
258 // Create vote
259 voteRKey := generateTID()
260
261 createVoteEvent := &jetstream.JetstreamEvent{
262 Did: testUser.DID,
263 Kind: "commit",
264 Commit: &jetstream.CommitEvent{
265 Rev: "test-rev",
266 Operation: "create",
267 Collection: "social.coves.feed.vote",
268 RKey: voteRKey,
269 CID: "bafyvote789",
270 Record: map[string]interface{}{
271 "$type": "social.coves.feed.vote",
272 "subject": map[string]interface{}{
273 "uri": commentURI,
274 "cid": commentCID,
275 },
276 "direction": "up",
277 "createdAt": fixedTime.Format(time.RFC3339),
278 },
279 },
280 }
281
282 if err := voteConsumer.HandleEvent(ctx, createVoteEvent); err != nil {
283 t.Fatalf("Failed to create vote: %v", err)
284 }
285
286 // Verify vote exists
287 commentAfterVote, _ := commentRepo.GetByURI(ctx, commentURI)
288 if commentAfterVote.UpvoteCount != 1 {
289 t.Fatalf("Expected upvote_count = 1 before delete, got %d", commentAfterVote.UpvoteCount)
290 }
291
292 // Delete vote
293 deleteVoteEvent := &jetstream.JetstreamEvent{
294 Did: testUser.DID,
295 Kind: "commit",
296 Commit: &jetstream.CommitEvent{
297 Rev: "test-rev",
298 Operation: "delete",
299 Collection: "social.coves.feed.vote",
300 RKey: voteRKey,
301 },
302 }
303
304 if err := voteConsumer.HandleEvent(ctx, deleteVoteEvent); err != nil {
305 t.Fatalf("Failed to delete vote: %v", err)
306 }
307
308 // Verify counts decremented
309 commentAfterDelete, err := commentRepo.GetByURI(ctx, commentURI)
310 if err != nil {
311 t.Fatalf("Failed to get comment after vote delete: %v", err)
312 }
313 if commentAfterDelete.UpvoteCount != 0 {
314 t.Errorf("Expected upvote_count = 0 after delete, got %d", commentAfterDelete.UpvoteCount)
315 }
316 if commentAfterDelete.Score != 0 {
317 t.Errorf("Expected score = 0 after delete, got %d", commentAfterDelete.Score)
318 }
319 })
320}
321
322// TestCommentVote_ViewerState tests viewer vote state in comment query responses
323func TestCommentVote_ViewerState(t *testing.T) {
324 db := setupTestDB(t)
325 defer func() {
326 if err := db.Close(); err != nil {
327 t.Logf("Failed to close database: %v", err)
328 }
329 }()
330
331 ctx := context.Background()
332 commentRepo := postgres.NewCommentRepository(db)
333 voteRepo := postgres.NewVoteRepository(db)
334 postRepo := postgres.NewPostRepository(db)
335 userRepo := postgres.NewUserRepository(db)
336 communityRepo := postgres.NewCommunityRepository(db)
337 userService := users.NewUserService(userRepo, nil, "http://localhost:3001")
338
339 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
340 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
341
342 // Use fixed timestamp to prevent flaky tests
343 fixedTime := time.Date(2025, 11, 6, 12, 0, 0, 0, time.UTC)
344
345 // Setup test data
346 testUser := createTestUser(t, db, "viewer.test", "did:plc:viewer123")
347 testCommunity, err := createFeedTestCommunity(db, ctx, "testcommunity", "owner.test")
348 if err != nil {
349 t.Fatalf("Failed to create test community: %v", err)
350 }
351 testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Test Post", 0, fixedTime)
352
353 t.Run("Viewer with vote sees vote state", func(t *testing.T) {
354 // Create comment
355 commentRKey := generateTID()
356 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
357 commentCID := "bafycomment111"
358
359 commentEvent := &jetstream.JetstreamEvent{
360 Did: testUser.DID,
361 Kind: "commit",
362 Commit: &jetstream.CommitEvent{
363 Rev: "test-rev",
364 Operation: "create",
365 Collection: "social.coves.community.comment",
366 RKey: commentRKey,
367 CID: commentCID,
368 Record: map[string]interface{}{
369 "$type": "social.coves.community.comment",
370 "content": "Comment with viewer vote",
371 "reply": map[string]interface{}{
372 "root": map[string]interface{}{
373 "uri": testPostURI,
374 "cid": "bafypost",
375 },
376 "parent": map[string]interface{}{
377 "uri": testPostURI,
378 "cid": "bafypost",
379 },
380 },
381 "createdAt": fixedTime.Format(time.RFC3339),
382 },
383 },
384 }
385
386 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
387 t.Fatalf("Failed to create comment: %v", err)
388 }
389
390 // Create vote
391 voteRKey := generateTID()
392 voteURI := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", testUser.DID, voteRKey)
393
394 voteEvent := &jetstream.JetstreamEvent{
395 Did: testUser.DID,
396 Kind: "commit",
397 Commit: &jetstream.CommitEvent{
398 Rev: "test-rev",
399 Operation: "create",
400 Collection: "social.coves.feed.vote",
401 RKey: voteRKey,
402 CID: "bafyvote111",
403 Record: map[string]interface{}{
404 "$type": "social.coves.feed.vote",
405 "subject": map[string]interface{}{
406 "uri": commentURI,
407 "cid": commentCID,
408 },
409 "direction": "up",
410 "createdAt": fixedTime.Format(time.RFC3339),
411 },
412 },
413 }
414
415 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil {
416 t.Fatalf("Failed to create vote: %v", err)
417 }
418
419 // Query comments with viewer authentication
420 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
421 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
422 PostURI: testPostURI,
423 Sort: "new",
424 Depth: 10,
425 Limit: 100,
426 ViewerDID: &testUser.DID,
427 })
428 if err != nil {
429 t.Fatalf("Failed to get comments: %v", err)
430 }
431
432 if len(response.Comments) == 0 {
433 t.Fatal("Expected at least one comment in response")
434 }
435
436 // Find our comment
437 var foundComment *comments.CommentView
438 for _, threadView := range response.Comments {
439 if threadView.Comment.URI == commentURI {
440 foundComment = threadView.Comment
441 break
442 }
443 }
444
445 if foundComment == nil {
446 t.Fatal("Expected to find test comment in response")
447 }
448
449 // Verify viewer state
450 if foundComment.Viewer == nil {
451 t.Fatal("Expected viewer state for authenticated request")
452 }
453 if foundComment.Viewer.Vote == nil {
454 t.Error("Expected viewer.vote to be populated")
455 } else if *foundComment.Viewer.Vote != "up" {
456 t.Errorf("Expected viewer.vote = 'up', got %s", *foundComment.Viewer.Vote)
457 }
458 if foundComment.Viewer.VoteURI == nil {
459 t.Error("Expected viewer.voteUri to be populated")
460 } else if *foundComment.Viewer.VoteURI != voteURI {
461 t.Errorf("Expected viewer.voteUri = %s, got %s", voteURI, *foundComment.Viewer.VoteURI)
462 }
463 })
464
465 t.Run("Viewer without vote sees empty state", func(t *testing.T) {
466 // Create comment (no vote)
467 commentRKey := generateTID()
468 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
469
470 commentEvent := &jetstream.JetstreamEvent{
471 Did: testUser.DID,
472 Kind: "commit",
473 Commit: &jetstream.CommitEvent{
474 Rev: "test-rev",
475 Operation: "create",
476 Collection: "social.coves.community.comment",
477 RKey: commentRKey,
478 CID: "bafycomment222",
479 Record: map[string]interface{}{
480 "$type": "social.coves.community.comment",
481 "content": "Comment without viewer vote",
482 "reply": map[string]interface{}{
483 "root": map[string]interface{}{
484 "uri": testPostURI,
485 "cid": "bafypost",
486 },
487 "parent": map[string]interface{}{
488 "uri": testPostURI,
489 "cid": "bafypost",
490 },
491 },
492 "createdAt": fixedTime.Format(time.RFC3339),
493 },
494 },
495 }
496
497 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
498 t.Fatalf("Failed to create comment: %v", err)
499 }
500
501 // Query with authentication but no vote
502 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
503 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
504 PostURI: testPostURI,
505 Sort: "new",
506 Depth: 10,
507 Limit: 100,
508 ViewerDID: &testUser.DID,
509 })
510 if err != nil {
511 t.Fatalf("Failed to get comments: %v", err)
512 }
513
514 if len(response.Comments) == 0 {
515 t.Fatal("Expected at least one comment in response")
516 }
517
518 // Find our comment
519 var foundComment *comments.CommentView
520 for _, threadView := range response.Comments {
521 if threadView.Comment.URI == commentURI {
522 foundComment = threadView.Comment
523 break
524 }
525 }
526
527 if foundComment == nil {
528 t.Fatal("Expected to find test comment in response")
529 }
530
531 // Verify viewer state exists but no vote
532 if foundComment.Viewer == nil {
533 t.Fatal("Expected viewer state for authenticated request")
534 }
535 if foundComment.Viewer.Vote != nil {
536 t.Errorf("Expected viewer.vote = nil (no vote), got %v", *foundComment.Viewer.Vote)
537 }
538 if foundComment.Viewer.VoteURI != nil {
539 t.Errorf("Expected viewer.voteUri = nil (no vote), got %v", *foundComment.Viewer.VoteURI)
540 }
541 })
542
543 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) {
544 // Query without authentication
545 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
546 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
547 PostURI: testPostURI,
548 Sort: "new",
549 Depth: 10,
550 Limit: 100,
551 ViewerDID: nil, // No authentication
552 })
553 if err != nil {
554 t.Fatalf("Failed to get comments: %v", err)
555 }
556
557 if len(response.Comments) > 0 {
558 // Verify no viewer state
559 if response.Comments[0].Comment.Viewer != nil {
560 t.Error("Expected viewer = nil for unauthenticated request")
561 }
562 }
563 })
564}