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}