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 // Use factory constructor with nil factory - this test only uses the read path (GetComments)
421 commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
422 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
423 PostURI: testPostURI,
424 Sort: "new",
425 Depth: 10,
426 Limit: 100,
427 ViewerDID: &testUser.DID,
428 })
429 if err != nil {
430 t.Fatalf("Failed to get comments: %v", err)
431 }
432
433 if len(response.Comments) == 0 {
434 t.Fatal("Expected at least one comment in response")
435 }
436
437 // Find our comment
438 var foundComment *comments.CommentView
439 for _, threadView := range response.Comments {
440 if threadView.Comment.URI == commentURI {
441 foundComment = threadView.Comment
442 break
443 }
444 }
445
446 if foundComment == nil {
447 t.Fatal("Expected to find test comment in response")
448 }
449
450 // Verify viewer state
451 if foundComment.Viewer == nil {
452 t.Fatal("Expected viewer state for authenticated request")
453 }
454 if foundComment.Viewer.Vote == nil {
455 t.Error("Expected viewer.vote to be populated")
456 } else if *foundComment.Viewer.Vote != "up" {
457 t.Errorf("Expected viewer.vote = 'up', got %s", *foundComment.Viewer.Vote)
458 }
459 if foundComment.Viewer.VoteURI == nil {
460 t.Error("Expected viewer.voteUri to be populated")
461 } else if *foundComment.Viewer.VoteURI != voteURI {
462 t.Errorf("Expected viewer.voteUri = %s, got %s", voteURI, *foundComment.Viewer.VoteURI)
463 }
464 })
465
466 t.Run("Viewer without vote sees empty state", func(t *testing.T) {
467 // Create comment (no vote)
468 commentRKey := generateTID()
469 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRKey)
470
471 commentEvent := &jetstream.JetstreamEvent{
472 Did: testUser.DID,
473 Kind: "commit",
474 Commit: &jetstream.CommitEvent{
475 Rev: "test-rev",
476 Operation: "create",
477 Collection: "social.coves.community.comment",
478 RKey: commentRKey,
479 CID: "bafycomment222",
480 Record: map[string]interface{}{
481 "$type": "social.coves.community.comment",
482 "content": "Comment without viewer vote",
483 "reply": map[string]interface{}{
484 "root": map[string]interface{}{
485 "uri": testPostURI,
486 "cid": "bafypost",
487 },
488 "parent": map[string]interface{}{
489 "uri": testPostURI,
490 "cid": "bafypost",
491 },
492 },
493 "createdAt": fixedTime.Format(time.RFC3339),
494 },
495 },
496 }
497
498 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil {
499 t.Fatalf("Failed to create comment: %v", err)
500 }
501
502 // Query with authentication but no vote
503 // Use factory constructor with nil factory - this test only uses the read path (GetComments)
504 commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
505 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
506 PostURI: testPostURI,
507 Sort: "new",
508 Depth: 10,
509 Limit: 100,
510 ViewerDID: &testUser.DID,
511 })
512 if err != nil {
513 t.Fatalf("Failed to get comments: %v", err)
514 }
515
516 if len(response.Comments) == 0 {
517 t.Fatal("Expected at least one comment in response")
518 }
519
520 // Find our comment
521 var foundComment *comments.CommentView
522 for _, threadView := range response.Comments {
523 if threadView.Comment.URI == commentURI {
524 foundComment = threadView.Comment
525 break
526 }
527 }
528
529 if foundComment == nil {
530 t.Fatal("Expected to find test comment in response")
531 }
532
533 // Verify viewer state exists but no vote
534 if foundComment.Viewer == nil {
535 t.Fatal("Expected viewer state for authenticated request")
536 }
537 if foundComment.Viewer.Vote != nil {
538 t.Errorf("Expected viewer.vote = nil (no vote), got %v", *foundComment.Viewer.Vote)
539 }
540 if foundComment.Viewer.VoteURI != nil {
541 t.Errorf("Expected viewer.voteUri = nil (no vote), got %v", *foundComment.Viewer.VoteURI)
542 }
543 })
544
545 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) {
546 // Query without authentication
547 // Use factory constructor with nil factory - this test only uses the read path (GetComments)
548 commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
549 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
550 PostURI: testPostURI,
551 Sort: "new",
552 Depth: 10,
553 Limit: 100,
554 ViewerDID: nil, // No authentication
555 })
556 if err != nil {
557 t.Fatalf("Failed to get comments: %v", err)
558 }
559
560 if len(response.Comments) > 0 {
561 // Verify no viewer state
562 if response.Comments[0].Comment.Viewer != nil {
563 t.Error("Expected viewer = nil for unauthenticated request")
564 }
565 }
566 })
567}