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